Compare commits

...

36 Commits

Author SHA1 Message Date
Eli Bosley
86b6c4f85b fix: inject Tailwind CSS into client entry point (#1537)
Added a Vite plugin to automatically inject the Tailwind CSS import into
the `unraid-components.client.js` entry file, enhancing the integration
of Tailwind CSS within the application. This change improves the setup
for styling components consistently across the project.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added automated validation to ensure Tailwind CSS styles are correctly
included in the custom elements build output.

* **Chores**
* Updated the build process to include a CSS validation step after
manifest generation.
* Enhanced development build configuration to enable CSS source maps and
optimize Tailwind CSS injection into web components.
  * Extended CSS theme with new responsive breakpoint variables.
* Improved CSS class specificity in user profile, server state, and
update modal components for consistent styling.
* Removed redundant style blocks and global CSS imports from multiple
components to streamline styling and reduce duplication.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-23 15:30:57 -04:00
Pujit Mehrotra
45bd73698b fix(connect): omit extraneous fields during connect config validation (#1538)
Prevent extraneous fields from migrating to `connect.json` from
`myservers.cfg`
2025-07-23 13:55:35 -04:00
Pujit Mehrotra
fee7d4613e refactor: add & use ConfigFilePersister primitive (#1534)
Add `ConfigFilePersister<T>` to provide automatic JSON file persistence
for configs. It bridges the gap between in-memory configuration (via
`ConfigService`) and persistent file storage, with minimal developer
effort.

## Key Features

- **Reactive Persistence**: Automatically saves config changes to disk
when `ConfigService` updates
- **NestJS Integration**: Implements lifecycle hooks for proper
initialization and cleanup
- **Standalone Operations**: Provides direct file access via
`getFileHandler()` for non-reactive use cases
- **Change Detection**: Only writes to disk when configuration actually
changes (performance optimization)
- **Error Handling**: Includes logging and graceful error handling
throughout

## How to Implement

Extend the class and implement these required methods:

```typescript
@Injectable()
class MyConfigPersister extends ConfigFilePersister<MyConfigType> {
  constructor(configService: ConfigService) {
    super(configService);
  }

  // Required: JSON filename in config directory
  fileName(): string { 
    return "my-config.json"; 
  }

  // Required: ConfigService key for reactive updates
  configKey(): string { 
    return "myConfig"; 
  }

  // Required: Default values for new installations
  defaultConfig(): MyConfigType {
    return { enabled: false, timeout: 5000 };
  }

  // optionally, override validate() and/or migrateConfig()
}
```

## Lifecycle Behavior

- **Initialization** (`onModuleInit`): Loads config from disk → sets in
ConfigService → starts reactive subscription
- **Runtime**: Automatically persists to disk when ConfigService changes
(buffered every 25ms)
- **Shutdown** (`onModuleDestroy`): Final persistence and cleanup

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

## Summary by CodeRabbit

* **New Features**
* Introduced a unified, robust configuration file management system with
automatic migration, validation, and efficient persistence for plugins
and services.

* **Refactor**
* Centralized configuration persistence logic into a shared base class,
simplifying and standardizing config handling.
* Refactored multiple config persisters to extend the new base class,
removing redundant manual file and lifecycle management.
* Removed legacy config state management, persistence helpers, and
related modules, streamlining the codebase.
* Simplified test suites to focus on core functionality and error
handling.

* **Chores**
* Updated dependencies to support the new configuration management
system.
* Integrated the new API config module into plugin modules for
consistent configuration handling.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-23 13:34:12 -04:00
Michael Datelle
b6acf50c0d refactor: update modals and color picker (#1494)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* The Welcome modal now automatically appears when visiting the
`/welcome` page.
* "Create a password" button in the Welcome modal is now disabled while
loading.

* **Refactor**
* Activation and Welcome modals now use a new Dialog component for
improved layout and styling.
* Theme and server selection components now use a simplified Select
dropdown with options passed as data for a cleaner interface.

* **Tests**
* Updated modal-related tests to use the new Dialog component and
improved mocking for more accurate and maintainable test coverage.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: mdatelle <mike@datelle.net>
2025-07-23 08:28:31 -04:00
renovate[bot]
8279531f2b fix(deps): pin dependency @nuxt/ui to 3.2.0 (#1532)
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [@nuxt/ui](https://ui.nuxt.com)
([source](https://redirect.github.com/nuxt/ui)) | dependencies | pin |
[`^3.2.0` ->
`3.2.0`](https://renovatebot.com/diffs/npm/@nuxt%2fui/3.2.0/3.2.0) |

Add the preset `:preserveSemverRanges` to your config if you don't want
to pin your dependencies.

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/unraid/api).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MS40MC4wIiwidXBkYXRlZEluVmVyIjoiNDEuNDAuMCIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-22 14:41:25 -04:00
Eli Bosley
0a18b38008 fix: truncate log files when they take up more than 5mb of space (#1530)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Updated configuration to enable WAN access, set custom ports, and add
new fields such as version and sandbox mode.

* **Bug Fixes**
* Improved log rotation reliability by directly managing log file size
and truncation every 20 minutes, with enhanced error handling and
logging.

* **Chores**
* Removed legacy log rotation configuration files and related test cases
to streamline maintenance.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-22 14:40:39 -04:00
Eli Bosley
23b2b88461 fix: use async for primary file read/writes (#1531)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Refactor**
* Improved application performance and responsiveness by converting all
synchronous file system operations to asynchronous ones throughout the
application.
* Enhanced reliability of file checks and file writing, ensuring
non-blocking behavior during configuration, notification handling, and
service operations.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-22 14:40:30 -04:00
Eli Bosley
f5352e3a26 fix: add missing breakpoints (#1535)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Style**
* Introduced new CSS custom properties for additional responsive
breakpoints, enhancing layout adaptability across a wider range of
screen sizes.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-22 10:14:48 -04:00
Pujit Mehrotra
9dfdb8dce7 fix: make settings grid responsive (#1463)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Introduced a new SettingsGrid component for consistent and responsive
grid layouts.

* **Refactor**
* Updated settings-related layouts to use the new SettingsGrid
component, improving maintainability and visual consistency across the
interface.

* **Chores**
  * Removed an unused CSS breakpoint variable from global styles.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-22 09:21:35 -04:00
Michael Datelle
407585cd40 feat(web): install and configure nuxt ui (#1524)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Introduced a primary color palette and enhanced color theming for the
UI.
* Added and showcased new UI button variants with primary color styling
on the main page.
* Integrated the @nuxt/ui module to enable advanced UI components and
theming options.

* **Style**
* Updated keyframe animations in global styles for improved CSS
structure.
* Refined color variables and UI color states for both light and dark
modes.

* **Chores**
  * Added @nuxt/ui as a project dependency.
  * Centralized UI configuration for easier theming management.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: mdatelle <mike@datelle.net>
2025-07-21 17:14:41 -04:00
Pujit Mehrotra
05056e7ca1 fix(notifications): gracefully handle & mask invalid notifications (#1529)
prevents log explosion due to large, invalid notifications.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Improved handling of invalid or corrupted notifications by displaying
a warning message instead of causing errors or interruptions.
* Enhanced robustness in displaying notification timestamps by
gracefully handling invalid date formats.

* **Refactor**
* Improved internal date formatting for notifications, ensuring more
consistent and user-friendly display.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1210811542761865
2025-07-21 15:28:05 -04:00
renovate[bot]
a74d935b56 fix(deps): pin dependencies (#1528)
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [@tailwindcss/cli](https://tailwindcss.com)
([source](https://redirect.github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-cli))
| dependencies | pin | [`^4.1.11` ->
`4.1.11`](https://renovatebot.com/diffs/npm/@tailwindcss%2fcli/4.1.11/4.1.11)
|
| [@tailwindcss/vite](https://tailwindcss.com)
([source](https://redirect.github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite))
| devDependencies | pin | [`^4.1.11` ->
`4.1.11`](https://renovatebot.com/diffs/npm/@tailwindcss%2fvite/4.1.11/4.1.11)
|
|
[tw-animate-css](https://redirect.github.com/Wombosvideo/tw-animate-css)
| dependencies | pin | [`^1.3.5` ->
`1.3.5`](https://renovatebot.com/diffs/npm/tw-animate-css/1.3.5/1.3.5) |

Add the preset `:preserveSemverRanges` to your config if you don't want
to pin your dependencies.

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the
rebase/retry checkbox.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get
[config
help](https://redirect.github.com/renovatebot/renovate/discussions) if
that's undesired.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/unraid/api).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MS40MC4wIiwidXBkYXRlZEluVmVyIjoiNDEuNDAuMCIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-21 13:13:23 -04:00
Eli Bosley
2c62e0ad09 feat: tailwind v4 (#1522)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Streamlined Tailwind CSS integration using Vite plugin, eliminating
the need for separate Tailwind config files.
* Updated theme and color variables for improved consistency and
maintainability.

* **Style**
* Standardized spacing, sizing, and font classes across all components
using Tailwind’s default scale.
* Reduced excessive gaps, padding, and font sizes for a more compact and
cohesive UI.
* Updated gradient, border, and shadow classes to match Tailwind v4
conventions.
* Replaced custom pixel-based classes with Tailwind’s bracketed
arbitrary value syntax where needed.
* Replaced focus outline styles from `outline-none` to `outline-hidden`
for consistent focus handling.
* Updated flex shrink/grow utility classes to use newer shorthand forms.
* Converted several component templates to use self-closing tags for
cleaner markup.
  * Adjusted icon sizes and spacing for improved visual balance.

* **Chores**
* Removed legacy Tailwind/PostCSS configuration files and related
scripts.
* Updated and cleaned up package dependencies for Tailwind v4 and
related plugins.
  * Removed unused or redundant build scripts and configuration exports.
  * Updated documentation to reflect new Tailwind v4 usage.
  * Removed Prettier Tailwind plugin from formatting configurations.
* Removed Nuxt Tailwind module in favor of direct Vite plugin
integration.
  * Cleaned up ESLint config by removing Prettier integration.

* **Bug Fixes**
  * Corrected invalid or outdated Tailwind class names and syntax.
* Fixed issues with max-width and other utility classes for improved
layout consistency.

* **Tests**
* Updated test assertions to match new class names and styling
conventions.

* **Documentation**
* Revised README and internal notes to clarify Tailwind v4 adoption and
configuration changes.
* Added new development notes emphasizing Tailwind v4 usage and
documentation references.

* **UI Components**
* Enhanced BrandButton stories with detailed variant, size, and padding
showcases for better visual testing.
* Improved theme store to apply dark mode class on both `<html>` and
`<body>` elements for compatibility.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-21 09:58:02 -04:00
renovate[bot]
1a8da6d92b fix(deps): update all non-major dependencies (#1510)
This PR contains the following updates:

| Package | Change | Age | Confidence |
|---|---|---|---|
| [@eslint/js](https://eslint.org)
([source](https://redirect.github.com/eslint/eslint/tree/HEAD/packages/js))
| [`9.30.1` ->
`9.31.0`](https://renovatebot.com/diffs/npm/@eslint%2fjs/9.30.1/9.31.0)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/@eslint%2fjs/9.31.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@eslint%2fjs/9.30.1/9.31.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
| [@rollup/rollup-linux-x64-gnu](https://rollupjs.org/)
([source](https://redirect.github.com/rollup/rollup)) | [`4.44.2` ->
`4.45.1`](https://renovatebot.com/diffs/npm/@rollup%2frollup-linux-x64-gnu/4.44.2/4.45.1)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/@rollup%2frollup-linux-x64-gnu/4.45.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@rollup%2frollup-linux-x64-gnu/4.44.2/4.45.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
|
[@storybook/addon-docs](https://redirect.github.com/storybookjs/storybook/tree/next/code/addons/docs)
([source](https://redirect.github.com/storybookjs/storybook/tree/HEAD/code/addons/docs))
| [`9.0.16` ->
`9.0.17`](https://renovatebot.com/diffs/npm/@storybook%2faddon-docs/9.0.16/9.0.17)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/@storybook%2faddon-docs/9.0.17?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@storybook%2faddon-docs/9.0.16/9.0.17?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
|
[@storybook/addon-links](https://redirect.github.com/storybookjs/storybook/tree/next/code/addons/links)
([source](https://redirect.github.com/storybookjs/storybook/tree/HEAD/code/addons/links))
| [`9.0.16` ->
`9.0.17`](https://renovatebot.com/diffs/npm/@storybook%2faddon-links/9.0.16/9.0.17)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/@storybook%2faddon-links/9.0.17?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@storybook%2faddon-links/9.0.16/9.0.17?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
|
[@storybook/builder-vite](https://redirect.github.com/storybookjs/storybook/tree/next/code/builders/builder-vite/#readme)
([source](https://redirect.github.com/storybookjs/storybook/tree/HEAD/code/builders/builder-vite))
| [`9.0.16` ->
`9.0.17`](https://renovatebot.com/diffs/npm/@storybook%2fbuilder-vite/9.0.16/9.0.17)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/@storybook%2fbuilder-vite/9.0.17?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@storybook%2fbuilder-vite/9.0.16/9.0.17?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
|
[@storybook/vue3-vite](https://redirect.github.com/storybookjs/storybook/tree/next/code/frameworks/vue3-vite)
([source](https://redirect.github.com/storybookjs/storybook/tree/HEAD/code/frameworks/vue3-vite))
| [`9.0.16` ->
`9.0.17`](https://renovatebot.com/diffs/npm/@storybook%2fvue3-vite/9.0.16/9.0.17)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/@storybook%2fvue3-vite/9.0.17?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@storybook%2fvue3-vite/9.0.16/9.0.17?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
| [@swc/core](https://swc.rs)
([source](https://redirect.github.com/swc-project/swc)) | [`1.12.11` ->
`1.12.14`](https://renovatebot.com/diffs/npm/@swc%2fcore/1.12.11/1.12.14)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/@swc%2fcore/1.12.14?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@swc%2fcore/1.12.11/1.12.14?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
|
[@types/node](https://redirect.github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/node)
([source](https://redirect.github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node))
| [`22.16.3` ->
`22.16.4`](https://renovatebot.com/diffs/npm/@types%2fnode/22.16.3/22.16.4)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/@types%2fnode/22.16.4?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@types%2fnode/22.16.3/22.16.4?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
|
[@typescript-eslint/eslint-plugin](https://typescript-eslint.io/packages/eslint-plugin)
([source](https://redirect.github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin))
| [`8.36.0` ->
`8.37.0`](https://renovatebot.com/diffs/npm/@typescript-eslint%2feslint-plugin/8.36.0/8.37.0)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/@typescript-eslint%2feslint-plugin/8.37.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@typescript-eslint%2feslint-plugin/8.36.0/8.37.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
| [cron](https://redirect.github.com/kelektiv/node-cron) | [`4.3.1` ->
`4.3.2`](https://renovatebot.com/diffs/npm/cron/4.3.1/4.3.2) |
[![age](https://developer.mend.io/api/mc/badges/age/npm/cron/4.3.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/cron/4.3.1/4.3.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
| [eslint](https://eslint.org)
([source](https://redirect.github.com/eslint/eslint)) | [`9.30.1` ->
`9.31.0`](https://renovatebot.com/diffs/npm/eslint/9.30.1/9.31.0) |
[![age](https://developer.mend.io/api/mc/badges/age/npm/eslint/9.31.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/eslint/9.30.1/9.31.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
|
[eslint-plugin-storybook](https://redirect.github.com/storybookjs/storybook/code/lib/eslint-plugin#readme)
([source](https://redirect.github.com/storybookjs/storybook/tree/HEAD/code/lib/eslint-plugin))
| [`9.0.16` ->
`9.0.17`](https://renovatebot.com/diffs/npm/eslint-plugin-storybook/9.0.16/9.0.17)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/eslint-plugin-storybook/9.0.17?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/eslint-plugin-storybook/9.0.16/9.0.17?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
| [graphql-ws](https://the-guild.dev/graphql/ws)
([source](https://redirect.github.com/enisdenjo/graphql-ws)) | [`6.0.5`
-> `6.0.6`](https://renovatebot.com/diffs/npm/graphql-ws/6.0.5/6.0.6) |
[![age](https://developer.mend.io/api/mc/badges/age/npm/graphql-ws/6.0.6?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/graphql-ws/6.0.5/6.0.6?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
| [nuxt](https://nuxt.com)
([source](https://redirect.github.com/nuxt/nuxt/tree/HEAD/packages/nuxt))
| [`3.17.6` ->
`3.17.7`](https://renovatebot.com/diffs/npm/nuxt/3.17.6/3.17.7) |
[![age](https://developer.mend.io/api/mc/badges/age/npm/nuxt/3.17.7?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/nuxt/3.17.6/3.17.7?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
| [storybook](https://storybook.js.org)
([source](https://redirect.github.com/storybookjs/storybook/tree/HEAD/code/core))
| [`9.0.16` ->
`9.0.17`](https://renovatebot.com/diffs/npm/storybook/9.0.16/9.0.17) |
[![age](https://developer.mend.io/api/mc/badges/age/npm/storybook/9.0.17?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/storybook/9.0.16/9.0.17?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
|
[typescript-eslint](https://typescript-eslint.io/packages/typescript-eslint)
([source](https://redirect.github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint))
| [`8.36.0` ->
`8.37.0`](https://renovatebot.com/diffs/npm/typescript-eslint/8.36.0/8.37.0)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/typescript-eslint/8.37.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/typescript-eslint/8.36.0/8.37.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
| [zx](https://google.github.io/zx/)
([source](https://redirect.github.com/google/zx)) | [`8.6.2` ->
`8.7.1`](https://renovatebot.com/diffs/npm/zx/8.3.2/8.7.1) |
[![age](https://developer.mend.io/api/mc/badges/age/npm/zx/8.7.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/zx/8.3.2/8.7.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
| [zx](https://google.github.io/zx/)
([source](https://redirect.github.com/google/zx)) | [`8.6.2` ->
`8.7.1`](https://renovatebot.com/diffs/npm/zx/8.6.2/8.7.1) |
[![age](https://developer.mend.io/api/mc/badges/age/npm/zx/8.7.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/zx/8.6.2/8.7.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|

---

### Release Notes

<details>
<summary>eslint/eslint (@&#8203;eslint/js)</summary>

###
[`v9.31.0`](https://redirect.github.com/eslint/eslint/compare/v9.30.1...d5054e5454a537e9ade238c768c262c6c592cbc1)

[Compare
Source](https://redirect.github.com/eslint/eslint/compare/v9.30.1...v9.31.0)

</details>

<details>
<summary>rollup/rollup (@&#8203;rollup/rollup-linux-x64-gnu)</summary>

###
[`v4.45.1`](https://redirect.github.com/rollup/rollup/blob/HEAD/CHANGELOG.md#4451)

[Compare
Source](https://redirect.github.com/rollup/rollup/compare/v4.45.0...v4.45.1)

*2025-07-15*

##### Bug Fixes

- Resolve crash when using certain conditional expressions
([#&#8203;6009](https://redirect.github.com/rollup/rollup/issues/6009))

##### Pull Requests

- [#&#8203;6009](https://redirect.github.com/rollup/rollup/pull/6009):
Add hasDeoptimizedCache flag for ConditionalExpression
([@&#8203;TrickyPi](https://redirect.github.com/TrickyPi))

###
[`v4.45.0`](https://redirect.github.com/rollup/rollup/blob/HEAD/CHANGELOG.md#4450)

[Compare
Source](https://redirect.github.com/rollup/rollup/compare/v4.44.2...v4.45.0)

*2025-07-12*

##### Features

- Improve tree-shaking when both branches of a conditional expression
return the same boolean value
([#&#8203;6000](https://redirect.github.com/rollup/rollup/issues/6000))
- In environments that support both CJS and ESM, prefer the ESM build of
Rollup
([#&#8203;6005](https://redirect.github.com/rollup/rollup/issues/6005))

##### Bug Fixes

- Ensure static blocks do not prevent tree-shaking if they access `this`
([#&#8203;6001](https://redirect.github.com/rollup/rollup/issues/6001))

##### Pull Requests

- [#&#8203;6000](https://redirect.github.com/rollup/rollup/pull/6000):
feat: improve get literal value for conditional expression
([@&#8203;ahabhgk](https://redirect.github.com/ahabhgk),
[@&#8203;lukastaegert](https://redirect.github.com/lukastaegert))
- [#&#8203;6001](https://redirect.github.com/rollup/rollup/pull/6001):
Correct the parent scope for static blocks
([@&#8203;TrickyPi](https://redirect.github.com/TrickyPi),
[@&#8203;lukastaegert](https://redirect.github.com/lukastaegert))
- [#&#8203;6005](https://redirect.github.com/rollup/rollup/pull/6005):
fix: export field order prefer esm
([@&#8203;DylanPiercey](https://redirect.github.com/DylanPiercey))

</details>

<details>
<summary>storybookjs/storybook (@&#8203;storybook/addon-docs)</summary>

###
[`v9.0.17`](https://redirect.github.com/storybookjs/storybook/compare/v9.0.16...06a11ce246b2e7a52d41e43420e37162c55133aa)

[Compare
Source](https://redirect.github.com/storybookjs/storybook/compare/v9.0.16...v9.0.17)

</details>

<details>
<summary>storybookjs/storybook (@&#8203;storybook/addon-links)</summary>

###
[`v9.0.17`](https://redirect.github.com/storybookjs/storybook/compare/v9.0.16...06a11ce246b2e7a52d41e43420e37162c55133aa)

[Compare
Source](https://redirect.github.com/storybookjs/storybook/compare/v9.0.16...v9.0.17)

</details>

<details>
<summary>storybookjs/storybook
(@&#8203;storybook/builder-vite)</summary>

###
[`v9.0.17`](https://redirect.github.com/storybookjs/storybook/blob/HEAD/CHANGELOG.md#9017)

[Compare
Source](https://redirect.github.com/storybookjs/storybook/compare/v9.0.16...v9.0.17)

- Addon Vitest: Fix support for plain `stories.tsx` files -
[#&#8203;32041](https://redirect.github.com/storybookjs/storybook/pull/32041),
thanks [@&#8203;ghengeveld](https://redirect.github.com/ghengeveld)!
- Onboarding: Intent survey -
[#&#8203;31944](https://redirect.github.com/storybookjs/storybook/pull/31944),
thanks [@&#8203;ghengeveld](https://redirect.github.com/ghengeveld)!
- UI: Fix text color for failing stories in sidebar -
[#&#8203;32042](https://redirect.github.com/storybookjs/storybook/pull/32042),
thanks [@&#8203;ghengeveld](https://redirect.github.com/ghengeveld)!

</details>

<details>
<summary>swc-project/swc (@&#8203;swc/core)</summary>

###
[`v1.12.14`](https://redirect.github.com/swc-project/swc/blob/HEAD/CHANGELOG.md#11214---2025-07-14)

[Compare
Source](https://redirect.github.com/swc-project/swc/compare/v1.12.11...v1.12.14)

##### Bug Fixes

- **(es/minifier)** Don't inline arrow when it contain `this`
([#&#8203;10825](https://redirect.github.com/swc-project/swc/issues/10825))
([8b43bb3](8b43bb35bc))

- **(es/parser)** Make `export` in NS to not affect file type
([#&#8203;10799](https://redirect.github.com/swc-project/swc/issues/10799))
([ae22033](ae22033dc4))

- **(es/parser)** Correctly check ambient context
([#&#8203;10802](https://redirect.github.com/swc-project/swc/issues/10802))
([f97ea03](f97ea03523))

##### Features

- **(es/parser)** Enable support for dynamic import with `defer` phase
([#&#8203;10845](https://redirect.github.com/swc-project/swc/issues/10845))
([097d29d](097d29d21c))

- **(plugin)** Remove `bytecheck` to make Wasm plugins backward
compatible
([#&#8203;10842](https://redirect.github.com/swc-project/swc/issues/10842))
([30ad808](30ad80809c))

##### Miscellaneous Tasks

- **(bindings)** Fix dependency issues
([7c57fbb](7c57fbb103))

- **(deps)** Update `class-validator` to avoid comments
([#&#8203;10819](https://redirect.github.com/swc-project/swc/issues/10819))
([bacfa4b](bacfa4b56d))

- **(ide)** Enable `--workspace` for rust-analyzer check
([#&#8203;10809](https://redirect.github.com/swc-project/swc/issues/10809))
([92647ff](92647ff9d9))

##### Performance

- **(es/minifier)** Use `u8` for `remaining_depth`
([#&#8203;10833](https://redirect.github.com/swc-project/swc/issues/10833))
([ed6956a](ed6956a46e))

- **(hstr)** Inline one more byte
([#&#8203;10817](https://redirect.github.com/swc-project/swc/issues/10817))
([3886c97](3886c9720d))

- **(hstr)** Remove static tag
([#&#8203;10832](https://redirect.github.com/swc-project/swc/issues/10832))
([66ae1e8](66ae1e8d5a))

##### Refactor

- **(es/helpers)** Make inline helpers optional at compile time
([#&#8203;10808](https://redirect.github.com/swc-project/swc/issues/10808))
([53f3881](53f38811cc))

- **(es/lexer)** Don't store buffer in lexer
([#&#8203;10830](https://redirect.github.com/swc-project/swc/issues/10830))
([cac40f1](cac40f135d))

- **(es/lints)** Remove warnings without features
([#&#8203;10794](https://redirect.github.com/swc-project/swc/issues/10794))
([41d507f](41d507fe1e))

- **(es/parser)** Reduce token query
([#&#8203;10834](https://redirect.github.com/swc-project/swc/issues/10834))
([5cd5185](5cd5185a7a))

- **(es/parser)** Reduce call to `parse_decorators`
([#&#8203;10846](https://redirect.github.com/swc-project/swc/issues/10846))
([356d3a0](356d3a0850))

- **(es/parser)** Remove duplicate check
([#&#8203;10847](https://redirect.github.com/swc-project/swc/issues/10847))
([2b04efd](2b04efd540))

- **(es/preset-env)** Use strpool,phf for corejs2 data
([#&#8203;10803](https://redirect.github.com/swc-project/swc/issues/10803))
([1652fd8](1652fd8038))

- **(es/react)** Remove redundant `replace` calls
([#&#8203;10795](https://redirect.github.com/swc-project/swc/issues/10795))
([a670b37](a670b37c33))

- **(es/react)** Remove `count_children`
([#&#8203;10818](https://redirect.github.com/swc-project/swc/issues/10818))
([2116ab2](2116ab2fa2))

- **(hstr)** Cleanup duplicate header
([#&#8203;10812](https://redirect.github.com/swc-project/swc/issues/10812))
([630dde9](630dde93c9))

- **(hstr)** Make the deallocation of `Atom`s explicit
([#&#8203;10813](https://redirect.github.com/swc-project/swc/issues/10813))
([406433d](406433d55d))

- **(hstr)** Remove `is_global`
([#&#8203;10820](https://redirect.github.com/swc-project/swc/issues/10820))
([afda0f9](afda0f9d0d))

##### Testing

- **(es/plugin)** Test memory layout of archived types
([#&#8203;10841](https://redirect.github.com/swc-project/swc/issues/10841))
([502e991](502e991a8b))

</details>

<details>
<summary>typescript-eslint/typescript-eslint
(@&#8203;typescript-eslint/eslint-plugin)</summary>

###
[`v8.37.0`](https://redirect.github.com/typescript-eslint/typescript-eslint/blob/HEAD/packages/eslint-plugin/CHANGELOG.md#8370-2025-07-14)

[Compare
Source](https://redirect.github.com/typescript-eslint/typescript-eslint/compare/v8.36.0...v8.37.0)

##### 🩹 Fixes

- **eslint-plugin:** \[unified-signatures] fix false positives for
ignoreOverloadsWithDifferentJSDoc option
([#&#8203;11381](https://redirect.github.com/typescript-eslint/typescript-eslint/pull/11381))

##### ❤️ Thank You

- Yukihiro Hasegawa [@&#8203;y-hsgw](https://redirect.github.com/y-hsgw)

You can read about our [versioning
strategy](https://main--typescript-eslint.netlify.app/users/versioning)
and
[releases](https://main--typescript-eslint.netlify.app/users/releases)
on our website.

</details>

<details>
<summary>kelektiv/node-cron (cron)</summary>

###
[`v4.3.2`](https://redirect.github.com/kelektiv/node-cron/blob/HEAD/CHANGELOG.md#432-2025-07-13)

[Compare
Source](https://redirect.github.com/kelektiv/node-cron/compare/v4.3.1...v4.3.2)

##### 🛠 Builds

- **deps:** update dependency luxon to ~3.7.0
([db69c74](db69c74501))

##### ♻️ Chores

- **action:** update github/codeql-action action to v3.29.0
([#&#8203;990](https://redirect.github.com/kelektiv/node-cron/issues/990))
([a3fbb3c](a3fbb3cc4d))
- **action:** update github/codeql-action action to v3.29.2
([0403c53](0403c53320))
- **action:** update marocchino/sticky-pull-request-comment action to
v2.9.3
([eda0c4d](eda0c4df35))
- **action:** update ossf/scorecard-action action to v2.4.2
([#&#8203;991](https://redirect.github.com/kelektiv/node-cron/issues/991))
([29a3a60](29a3a604ef))
- **action:** update step-security/harden-runner action to v2.12.1
([ba49a56](ba49a5656c))
- **action:** update step-security/harden-runner action to v2.12.2
([845202e](845202ee97))
- **deps:** lock file maintenance
([#&#8203;989](https://redirect.github.com/kelektiv/node-cron/issues/989))
([bc1bf72](bc1bf72ff7))
- **deps:** lock file maintenance
([#&#8203;999](https://redirect.github.com/kelektiv/node-cron/issues/999))
([e78d986](e78d9869d6))
- **deps:** update dependency
[@&#8203;swc](https://redirect.github.com/swc)/core to v1.12.1
([#&#8203;992](https://redirect.github.com/kelektiv/node-cron/issues/992))
([b5d3bd3](b5d3bd3328))
- **deps:** update dependency
[@&#8203;swc](https://redirect.github.com/swc)/core to v1.12.5
([d374494](d374494609))
- **deps:** update dependency
[@&#8203;swc](https://redirect.github.com/swc)/core to v1.12.9
([8060c41](8060c41685))
- **deps:** update dependency
[@&#8203;types](https://redirect.github.com/types)/node to v22.15.32
([#&#8203;993](https://redirect.github.com/kelektiv/node-cron/issues/993))
([ce9743b](ce9743ba05))
- **deps:** update dependency
[@&#8203;types](https://redirect.github.com/types)/node to v22.16.0
([7bae5b1](7bae5b1ef8))
- **deps:** update linters
([24eb53f](24eb53ff67))
- **deps:** update linters
([#&#8203;995](https://redirect.github.com/kelektiv/node-cron/issues/995))
([9395484](9395484758))
- **deps:** update node.js to v23.11.1
([#&#8203;985](https://redirect.github.com/kelektiv/node-cron/issues/985))
([674a344](674a3448b5))
- **deps:** update semantic-release related packages
([cc2676a](cc2676aa88))
- **deps:** update semantic-release related packages
([#&#8203;994](https://redirect.github.com/kelektiv/node-cron/issues/994))
([4d738df](4d738df05f))

</details>

<details>
<summary>eslint/eslint (eslint)</summary>

###
[`v9.31.0`](https://redirect.github.com/eslint/eslint/compare/v9.30.1...14053edc64bd378ab920575f2488fbfcbb5a4ea0)

[Compare
Source](https://redirect.github.com/eslint/eslint/compare/v9.30.1...v9.31.0)

</details>

<details>
<summary>enisdenjo/graphql-ws (graphql-ws)</summary>

###
[`v6.0.6`](https://redirect.github.com/enisdenjo/graphql-ws/blob/HEAD/CHANGELOG.md#606)

[Compare
Source](https://redirect.github.com/enisdenjo/graphql-ws/compare/v6.0.5...v6.0.6)

##### Patch Changes

-
[#&#8203;648](https://redirect.github.com/enisdenjo/graphql-ws/pull/648)
[`1f53bb4`](1f53bb48b1)
Thanks [@&#8203;enisdenjo](https://redirect.github.com/enisdenjo)! - Fix
building issues causing CJS type definitions referencing ESM modules

</details>

<details>
<summary>nuxt/nuxt (nuxt)</summary>

###
[`v3.17.7`](https://redirect.github.com/nuxt/nuxt/releases/tag/v3.17.7)

[Compare
Source](https://redirect.github.com/nuxt/nuxt/compare/v3.17.6...v3.17.7)

> 3.17.7 is the last patch release before v3.18.

#####  Upgrading

Our recommendation for upgrading is to run:

```sh
npx nuxt upgrade --dedupe
```

This will deduplicate your lockfile as well, and help ensure that you
pull in updates from other dependencies that Nuxt relies on,
particularly in the unjs ecosystem.

##### 👉 Changelog

[compare
changes](https://redirect.github.com/nuxt/nuxt/compare/v3.17.6...v3.17.7)

##### 🩹 Fixes

- **nuxt:** Safe-guard `extraPageMetaExtractionKeys`
([#&#8203;32510](https://redirect.github.com/nuxt/nuxt/pull/32510))
- **nuxt:** Expose `loadBuilder` error cause
([8f13ce3c2](https://redirect.github.com/nuxt/nuxt/commit/8f13ce3c2))
- **vite:** Handle resolving string vite input
([#&#8203;32527](https://redirect.github.com/nuxt/nuxt/pull/32527))
- **nuxt:** Wrap only server components with island generic
([#&#8203;32540](https://redirect.github.com/nuxt/nuxt/pull/32540))
- **vite:** Ignore when client entry cannot be resolved
([19a292f34](https://redirect.github.com/nuxt/nuxt/commit/19a292f34))
- **nuxt:** Normalize segment catchall pattern before checking for
parent
([#&#8203;32413](https://redirect.github.com/nuxt/nuxt/pull/32413))
- **nuxt:** Update warning message to warn against `null` values
([c1b83eab5](https://redirect.github.com/nuxt/nuxt/commit/c1b83eab5))
- **nuxt:** Ensure `semver.satisfies` returns true for pre-release
versions
([#&#8203;32574](https://redirect.github.com/nuxt/nuxt/pull/32574))
- **nuxt:** Scroll to anchor if present when changing page without saved
position
([#&#8203;32376](https://redirect.github.com/nuxt/nuxt/pull/32376))
- **nuxt:** Handle `execute being passed to `watch\`
([#&#8203;32591](https://redirect.github.com/nuxt/nuxt/pull/32591))

##### 📖 Documentation

- Update fetch types
([#&#8203;32522](https://redirect.github.com/nuxt/nuxt/pull/32522))
- Clarify that runtime env variables must start with `NUXT_`
([#&#8203;32223](https://redirect.github.com/nuxt/nuxt/pull/32223))
- Fix key change behavior in `useAsyncData` and `useFetch` migration
([#&#8203;32560](https://redirect.github.com/nuxt/nuxt/pull/32560))
- Change return type of async data from `undefined` to `null` in v3 docs
([#&#8203;32562](https://redirect.github.com/nuxt/nuxt/pull/32562))
- Add section on custom hooks for Nuxt modules
([#&#8203;32586](https://redirect.github.com/nuxt/nuxt/pull/32586))
- Provide `async` keyword
([#&#8203;32587](https://redirect.github.com/nuxt/nuxt/pull/32587))
- Move augmenting hook types in hooks page
([#&#8203;32595](https://redirect.github.com/nuxt/nuxt/pull/32595))
- Add section about module loading order
([#&#8203;32597](https://redirect.github.com/nuxt/nuxt/pull/32597))

#####  Tests

- Reenable skipped unit tests
([8fc9b9ee9](https://redirect.github.com/nuxt/nuxt/commit/8fc9b9ee9))
- Update test snapshot for `generateTypes`
([c0855439d](https://redirect.github.com/nuxt/nuxt/commit/c0855439d))
- Improve page scanning test stability
([84b96f3de](https://redirect.github.com/nuxt/nuxt/commit/84b96f3de))
- Pass timeZone in to `<NuxtTime>` test
([#&#8203;32558](https://redirect.github.com/nuxt/nuxt/pull/32558))
- Add more useAsyncData + useFetch tests
([#&#8203;32585](https://redirect.github.com/nuxt/nuxt/pull/32585))
- Avoid hard-coding async-data keys
([bfca95118](https://redirect.github.com/nuxt/nuxt/commit/bfca95118))

##### ❤️ Contributors

- Daniel Roe
([@&#8203;danielroe](https://redirect.github.com/danielroe))
- Julien Huang
([@&#8203;huang-julien](https://redirect.github.com/huang-julien))
- abeer0 ([@&#8203;iiio2](https://redirect.github.com/iiio2))
- Bobbie Goede
([@&#8203;BobbieGoede](https://redirect.github.com/BobbieGoede))
- Damian Głowala
([@&#8203;DamianGlowala](https://redirect.github.com/DamianGlowala))
- Nestor Vera ([@&#8203;hacknug](https://redirect.github.com/hacknug))
- Ezra Ashenafi ([@&#8203;Eazash](https://redirect.github.com/Eazash))
- Mike Laumann Bellika
([@&#8203;MikeBellika](https://redirect.github.com/MikeBellika))
- Maxime Pauvert
([@&#8203;maximepvrt](https://redirect.github.com/maximepvrt))
- Chriest Yu ([@&#8203;jcppman](https://redirect.github.com/jcppman))
- Andrei Hudalla
([@&#8203;paranoidPhantom](https://redirect.github.com/paranoidPhantom))
- Sigrid Huemer ([@&#8203;s1gr1d](https://redirect.github.com/s1gr1d))
- xjccc ([@&#8203;xjccc](https://redirect.github.com/xjccc))

</details>

<details>
<summary>storybookjs/storybook (storybook)</summary>

###
[`v9.0.17`](https://redirect.github.com/storybookjs/storybook/compare/v9.0.16...06a11ce246b2e7a52d41e43420e37162c55133aa)

[Compare
Source](https://redirect.github.com/storybookjs/storybook/compare/v9.0.16...v9.0.17)

</details>

<details>
<summary>typescript-eslint/typescript-eslint
(typescript-eslint)</summary>

###
[`v8.37.0`](https://redirect.github.com/typescript-eslint/typescript-eslint/blob/HEAD/packages/typescript-eslint/CHANGELOG.md#8370-2025-07-14)

[Compare
Source](https://redirect.github.com/typescript-eslint/typescript-eslint/compare/v8.36.0...v8.37.0)

##### 🚀 Features

- **typescript-estree:** infer tsconfigRootDir from call stack
([#&#8203;11370](https://redirect.github.com/typescript-eslint/typescript-eslint/pull/11370))

##### ❤️ Thank You

- Josh Goldberg 

You can read about our [versioning
strategy](https://main--typescript-eslint.netlify.app/users/versioning)
and
[releases](https://main--typescript-eslint.netlify.app/users/releases)
on our website.

</details>

<details>
<summary>google/zx (zx)</summary>

###
[`v8.7.1`](https://redirect.github.com/google/zx/releases/tag/8.7.1): —
Pipe Whisperer

[Compare
Source](https://redirect.github.com/google/zx/compare/8.7.0...8.7.1)

Continues
[v8.7.0](https://redirect.github.com/google/zx/releases/tag/8.7.0):
handles new `ps()` corner case and improves `$.kill` mechanics on
Windows
[#&#8203;1266](https://redirect.github.com/google/zx/issues/1266)
[#&#8203;1267](https://redirect.github.com/google/zx/pull/1267)
[#&#8203;1269](https://redirect.github.com/google/zx/pull/1269)
[webpod/ps#14](https://redirect.github.com/webpod/ps/pull/14)

###
[`v8.7.0`](https://redirect.github.com/google/zx/releases/tag/8.7.0): —
Solder Savior

[Compare
Source](https://redirect.github.com/google/zx/compare/8.6.2...8.7.0)

Important fixes for annoying flaky bugs

#### kill() 🐞

We've found an interesting case
[#&#8203;1262](https://redirect.github.com/google/zx/pull/1262)

```js
const p = $`sleep 1000`
const {pid} = p // 12345
await p.kill()
```

If we kill the process again, the result might be unexpected:

```js
await ps({pid}) // {pid: 12345, ppid: 67890, command: 'another command', ...}
p.kill()
```

This happens because the `pid` may be reused by the system for another
process, so we've added extra assertions to prevent indeterminacy:

```js
p.kill()  // Error: Too late to kill the process.
p.abort() // Error: Too late to abort the process.
```

#### ps() 🐛

- `ps()` uses
**[wmic](https://en.wikipedia.org/wiki/Windows_Management_Instrumentation)**
internally on Windows, it relies on *fragile* heuristics to parse the
output. We have improved this logic to handle more format variants, but
over time (in v9 maybe) we're planning to change the approach.

[#&#8203;1256](https://redirect.github.com/google/zx/pull/1256)
[#&#8203;1263](https://redirect.github.com/google/zx/issues/1263)
[webpod/ps#12](https://redirect.github.com/webpod/ps/pull/12)
[webpod/ingrid#6](https://redirect.github.com/webpod/ingrid/pull/6)

```js
const [root] = await ps.lookup({ pid: process.pid })
assert.equal(root.pid, process.pid)
```

###
[`v8.6.2`](https://redirect.github.com/google/zx/releases/tag/8.6.2): —
Flow Unstoppable

[Compare
Source](https://redirect.github.com/google/zx/compare/8.6.1...8.6.2)

Fixes `$.prefix` & `$.postfix` values settings via env variables
[#&#8203;1261](https://redirect.github.com/google/zx/pull/1261)
[#&#8203;1260](https://redirect.github.com/google/zx/issues/1260)

###
[`v8.6.1`](https://redirect.github.com/google/zx/releases/tag/8.6.1): —
Drain Hero

[Compare
Source](https://redirect.github.com/google/zx/compare/8.6.0...8.6.1)

- Use `process.env.SHELL` as default shell if defined
[#&#8203;1252](https://redirect.github.com/google/zx/pull/1252)

```bash
SHELL=/bin/zsh zx script.js
```

- Accept numeric strings as `parseDuration()` arg
[#&#8203;1249](https://redirect.github.com/google/zx/pull/1249)

```js
await sleep(1000)   // 1 second
await sleep('1000') // 1 second
```

- Update docker base image to `node:24-alpine`
[#&#8203;1239](https://redirect.github.com/google/zx/pull/1239)
- Docs improvements
[#&#8203;1242](https://redirect.github.com/google/zx/pull/1242)
[#&#8203;1243](https://redirect.github.com/google/zx/pull/1243)
[#&#8203;1246](https://redirect.github.com/google/zx/pull/1246)
[#&#8203;1248](https://redirect.github.com/google/zx/pull/1248)
[#&#8203;1251](https://redirect.github.com/google/zx/pull/1251)

###
[`v8.6.0`](https://redirect.github.com/google/zx/releases/tag/8.6.0): —
Valve Vanguard

[Compare
Source](https://redirect.github.com/google/zx/compare/8.5.5...8.6.0)

- Enabled `thenable` params processing for `$` literals
[#&#8203;1237](https://redirect.github.com/google/zx/pull/1237)

```js
const a1 = $`echo foo`
const a2 = new Promise((resolve) => setTimeout(resolve, 20, ['bar', 'baz']))

await $`echo ${a1} ${a2}` // foo bar baz
```

- A dozen of internal refactorings
[#&#8203;1225](https://redirect.github.com/google/zx/pull/1225)
[#&#8203;1226](https://redirect.github.com/google/zx/pull/1226)
[#&#8203;1228](https://redirect.github.com/google/zx/pull/1228)
[#&#8203;1229](https://redirect.github.com/google/zx/pull/1229)
[#&#8203;1230](https://redirect.github.com/google/zx/pull/1230)
[#&#8203;1231](https://redirect.github.com/google/zx/pull/1231)
[#&#8203;1232](https://redirect.github.com/google/zx/pull/1232)
[#&#8203;1233](https://redirect.github.com/google/zx/pull/1233)
[#&#8203;1234](https://redirect.github.com/google/zx/pull/1234)
[#&#8203;1235](https://redirect.github.com/google/zx/pull/1235)
[#&#8203;1236](https://redirect.github.com/google/zx/pull/1236)
[#&#8203;1238](https://redirect.github.com/google/zx/pull/1238)
[#&#8203;1239](https://redirect.github.com/google/zx/pull/1239)
  - Deps bumping
  - Bytes shrinking
  - Docs improvements

###
[`v8.5.5`](https://redirect.github.com/google/zx/releases/tag/8.5.5): —
PVC Wizard

[Compare
Source](https://redirect.github.com/google/zx/compare/8.5.4...8.5.5)

Minor feature polish.

- `ProcessPromise` and `ProcessOutput` `lines()` getters now accept a
custom delimiter
[#&#8203;1220](https://redirect.github.com/google/zx/issues/1220)
[#&#8203;1218](https://redirect.github.com/google/zx/issues/1218)

```ts
const cwd = tempdir()
const delimiter = '\0'

const p1 = $({
  cwd
})`touch foo bar baz; find ./ -type f -print0 -maxdepth 1`
(await p1.lines(delimiter)).sort() // ['./bar', './baz', './foo']
  
// or via options
const lines = []
const p2 = $({
  delimiter,
  cwd,
})`find ./ -type f -print0 -maxdepth 1`

for await (const line of p2) {
  lines.push(line)
}

lines.sort() // ['./bar', './baz', './foo']
```

- Handle `.nothrow()` option in `ProcessProcess[AsyncIterator]`
[#&#8203;1216](https://redirect.github.com/google/zx/pull/1216)
[#&#8203;1217](https://redirect.github.com/google/zx/pull/1217)
- Updates yaml to
[v2.8.0](https://redirect.github.com/eemeli/yaml/releases/tag/v2.8.0)
[#&#8203;1221](https://redirect.github.com/google/zx/pull/1221)

###
[`v8.5.4`](https://redirect.github.com/google/zx/releases/tag/8.5.4): —
Pipe Dreamer

[Compare
Source](https://redirect.github.com/google/zx/compare/8.5.3...8.5.4)

- Fixed the `pipe(file: string)` signature type declaration
[#&#8203;1208](https://redirect.github.com/google/zx/issues/1208)
[#&#8203;1209](https://redirect.github.com/google/zx/issues/1209)

###
[`v8.5.3`](https://redirect.github.com/google/zx/releases/tag/8.5.3): —
Trap Master

[Compare
Source](https://redirect.github.com/google/zx/compare/8.5.2...8.5.3)

- Another portion of JSR related improvements
[#&#8203;1193](https://redirect.github.com/google/zx/pull/1193)
[#&#8203;1192](https://redirect.github.com/google/zx/pull/1192)
- Goods refactoring
[#&#8203;1195](https://redirect.github.com/google/zx/pull/1195)
  - Fixes `expBackoff` implementation
  - Sets `$.log.output` as default `spinner()` output
  - Makes configurable `question()` I/O
- Added
[Graaljs](https://www.graalvm.org/latest/reference-manual/js/NodeJS/)
compatability test
[#&#8203;1194](https://redirect.github.com/google/zx/pull/1194)
- Docs improvements, usage examples updates
[#&#8203;1198](https://redirect.github.com/google/zx/pull/1198)

###
[`v8.5.2`](https://redirect.github.com/google/zx/releases/tag/8.5.2): —
Threaded Perfection

[Compare
Source](https://redirect.github.com/google/zx/compare/8.5.0...8.5.2)

- Various JSR fixes
[#&#8203;1189](https://redirect.github.com/google/zx/pull/1189)
[#&#8203;1186](https://redirect.github.com/google/zx/pull/1186)
[#&#8203;1179](https://redirect.github.com/google/zx/pull/1179)
[#&#8203;1187](https://redirect.github.com/google/zx/pull/1187)
- Docs improvements
[#&#8203;1185](https://redirect.github.com/google/zx/pull/1185)
[#&#8203;1181](https://redirect.github.com/google/zx/pull/1181)

###
[`v8.5.0`](https://redirect.github.com/google/zx/releases/tag/8.5.0): —
Flow Splitter

[Compare
Source](9ba1fb4b8d...8.5.0)

In this release we're significantly expanding the zx setup capabilities.

#### zx@lite

Just core functions without extras, ~7x smaller than the full version.
[#&#8203;1131](https://redirect.github.com/google/zx/pull/1131)

```shell
npm i zx@lite
npm i zx@8.5.0-lite
```

Detailed comparison: [zx/versions](https://google.github.io/zx/versions)

```ts
import { $ } from 'zx'
await $`echo foo`
```

#### Channels

We have completely reforged the distribution flow. Now zx is available
in multiple formats:

- [npmjs](https://www.npmjs.com/package/zx)
- [GH npm](https://redirect.github.com/google/zx/pkgs/npm/zx)
- [GH repo](https://redirect.github.com/google/zx)
- [GH docker](https://redirect.github.com/google/zx/pkgs/container/zx)
- [JSR](https://jsr.io/@&#8203;webpod/zx)
-
[Homebrew](https://redirect.github.com/Homebrew/homebrew-core/blob/master/Formula/z/zx.rb)

```shell

### npm pkg from registry.npmjs.org
npm i zx        

### install directly from the GH
npm i google/zx 

### from GH the npm registry
npm i --registry=https://npm.pkg.github.com @&#8203;google/zx

### fetch from the JSR
### https://jsr.io/docs/using-packages

### @&#8203;webpod is temporary JSR scope until @&#8203;google/zx becomes ready, we'll migrate later
npx jsr add @&#8203;webpod/zx
deno add jsr:@&#8203;webpod/zx

### homebrew formula
### https://github.com/Homebrew/homebrew-core/blob/master/Formula/z/zx.rb
brew install zx
```

[#&#8203;1141](https://redirect.github.com/google/zx/pull/1141)...
[#&#8203;1157](https://redirect.github.com/google/zx/pull/1157)

#### Docker

If you'd prefer to run zx in a container, you can pull the image from
the GH docker registry.
[node:22-alpine](https://hub.docker.com/_/node) is used as a base.
[#&#8203;1142](https://redirect.github.com/google/zx/pull/1142)
[#&#8203;1145](https://redirect.github.com/google/zx/pull/1145)

```shell
docker pull ghcr.io/google/zx:8.5.0
docker run -t ghcr.io/google/zx:8.5.0 -e="await \$({verbose: true})\`echo foo\`"
docker run -t -i -v ./:/script ghcr.io/google/zx:8.5.0 script/t.js
```

#### Chores

- Introduced fetch pipe helper to bypass string size limits
[#&#8203;1130](https://redirect.github.com/google/zx/pull/1130)
[#&#8203;977](https://redirect.github.com/google/zx/issues/977)

```ts
const p1 = fetch('https://example.com').pipe($`cat`)
const p2 = fetch('https://example.com').pipe`cat`
```

- Added `glob.sync` shortcut
[#&#8203;1135](https://redirect.github.com/google/zx/pull/1135)

```ts
import { glob } from 'zx'
const packages = glob.sync(['package.json', 'packages/*/package.json'])
```

- Restored CLI flags and envars symmetry
[#&#8203;1137](https://redirect.github.com/google/zx/pull/1137)
[#&#8203;1138](https://redirect.github.com/google/zx/pull/1138)

```shell
ZX_REGISTRY='https://custom-registry.example.com' zx script.js
```

- Enhanced errors stacktrace formatting
[#&#8203;1166](https://redirect.github.com/google/zx/pull/1166)
- Improved 3rd party licenses digest
[#&#8203;1140](https://redirect.github.com/google/zx/pull/1140)
- Enabled zizmor checks
[#&#8203;1126](https://redirect.github.com/google/zx/pull/1126)
- Docs improvements
[#&#8203;1128](https://redirect.github.com/google/zx/pull/1128)
[#&#8203;1134](https://redirect.github.com/google/zx/pull/1134)
[#&#8203;1136](https://redirect.github.com/google/zx/pull/1136)
[#&#8203;1164](https://redirect.github.com/google/zx/pull/1164)

###
[`v8.4.2`](https://redirect.github.com/google/zx/compare/8.4.1...9ba1fb4b8d17a4f5f0067d438b49568606469903)

[Compare
Source](https://redirect.github.com/google/zx/compare/8.4.1...9ba1fb4b8d17a4f5f0067d438b49568606469903)

###
[`v8.4.1`](https://redirect.github.com/google/zx/releases/tag/8.4.1): –
Rusty Elbow

[Compare
Source](https://redirect.github.com/google/zx/compare/8.4.0...8.4.1)

Logger enhancements are arriving in this release.
[#&#8203;1119](https://redirect.github.com/google/zx/issues/1119)
[#&#8203;1122](https://redirect.github.com/google/zx/pull/1122)
[#&#8203;1123](https://redirect.github.com/google/zx/pull/1123)
[#&#8203;1125](https://redirect.github.com/google/zx/pull/1125)

- You can customize the output by defining your own formatters for each
log entry kind.

```ts
$.log.formatters = {
  cmd: (entry: LogEntry) => `CMD: ${entry.cmd}`,
  fetch: (entry: LogEntry) => `FETCH: ${entry.url}`
  //...
}
```

- Cmd highlighter now *should* properly detect bins and arguments. If
still not, please report it in
[#&#8203;1122](https://redirect.github.com/google/zx/pull/1122)
- Switched to TS 5.8
[#&#8203;1120](https://redirect.github.com/google/zx/pull/1120)
- Applied [zizmor](https://woodruffw.github.io/zizmor/) to check GHA
workflows
[#&#8203;1126](https://redirect.github.com/google/zx/pull/1126)
- Prettier is now enabled as a pre-commit hook
[#&#8203;1118](https://redirect.github.com/google/zx/pull/1118)

###
[`v8.4.0`](https://redirect.github.com/google/zx/releases/tag/8.4.0): –
Drip Detective

[Compare
Source](https://redirect.github.com/google/zx/compare/8.3.2...8.4.0)

Try the new batch of enhancements: `npm i zx@8.4.0`
https://www.npmjs.com/package/zx/v/8.4.0

#### Changes

- The CLI option `--prefer-local` now allows linking both external
binaries and packages
[#&#8203;1116](https://redirect.github.com/google/zx/pull/1116)
[#&#8203;1117](https://redirect.github.com/google/zx/pull/1117)

```js
const cwd = tmpdir()
const external = tmpdir()
await fs.outputJson(path.join(external, 'node_modules/a/package.json'), {
  name: 'a',
  version: '1.0.0',
  type: 'module',
  exports: './index.js',
})
await fs.outputFile(
  path.join(external, 'node_modules/a/index.js'),
  `
export const a = 'AAA'
`
)
const script = `
import {a} from 'a'
console.log(a);
`
const out = await $`zx --cwd=${cwd} --prefer-local=${external} --test <<< ${script}`
assert.equal(out.stdout, 'AAA\n')
```

- The `quote` has been slightly changed for a conner case, when zx
literal gets an array.
[#&#8203;999](https://redirect.github.com/google/zx/issues/999)
[#&#8203;1113](https://redirect.github.com/google/zx/issues/1113)

```js
const p = $({prefix: '', postfix: ''})`echo ${[1, '', '*', '2']}`

// before
p.cmd //  `echo 1  $'*' 2`) 

// after
p.cmd //  `echo 1 $'' $'*' 2`) 
```

- Provided support for custom script extensions via CLI
[#&#8203;1104](https://redirect.github.com/google/zx/pull/1104)
[#&#8203;1105](https://redirect.github.com/google/zx/pull/1105)

```bash
zx script.zx           # Unknown file extension "\.zx"
zx --ext=mjs script.zx # OK
```

- Enhanced `nothrow` option to suppress any errors
[#&#8203;1108](https://redirect.github.com/google/zx/pull/1108)
[#&#8203;1109](https://redirect.github.com/google/zx/pull/1109)

```js
const err = new Error('BrokenSpawn')
const o = await $({
  nothrow: true,
  spawn() {
    throw err
  },
})`echo foo`
o.ok       // false
o.exitCode // null
o.message  // BrokenSpawn...
o.cause    // err
```

- `@types/node` and `@types/fs-extra` deps replaced with triple-slash
typing refs
[#&#8203;1102](https://redirect.github.com/google/zx/pull/1102)
- Made `ProcessOutput` iterable
[#&#8203;1101](https://redirect.github.com/google/zx/pull/1101)
- Handle inappropriate `ProcessPromise` instantiation
[#&#8203;1097](https://redirect.github.com/google/zx/pull/1097)
[#&#8203;1098](https://redirect.github.com/google/zx/pull/1098)
- Pass origin error as `ProcessOuput` cause
[#&#8203;1110](https://redirect.github.com/google/zx/pull/1110)
- Separated build and release steps
[#&#8203;1106](https://redirect.github.com/google/zx/pull/1106)
- Internal improvements
- Introduced API bus
[#&#8203;1083](https://redirect.github.com/google/zx/pull/1083)
- Optimized `ProcessOutput` inners
[#&#8203;1096](https://redirect.github.com/google/zx/pull/1096)
[#&#8203;1095](https://redirect.github.com/google/zx/pull/1095)
- Pinned deps
[#&#8203;1099](https://redirect.github.com/google/zx/pull/1099)
[#&#8203;1100](https://redirect.github.com/google/zx/pull/1100)
- Switched to explicit `.ts` extensions for relative imports
[#&#8203;1111](https://redirect.github.com/google/zx/pull/1111)

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get
[config
help](https://redirect.github.com/renovatebot/renovate/discussions) if
that's undesired.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/unraid/api).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MS4yMy4yIiwidXBkYXRlZEluVmVyIjoiNDEuMjMuMiIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-15 14:55:56 -04:00
github-actions[bot]
81808ada0f chore(main): release 4.10.0 (#1505)
🤖 I have created a release *beep* *boop*
---


## [4.10.0](https://github.com/unraid/api/compare/v4.9.5...v4.10.0)
(2025-07-15)


### Features

* trial extension allowed within 5 days of expiration
([#1490](https://github.com/unraid/api/issues/1490))
([f34a33b](f34a33bc9f))


### Bug Fixes

* delay `nginx:reload` file mod effect by 10 seconds
([#1512](https://github.com/unraid/api/issues/1512))
([af33e99](af33e999a0))
* **deps:** update all non-major dependencies
([#1489](https://github.com/unraid/api/issues/1489))
([53b05eb](53b05ebe5e))
* ensure no crash if emhttp state configs are missing
([#1514](https://github.com/unraid/api/issues/1514))
([1a7d35d](1a7d35d3f6))
* **my.servers:** improve DNS resolution robustness for backup server
([#1518](https://github.com/unraid/api/issues/1518))
([eecd9b1](eecd9b1017))
* over-eager cloud query from web components
([#1506](https://github.com/unraid/api/issues/1506))
([074370c](074370c42c))
* replace myservers.cfg reads in UpdateFlashBackup.php
([#1517](https://github.com/unraid/api/issues/1517))
([441e180](441e1805c1))
* rm short-circuit in `rc.unraid-api` if plugin config dir is absent
([#1515](https://github.com/unraid/api/issues/1515))
([29dcb7d](29dcb7d0f0))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-07-15 14:32:44 -04:00
Eli Bosley
eecd9b1017 fix(my.servers): improve DNS resolution robustness for backup server (#1518)
Add multiple fallback methods for DNS resolution when checking
backup.unraid.net

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved reliability of DNS resolution checks for backup services,
reducing false error reports.
* Enhanced error messages to provide clearer guidance if DNS resolution
fails, including advice to check DNS settings in network configuration.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-15 11:33:21 -04:00
Pujit Mehrotra
441e1805c1 fix: replace myservers.cfg reads in UpdateFlashBackup.php (#1517)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added a new method for verifying user sign-in status using a dedicated
configuration handler.
* Introduced a class to manage connection configuration and status
checks.

* **Refactor**
* Updated logic for checking connection and registration status to use
new configuration handling methods for improved clarity and reliability.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-15 10:30:04 -04:00
Pujit Mehrotra
29dcb7d0f0 fix: rm short-circuit in rc.unraid-api if plugin config dir is absent (#1515)
This short-circuit causes any/all `rc.unraid-api` invocations to
immediately fail on fresh 7.2 images (because
`/boot/config/dynamix.my.servers` doesn't exist).

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Refactor**
* Removed initial checks and setup for a plugin directory and default
environment file in the startup script.
* Simplified environment switching with streamlined commands and
improved error handling.
* Removed deprecated environment path references and updated related
tests.
* **Documentation**
* Added descriptive comments clarifying build and environment settings.
* **Tests**
* Updated test cases by removing assertions related to deprecated
environment paths.
* **Maintenance**
  * Updated timestamp fixtures for consistency.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-15 09:48:12 -04:00
Pujit Mehrotra
1a7d35d3f6 fix: ensure no crash if emhttp state configs are missing (#1514)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added new utility functions to improve file writing reliability by
ensuring parent directories exist before writing.
* Introduced a new watch command for easier development workflow in the
shared package.

* **Bug Fixes**
* Improved startup behavior by logging warnings for missing
configuration keys instead of crashing, allowing initialization to
proceed.

* **Chores**
* Updated configuration version number and reformatted plugin list for
clarity.
* Relocated certain GraphQL schema type and enum declarations without
changing their content.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1210788779106748
2025-07-15 09:48:01 -04:00
Pujit Mehrotra
af33e999a0 fix: delay nginx:reload file mod effect by 10 seconds (#1512)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Enhancements**
  * Added logging to indicate when Nginx is successfully reloaded.
* Introduced a 10-second delay with a log message before triggering
Nginx reloads in file modification effects.

* **Style**
* Removed a startup message from the Unraid API service plugin
installation process.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: Eli Bosley <ekbosley@gmail.com>
2025-07-14 11:12:20 -04:00
renovate[bot]
85a35804c1 chore(deps): pin dependencies (#1478)
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [jiti](https://redirect.github.com/unjs/jiti) | devDependencies | pin
| [`^2.4.2` ->
`2.4.2`](https://renovatebot.com/diffs/npm/jiti/2.4.2/2.4.2) |
| [node](https://redirect.github.com/actions/node-versions) | uses-with
| pin | `22` -> `22.17.0` |
| [wrangler](https://redirect.github.com/cloudflare/workers-sdk)
([source](https://redirect.github.com/cloudflare/workers-sdk/tree/HEAD/packages/wrangler))
| devDependencies | pin | [`^4.0.0` ->
`4.24.3`](https://renovatebot.com/diffs/npm/wrangler/4.24.3/4.24.3) |
| [ws](https://redirect.github.com/websockets/ws) | peerDependencies |
pin | [`^8.18.0` ->
`8.18.3`](https://renovatebot.com/diffs/npm/ws/8.18.3/8.18.3) |

Add the preset `:preserveSemverRanges` to your config if you don't want
to pin your dependencies.

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the
rebase/retry checkbox.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get
[config
help](https://redirect.github.com/renovatebot/renovate/discussions) if
that's undesired.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/unraid/api).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MS4yMy4yIiwidXBkYXRlZEluVmVyIjoiNDEuMjMuMiIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-14 11:11:46 -04:00
Eli Bosley
a35c8ff2f1 refactor(install): add debugging to install process
- Remove redundant log file handling and display errors directly to users
- Add debug information for troubleshooting installation issues

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved installation and verification scripts to display output and
error messages directly to the user, rather than writing to a log file.
* Enhanced error messages to provide clearer instructions when issues
occur during installation or verification.

* **New Features**
* Added detailed debug output during the API service startup to assist
with troubleshooting.

* **Chores**
* Updated script environments and streamlined directory creation for
improved reliability.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-14 10:59:05 -04:00
renovate[bot]
153e7a1e3a chore(deps): update dependency @vitejs/plugin-vue to v6 (#1431)
This PR contains the following updates:

| Package | Change | Age | Confidence |
|---|---|---|---|
|
[@vitejs/plugin-vue](https://redirect.github.com/vitejs/vite-plugin-vue/tree/main/packages/plugin-vue#readme)
([source](https://redirect.github.com/vitejs/vite-plugin-vue/tree/HEAD/packages/plugin-vue))
| [`5.2.4` ->
`6.0.0`](https://renovatebot.com/diffs/npm/@vitejs%2fplugin-vue/5.2.4/6.0.0)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/@vitejs%2fplugin-vue/6.0.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@vitejs%2fplugin-vue/5.2.4/6.0.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|

---

### Release Notes

<details>
<summary>vitejs/vite-plugin-vue (@&#8203;vitejs/plugin-vue)</summary>

###
[`v6.0.0`](https://redirect.github.com/vitejs/vite-plugin-vue/blob/HEAD/packages/plugin-vue/CHANGELOG.md#600-2025-06-24)

##### Bug Fixes

- **deps:** update all non-major dependencies
([#&#8203;590](https://redirect.github.com/vitejs/vite-plugin-vue/issues/590))
([43426c8](43426c8476))
- **deps:** update all non-major dependencies
([#&#8203;600](https://redirect.github.com/vitejs/vite-plugin-vue/issues/600))
([a4c32a8](a4c32a84f3))
- **deps:** update all non-major dependencies
([#&#8203;605](https://redirect.github.com/vitejs/vite-plugin-vue/issues/605))
([67534e5](67534e5d8c))
- **deps:** update all non-major dependencies
([#&#8203;609](https://redirect.github.com/vitejs/vite-plugin-vue/issues/609))
([98c52eb](98c52eb594))

##### Miscellaneous Chores

- add `description` and `keywords` field to package.json
([#&#8203;604](https://redirect.github.com/vitejs/vite-plugin-vue/issues/604))
([67ab76b](67ab76b485))
- **deps:** update dependency rollup to ^4.41.1
([#&#8203;591](https://redirect.github.com/vitejs/vite-plugin-vue/issues/591))
([256ac31](256ac314e6))
- **deps:** update dependency rollup to ^4.43.0
([#&#8203;601](https://redirect.github.com/vitejs/vite-plugin-vue/issues/601))
([a495edf](a495edf617))
- remove Vite 7 beta from supported range
([#&#8203;598](https://redirect.github.com/vitejs/vite-plugin-vue/issues/598))
([c7ddd62](c7ddd625a7))

##### Code Refactoring

- always use `crypto.hash`
([#&#8203;606](https://redirect.github.com/vitejs/vite-plugin-vue/issues/606))
([5de85f6](5de85f6a15))

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/unraid/api).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC42Mi4xIiwidXBkYXRlZEluVmVyIjoiNDEuMjMuMiIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-11 15:29:14 -04:00
renovate[bot]
e73fc356cb chore(deps): update dependency wrangler to v4 (#1508)
This PR contains the following updates:

| Package | Change | Age | Confidence |
|---|---|---|---|
| [wrangler](https://redirect.github.com/cloudflare/workers-sdk)
([source](https://redirect.github.com/cloudflare/workers-sdk/tree/HEAD/packages/wrangler))
| [`^3.114.10` ->
`^4.0.0`](https://renovatebot.com/diffs/npm/wrangler/3.114.10/4.24.3) |
[![age](https://developer.mend.io/api/mc/badges/age/npm/wrangler/4.24.3?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/wrangler/3.114.10/4.24.3?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|

---

### Release Notes

<details>
<summary>cloudflare/workers-sdk (wrangler)</summary>

###
[`v4.24.3`](https://redirect.github.com/cloudflare/workers-sdk/blob/HEAD/packages/wrangler/CHANGELOG.md#4243)

[Compare
Source](https://redirect.github.com/cloudflare/workers-sdk/compare/wrangler@4.24.2...wrangler@4.24.3)

##### Patch Changes

-
[#&#8203;9923](https://redirect.github.com/cloudflare/workers-sdk/pull/9923)
[`c01c4ee`](c01c4ee6af)
Thanks [@&#8203;gpanders](https://redirect.github.com/gpanders)! - Fix
image name resolution when modifying a container application

-
[#&#8203;9833](https://redirect.github.com/cloudflare/workers-sdk/pull/9833)
[`3743896`](3743896120)
Thanks
[@&#8203;dario-piotrowicz](https://redirect.github.com/dario-piotrowicz)!
- fix: ensure that container builds don't disrupt dev hotkey handling

currently container builds run during local development (via `wrangler
dev` or `startWorker`) prevent the standard hotkeys not to be recognized
(most noticeably `ctrl+c`, preventing developers from existing the
process), the changes here ensure that hotkeys are instead correctly
handled as expected

- Updated dependencies \[]:
  - miniflare@4.20250709.0

###
[`v4.24.2`](https://redirect.github.com/cloudflare/workers-sdk/blob/HEAD/packages/wrangler/CHANGELOG.md#4242)

[Compare
Source](https://redirect.github.com/cloudflare/workers-sdk/compare/wrangler@4.24.1...wrangler@4.24.2)

##### Patch Changes

-
[#&#8203;9917](https://redirect.github.com/cloudflare/workers-sdk/pull/9917)
[`80cc834`](80cc83403e)
Thanks [@&#8203;edmundhung](https://redirect.github.com/edmundhung)! -
fix: assets only versions upload should include tag and message

###
[`v4.24.1`](https://redirect.github.com/cloudflare/workers-sdk/blob/HEAD/packages/wrangler/CHANGELOG.md#4241)

[Compare
Source](https://redirect.github.com/cloudflare/workers-sdk/compare/wrangler@4.24.0...wrangler@4.24.1)

##### Patch Changes

-
[#&#8203;9765](https://redirect.github.com/cloudflare/workers-sdk/pull/9765)
[`05adc61`](05adc615c9)
Thanks
[@&#8203;hasip-timurtas](https://redirect.github.com/hasip-timurtas)! -
Build container images without the user's account ID. This allows
containers to be built and verified in dry run mode (where we do not
necessarily have the user's account info).

When we push the image to the managed registry, we first re-tag the
image to include the user's account ID so that the image has the full
resolved image name.

- Updated dependencies
\[[`bb09e50`](bb09e50d8e),
[`25dbe54`](25dbe5480d),
[`3bdec6b`](3bdec6b768)]:
  - miniflare@4.20250709.0

###
[`v4.24.0`](https://redirect.github.com/cloudflare/workers-sdk/blob/HEAD/packages/wrangler/CHANGELOG.md#4240)

[Compare
Source](https://redirect.github.com/cloudflare/workers-sdk/compare/wrangler@4.23.0...wrangler@4.24.0)

##### Minor Changes

-
[#&#8203;9796](https://redirect.github.com/cloudflare/workers-sdk/pull/9796)
[`ba69586`](ba69586d8f)
Thanks [@&#8203;simonabadoiu](https://redirect.github.com/simonabadoiu)!
- Browser Rendering local mode

-
[#&#8203;9825](https://redirect.github.com/cloudflare/workers-sdk/pull/9825)
[`49c85c5`](49c85c5306)
Thanks [@&#8203;ReppCodes](https://redirect.github.com/ReppCodes)! - Add
support for origin\_connection\_limit to Wrangler

  Configure connection limits to Hyperdrive via command line options:

- `--origin-connection-limit`: The (soft) maximum number of connections
that Hyperdrive may establish to the origin database.

-
[#&#8203;9064](https://redirect.github.com/cloudflare/workers-sdk/pull/9064)
[`a1181bf`](a1181bf804)
Thanks [@&#8203;sdnts](https://redirect.github.com/sdnts)! - Added an
`event-subscriptions` subcommand

##### Patch Changes

-
[#&#8203;9729](https://redirect.github.com/cloudflare/workers-sdk/pull/9729)
[`1b3a2b7`](1b3a2b71b7)
Thanks [@&#8203;404Wolf](https://redirect.github.com/404Wolf)! - Set
docker build context to the Dockerfile directory when
`image_build_context` is not explicitly provided

-
[#&#8203;9845](https://redirect.github.com/cloudflare/workers-sdk/pull/9845)
[`dbfa4ef`](dbfa4ef4d4)
Thanks [@&#8203;jonboulle](https://redirect.github.com/jonboulle)! -
remove extraneous double spaces from Wrangler help output

-
[#&#8203;9811](https://redirect.github.com/cloudflare/workers-sdk/pull/9811)
[`fc29c31`](fc29c31ae0)
Thanks [@&#8203;gpanders](https://redirect.github.com/gpanders)! - Fix
unauthorized errors on "containers images delete".

-
[#&#8203;9813](https://redirect.github.com/cloudflare/workers-sdk/pull/9813)
[`45497ab`](45497ab4a4)
Thanks [@&#8203;gpanders](https://redirect.github.com/gpanders)! -
Support container image names without account ID

-
[#&#8203;9821](https://redirect.github.com/cloudflare/workers-sdk/pull/9821)
[`a447d67`](a447d6722a)
Thanks
[@&#8203;WillTaylorDev](https://redirect.github.com/WillTaylorDev)! -
Preview Aliases: Force alias generation to meet stricter naming
requirements.

For cases where CI is requesting Wrangler to generate the alias based on
the branch name, we want a stricter check around the generated alias
name in order to avoid version upload failures. If a valid alias name
was not able to be generated, we warn and do not provide an alias
(avoiding a version upload failure).

-
[#&#8203;9840](https://redirect.github.com/cloudflare/workers-sdk/pull/9840)
[`7c55f9e`](7c55f9e1ea)
Thanks
[@&#8203;dario-piotrowicz](https://redirect.github.com/dario-piotrowicz)!
- fix: make sure that the experimental `remoteBindings` flag is properly
handled in `getPlatformProxy`

There are two issues related to how the experimental `remoteBindings`
flag is handled in `getPlatformProxy` that are being fixed by this
change:

- the `experimental_remote` configuration flag set on service bindings
is incorrectly always taken into account, even if `remoteBindings` is
set to `false`
- the `experimental_remote` configuration flag of all the other bindings
is never taken into account (effectively preventing the bindings to be
used in remote mode) since the `remoteBindings` flag is not being
properly propagated

-
[#&#8203;9801](https://redirect.github.com/cloudflare/workers-sdk/pull/9801)
[`0bb619a`](0bb619a929)
Thanks [@&#8203;IRCody](https://redirect.github.com/IRCody)! -
Containers: Fix issue where setting an image URI instead of dockerfile
would incorrectly not update the image

-
[#&#8203;9872](https://redirect.github.com/cloudflare/workers-sdk/pull/9872)
[`a727db3`](a727db341a)
Thanks [@&#8203;emily-shen](https://redirect.github.com/emily-shen)! -
fix: resolve Dockerfile path relative to the Wrangler config path

This fixes a bug where Wrangler would not be able to find a Dockerfile
if a Wrangler config path had been specified with the `--config` flag.

-
[#&#8203;9815](https://redirect.github.com/cloudflare/workers-sdk/pull/9815)
[`1358034`](1358034ec2)
Thanks [@&#8203;gpanders](https://redirect.github.com/gpanders)! -
Remove --json flag from containers and cloudchamber commands (except for
"images list")

-
[#&#8203;9734](https://redirect.github.com/cloudflare/workers-sdk/pull/9734)
[`1a58bc3`](1a58bc34d6)
Thanks [@&#8203;penalosa](https://redirect.github.com/penalosa)! - Make
Wrangler warn more loudly if you're missing auth scopes

-
[#&#8203;9748](https://redirect.github.com/cloudflare/workers-sdk/pull/9748)
[`7e3aa1b`](7e3aa1b774)
Thanks [@&#8203;alsuren](https://redirect.github.com/alsuren)! -
Internal-only WRANGLER\_D1\_EXTRA\_LOCATION\_CHOICES environment
variable for enabling D1's testing location hints

- Updated dependencies
\[[`ba69586`](ba69586d8f),
[`1a75f85`](1a75f85ae9),
[`395f36d`](395f36de12),
[`6f344bf`](6f344bfe31)]:
  - miniflare@4.20250705.0

###
[`v4.23.0`](https://redirect.github.com/cloudflare/workers-sdk/blob/HEAD/packages/wrangler/CHANGELOG.md#4230)

[Compare
Source](https://redirect.github.com/cloudflare/workers-sdk/compare/wrangler@4.22.0...wrangler@4.23.0)

##### Minor Changes

-
[#&#8203;9535](https://redirect.github.com/cloudflare/workers-sdk/pull/9535)
[`56dc5c4`](56dc5c4946)
Thanks [@&#8203;penalosa](https://redirect.github.com/penalosa)! - In
2023 we announced [breakpoint debugging
support](https://blog.cloudflare.com/debugging-cloudflare-workers/) for
Workers, which meant that you could easily debug your Worker code in
Wrangler's built-in devtools (accessible via the `[d]` hotkey) as well
as multiple other devtools clients, [including
VSCode](https://developers.cloudflare.com/workers/observability/dev-tools/breakpoints/).
For most developers, breakpoint debugging via VSCode is the most natural
flow, but until now it's required [manually configuring a `launch.json`
file](https://developers.cloudflare.com/workers/observability/dev-tools/breakpoints/#setup-vs-code-to-use-breakpoints),
running `wrangler dev`, and connecting via VSCode's built-in debugger.

Now, using VSCode's built-in [JavaScript Debug
Terminals](https://code.visualstudio.com/docs/nodejs/nodejs-debugging#_javascript-debug-terminal),
there are just two steps: open a JS debug terminal and run `wrangler
dev` (or `vite dev`). VSCode will automatically connect to your running
Worker (even if you're running multiple Workers at once!) and start a
debugging session.

-
[#&#8203;9810](https://redirect.github.com/cloudflare/workers-sdk/pull/9810)
[`8acaf43`](8acaf432ac)
Thanks
[@&#8203;WillTaylorDev](https://redirect.github.com/WillTaylorDev)! -
WC-3626 Pull branch name from WORKERS\_CI\_BRANCH if exists.

##### Patch Changes

-
[#&#8203;9775](https://redirect.github.com/cloudflare/workers-sdk/pull/9775)
[`4309bb3`](4309bb30d2)
Thanks [@&#8203;vicb](https://redirect.github.com/vicb)! - Cap the
number of errors and warnings for bulk KV put to avoid consuming too
much memory

-
[#&#8203;9799](https://redirect.github.com/cloudflare/workers-sdk/pull/9799)
[`d11288a`](d11288aff5)
Thanks [@&#8203;penalosa](https://redirect.github.com/penalosa)! -
Better messaging for account owned tokens in `wrangler whoami`

- Updated dependencies
\[[`56dc5c4`](56dc5c4946)]:
  - miniflare@4.20250617.5

###
[`v4.22.0`](https://redirect.github.com/cloudflare/workers-sdk/blob/HEAD/packages/wrangler/CHANGELOG.md#4220)

[Compare
Source](https://redirect.github.com/cloudflare/workers-sdk/compare/wrangler@4.21.2...wrangler@4.22.0)

##### Minor Changes

-
[#&#8203;7871](https://redirect.github.com/cloudflare/workers-sdk/pull/7871)
[`f2a8d4a`](f2a8d4a91e)
Thanks
[@&#8203;dario-piotrowicz](https://redirect.github.com/dario-piotrowicz)!
- add support for assets bindings to `getPlatformProxy`

this change makes sure that that `getPlatformProxy`, when the input
configuration
file contains an assets field, correctly returns the appropriate asset
binding proxy

  example:

  ```jsonc
  // wrangler.jsonc
  {
  	"name": "my-worker",
  	"assets": {
  		"directory": "./public/",
  		"binding": "ASSETS",
  	},
  }
  ```

  ```js
  import { getPlatformProxy } from "wrangler";

  const { env, dispose } = await getPlatformProxy();

const text = await (await
env.ASSETS.fetch("http://0.0.0.0/file.txt")).text();
  console.log(text); // logs the content of file.txt

  await dispose();
  ```

##### Patch Changes

-
[#&#8203;9717](https://redirect.github.com/cloudflare/workers-sdk/pull/9717)
[`d2f2f72`](d2f2f726a1)
Thanks
[@&#8203;nikitassharma](https://redirect.github.com/nikitassharma)! -
Containers should default to a "dev" instance type when no instance type
is specified in the wrangler config

-
[#&#8203;9620](https://redirect.github.com/cloudflare/workers-sdk/pull/9620)
[`1b967ea`](1b967ea0ef)
Thanks [@&#8203;gpanders](https://redirect.github.com/gpanders)! -
Simplify containers images list output format

-
[#&#8203;9684](https://redirect.github.com/cloudflare/workers-sdk/pull/9684)
[`94a340e`](94a340e121)
Thanks
[@&#8203;WillTaylorDev](https://redirect.github.com/WillTaylorDev)! -
Select only successfully deployed deployments when tailing.

###
[`v4.21.2`](https://redirect.github.com/cloudflare/workers-sdk/blob/HEAD/packages/wrangler/CHANGELOG.md#4212)

[Compare
Source](https://redirect.github.com/cloudflare/workers-sdk/compare/wrangler@4.21.1...wrangler@4.21.2)

##### Patch Changes

-
[#&#8203;9731](https://redirect.github.com/cloudflare/workers-sdk/pull/9731)
[`75b75f3`](75b75f3de7)
Thanks [@&#8203;gabivlj](https://redirect.github.com/gabivlj)! -
containers: Check for container scopes before running a container
command to give a better error

-
[#&#8203;9641](https://redirect.github.com/cloudflare/workers-sdk/pull/9641)
[`fdbc9f6`](fdbc9f6048)
Thanks [@&#8203;IRCody](https://redirect.github.com/IRCody)! - Update
container builds to use a more robust method for detecting if the
currently built image already exists.

-
[#&#8203;9736](https://redirect.github.com/cloudflare/workers-sdk/pull/9736)
[`55c83a7`](55c83a7cf9)
Thanks [@&#8203;gabivlj](https://redirect.github.com/gabivlj)! -
containers: Do not check scopes if not defined

-
[#&#8203;9667](https://redirect.github.com/cloudflare/workers-sdk/pull/9667)
[`406fba5`](406fba5fa2)
Thanks [@&#8203;IRCody](https://redirect.github.com/IRCody)! - Fail
earlier in the deploy process when deploying a container worker if
docker is not detected.

###
[`v4.21.1`](https://redirect.github.com/cloudflare/workers-sdk/blob/HEAD/packages/wrangler/CHANGELOG.md#4211)

[Compare
Source](https://redirect.github.com/cloudflare/workers-sdk/compare/wrangler@4.21.0...wrangler@4.21.1)

##### Patch Changes

-
[#&#8203;9626](https://redirect.github.com/cloudflare/workers-sdk/pull/9626)
[`9c938c2`](9c938c2183)
Thanks [@&#8203;penalosa](https://redirect.github.com/penalosa)! -
Support `wrangler version upload` for Python Workers

-
[#&#8203;9718](https://redirect.github.com/cloudflare/workers-sdk/pull/9718)
[`fb83341`](fb83341bed)
Thanks [@&#8203;mhart](https://redirect.github.com/mhart)! - fix error
message when docker daemon is not running

-
[#&#8203;9689](https://redirect.github.com/cloudflare/workers-sdk/pull/9689)
[`b137a6f`](b137a6f090)
Thanks [@&#8203;emily-shen](https://redirect.github.com/emily-shen)! -
fix: correctly pass container engine config to miniflare

-
[#&#8203;9722](https://redirect.github.com/cloudflare/workers-sdk/pull/9722)
[`29e911a`](29e911abbb)
Thanks [@&#8203;emily-shen](https://redirect.github.com/emily-shen)! -
Update containers config schema.

Deprecates `containers.configuration` in favour of top level fields.
Makes top level `image` required. Deprecates `instances` and
`durable_objects`. Makes `name` optional.

-
[#&#8203;9666](https://redirect.github.com/cloudflare/workers-sdk/pull/9666)
[`f3c5791`](f3c5791e3a)
Thanks [@&#8203;IRCody](https://redirect.github.com/IRCody)! - Add a
reasonable default name for containers that have no defined name.

- Updated dependencies
\[[`b137a6f`](b137a6f090)]:
  - miniflare@4.20250617.4

###
[`v4.21.0`](https://redirect.github.com/cloudflare/workers-sdk/blob/HEAD/packages/wrangler/CHANGELOG.md#4210)

[Compare
Source](https://redirect.github.com/cloudflare/workers-sdk/compare/wrangler@4.20.5...wrangler@4.21.0)

##### Minor Changes

-
[#&#8203;9692](https://redirect.github.com/cloudflare/workers-sdk/pull/9692)
[`273952f`](273952ff89)
Thanks [@&#8203;dom96](https://redirect.github.com/dom96)! - Condenses
Python vendored modules in output table

-
[#&#8203;9654](https://redirect.github.com/cloudflare/workers-sdk/pull/9654)
[`2a5988c`](2a5988c50a)
Thanks [@&#8203;dom96](https://redirect.github.com/dom96)! - Python
Workers now automatically bundle .so files from vendored packages

##### Patch Changes

-
[#&#8203;9695](https://redirect.github.com/cloudflare/workers-sdk/pull/9695)
[`0e64c35`](0e64c3515f)
Thanks [@&#8203;emily-shen](https://redirect.github.com/emily-shen)! -
Move hotkey registration later in dev start up

This should have no functional change, but allows us to conditionally
render hotkeys based on config.

-
[#&#8203;9098](https://redirect.github.com/cloudflare/workers-sdk/pull/9098)
[`ef20754`](ef207546d6)
Thanks [@&#8203;jseba](https://redirect.github.com/jseba)! - Migrate
Workers Containers commands to Containers API Endpoints

The Workers Containers API was built on top of Cloudchamber, but has now
been moved to its own API
  with a reduced scoping and new token.

-
[#&#8203;9712](https://redirect.github.com/cloudflare/workers-sdk/pull/9712)
[`2a4c467`](2a4c467d83)
Thanks [@&#8203;emily-shen](https://redirect.github.com/emily-shen)! -
Make `wrangler container` commands print `open-beta` status

###
[`v4.20.5`](https://redirect.github.com/cloudflare/workers-sdk/blob/HEAD/packages/wrangler/CHANGELOG.md#4205)

[Compare
Source](https://redirect.github.com/cloudflare/workers-sdk/compare/wrangler@4.20.4...wrangler@4.20.5)

##### Patch Changes

-
[#&#8203;9688](https://redirect.github.com/cloudflare/workers-sdk/pull/9688)
[`086e29d`](086e29daf4)
Thanks
[@&#8203;dario-piotrowicz](https://redirect.github.com/dario-piotrowicz)!
- add remote bindings support to `getPlatformProxy`

  Example:

  ```json
  // wrangler.jsonc
  {
  	"name": "get-platform-proxy-test",
  	"services": [
  		{
  			"binding": "MY_WORKER",
  			"service": "my-worker",
  			"experimental_remote": true
  		}
  	]
  }
  ```

  ```js
  // index.mjs
  import { getPlatformProxy } from "wrangler";

  const { env } = await getPlatformProxy({
  	experimental: {
  		remoteBindings: true,
  	},
  });

  // env.MY_WORKER.fetch() fetches from the remote my-worker service
  ```

-
[#&#8203;9558](https://redirect.github.com/cloudflare/workers-sdk/pull/9558)
[`d5edf52`](d5edf52b43)
Thanks
[@&#8203;ichernetsky-cf](https://redirect.github.com/ichernetsky-cf)! -
`wrangler containers apply` uses `observability` configuration.

-
[#&#8203;9678](https://redirect.github.com/cloudflare/workers-sdk/pull/9678)
[`24b2c66`](24b2c666cf)
Thanks
[@&#8203;dario-piotrowicz](https://redirect.github.com/dario-piotrowicz)!
- remove warnings during config validations on `experimental_remote`
fields

wrangler commands, run without the `--x-remote-bindings` flag, parsing
config files containing `experimental_remote` fields currently show
warnings stating that the field is not recognized. This is usually more
cumbersome than helpful so here we're loosening up this validation and
making wrangler always recognize the field even when no
`--x-remote-bindings` flag is provided

-
[#&#8203;9633](https://redirect.github.com/cloudflare/workers-sdk/pull/9633)
[`3f478af`](3f478af7f1)
Thanks
[@&#8203;nikitassharma](https://redirect.github.com/nikitassharma)! -
Add support for setting an instance type for containers in wrangler.
This allows users to configure memory, disk, and vCPU by setting
instance type when interacting with containers.

-
[#&#8203;9596](https://redirect.github.com/cloudflare/workers-sdk/pull/9596)
[`5162c51`](5162c51946)
Thanks
[@&#8203;CarmenPopoviciu](https://redirect.github.com/CarmenPopoviciu)!
- add ability to pull images for containers local dev

- Updated dependencies
\[[`bfb791e`](bfb791e708),
[`5162c51`](5162c51946)]:
  - miniflare@4.20250617.3

###
[`v4.20.4`](https://redirect.github.com/cloudflare/workers-sdk/blob/HEAD/packages/wrangler/CHANGELOG.md#4204)

[Compare
Source](https://redirect.github.com/cloudflare/workers-sdk/compare/wrangler@4.20.3...wrangler@4.20.4)

##### Patch Changes

-
[#&#8203;9673](https://redirect.github.com/cloudflare/workers-sdk/pull/9673)
[`ffa742f`](ffa742f32f)
Thanks
[@&#8203;dario-piotrowicz](https://redirect.github.com/dario-piotrowicz)!
- fix: ensure that wrangler deploy and version upload don't override the
remote-bindings flag

-
[#&#8203;9653](https://redirect.github.com/cloudflare/workers-sdk/pull/9653)
[`8a60fe7`](8a60fe76ec)
Thanks [@&#8203;penalosa](https://redirect.github.com/penalosa)! -
Rename `WRANGLER_CONTAINERS_DOCKER_PATH` to `WRANGLER_DOCKER_BIN`

-
[#&#8203;9664](https://redirect.github.com/cloudflare/workers-sdk/pull/9664)
[`c489a44`](c489a44847)
Thanks [@&#8203;IRCody](https://redirect.github.com/IRCody)! - Remove
cloudchamber/container apply confirmation dialog when run
non-interactively.

-
[#&#8203;9653](https://redirect.github.com/cloudflare/workers-sdk/pull/9653)
[`8a60fe7`](8a60fe76ec)
Thanks [@&#8203;penalosa](https://redirect.github.com/penalosa)! - Add a
warning banner to `wrangler cloudchamber` and `wrangler containers`
commands

-
[#&#8203;9605](https://redirect.github.com/cloudflare/workers-sdk/pull/9605)
[`17d23d8`](17d23d8e5f)
Thanks [@&#8203;emily-shen](https://redirect.github.com/emily-shen)! -
Add rebuild hotkey for containers local dev, and clean up containers at
the end of a dev session.

- Updated dependencies
\[[`17d23d8`](17d23d8e5f)]:
  - miniflare@4.20250617.2

###
[`v4.20.3`](https://redirect.github.com/cloudflare/workers-sdk/blob/HEAD/packages/wrangler/CHANGELOG.md#4203)

[Compare
Source](https://redirect.github.com/cloudflare/workers-sdk/compare/wrangler@4.20.2...wrangler@4.20.3)

##### Patch Changes

-
[#&#8203;9621](https://redirect.github.com/cloudflare/workers-sdk/pull/9621)
[`08be3ed`](08be3ed057)
Thanks [@&#8203;gabivlj](https://redirect.github.com/gabivlj)! -
wrangler containers: 'default' scheduling policy should be the default

-
[#&#8203;9586](https://redirect.github.com/cloudflare/workers-sdk/pull/9586)
[`d1d34fe`](d1d34fedd1)
Thanks [@&#8203;penalosa](https://redirect.github.com/penalosa)! -
Remove the Mixed Mode naming in favour of "remote bindings"/"remote
proxy"

- Updated dependencies
\[[`d1d34fe`](d1d34fedd1)]:
  - miniflare@4.20250617.1

###
[`v4.20.2`](https://redirect.github.com/cloudflare/workers-sdk/blob/HEAD/packages/wrangler/CHANGELOG.md#4202)

[Compare
Source](https://redirect.github.com/cloudflare/workers-sdk/compare/wrangler@4.20.1...wrangler@4.20.2)

##### Patch Changes

-
[#&#8203;9565](https://redirect.github.com/cloudflare/workers-sdk/pull/9565)
[`b1c9139`](b1c9139524)
Thanks [@&#8203;IRCody](https://redirect.github.com/IRCody)! - Ensure
that a container applications image configuration is not updated if
there were not changes to the image.

-
[#&#8203;9628](https://redirect.github.com/cloudflare/workers-sdk/pull/9628)
[`92f12f4`](92f12f442d)
Thanks [@&#8203;gpanders](https://redirect.github.com/gpanders)! -
Remove "Cloudchamber" from user facing error messages

-
[#&#8203;9576](https://redirect.github.com/cloudflare/workers-sdk/pull/9576)
[`2671e77`](2671e77843)
Thanks [@&#8203;vicb](https://redirect.github.com/vicb)! - Add core
local dev functionality for containers.
Adds a new WRANGLER\_DOCKER\_HOST env var to customise what socket to
connect to.

- Updated dependencies
\[[`828b7df`](828b7dffad),
[`2671e77`](2671e77843)]:
  - miniflare@4.20250617.0

###
[`v4.20.1`](https://redirect.github.com/cloudflare/workers-sdk/blob/HEAD/packages/wrangler/CHANGELOG.md#4201)

[Compare
Source](https://redirect.github.com/cloudflare/workers-sdk/compare/wrangler@4.20.0...wrangler@4.20.1)

##### Patch Changes

-
[#&#8203;9536](https://redirect.github.com/cloudflare/workers-sdk/pull/9536)
[`3b61c41`](3b61c41f2c)
Thanks
[@&#8203;dario-piotrowicz](https://redirect.github.com/dario-piotrowicz)!
- expose `Unstable_Binding` type

-
[#&#8203;9564](https://redirect.github.com/cloudflare/workers-sdk/pull/9564)
[`1d3293f`](1d3293f0cb)
Thanks [@&#8203;skepticfx](https://redirect.github.com/skepticfx)! -
Switch container registry to `registry.cloudflare.com` from
`registry.cloudchamber.cfdata.org`.
  Also adds the env var `CLOUDFLARE_CONTAINER_REGISTRY` to override this

-
[#&#8203;9520](https://redirect.github.com/cloudflare/workers-sdk/pull/9520)
[`04f9164`](04f9164bbc)
Thanks [@&#8203;vicb](https://redirect.github.com/vicb)! - fix the
default value for keep\_names (`true`)

-
[#&#8203;9506](https://redirect.github.com/cloudflare/workers-sdk/pull/9506)
[`36113c2`](36113c29c8)
Thanks [@&#8203;penalosa](https://redirect.github.com/penalosa)! - Strip
the `CF-Connecting-IP` header from outgoing fetches

-
[#&#8203;9592](https://redirect.github.com/cloudflare/workers-sdk/pull/9592)
[`49f5ac7`](49f5ac7ef2)
Thanks
[@&#8203;petebacondarwin](https://redirect.github.com/petebacondarwin)!
- Point to the right location for docs on telemetry

-
[#&#8203;9593](https://redirect.github.com/cloudflare/workers-sdk/pull/9593)
[`cf33417`](cf33417320)
Thanks [@&#8203;vicb](https://redirect.github.com/vicb)! - drop unused
`WRANGLER_UNENV_RESOLVE_PATHS` env var

-
[#&#8203;9566](https://redirect.github.com/cloudflare/workers-sdk/pull/9566)
[`521eeb9`](521eeb9d7d)
Thanks [@&#8203;vicb](https://redirect.github.com/vicb)! - Bump
`@cloudflare/unenv-preset` to 2.3.3

-
[#&#8203;9344](https://redirect.github.com/cloudflare/workers-sdk/pull/9344)
[`02e2c1e`](02e2c1e4de)
Thanks
[@&#8203;dario-piotrowicz](https://redirect.github.com/dario-piotrowicz)!
- add warning about env not specified to potentially risky wrangler
commands

add a warning suggesting users to specify their target environment (via
`-e` or `--env`)
when their wrangler config file contains some environments and they are
calling one
  of the following commands:

  - wrangler deploy
  - wrangler versions upload
  - wrangler versions deploy
  - wrangler versions secret bulk
  - wrangler versions secret put
  - wrangler versions secret delete
  - wrangler secret bulk
  - wrangler secret put
  - wrangler secret delete
  - wrangler triggers deploy

this is a measure we're putting in place to try to prevent developers
from accidentally applying
  changes to an incorrect (potentially even production) environment

-
[#&#8203;9344](https://redirect.github.com/cloudflare/workers-sdk/pull/9344)
[`02e2c1e`](02e2c1e4de)
Thanks
[@&#8203;dario-piotrowicz](https://redirect.github.com/dario-piotrowicz)!
- allow passing an empty string to the `-e|--env` flag to target the
top-level environment

-
[#&#8203;9536](https://redirect.github.com/cloudflare/workers-sdk/pull/9536)
[`3b61c41`](3b61c41f2c)
Thanks
[@&#8203;dario-piotrowicz](https://redirect.github.com/dario-piotrowicz)!
- performance improvement: restart a mixed mode session only if the
worker's remote bindings have changed

-
[#&#8203;9550](https://redirect.github.com/cloudflare/workers-sdk/pull/9550)
[`c117904`](c11790486f)
Thanks
[@&#8203;dario-piotrowicz](https://redirect.github.com/dario-piotrowicz)!
- allow `startWorker` to accept `false` as an `inspector` option (to
disable the inspector server)

-
[#&#8203;9473](https://redirect.github.com/cloudflare/workers-sdk/pull/9473)
[`fae8c02`](fae8c02bcf)
Thanks
[@&#8203;dario-piotrowicz](https://redirect.github.com/dario-piotrowicz)!
- expose new `experimental_maybeStartOrUpdateMixedModeSession` utility

- Updated dependencies
\[[`bd528d5`](bd528d5d53),
[`2177fb4`](2177fb44f4),
[`36113c2`](36113c29c8),
[`e16fcc7`](e16fcc747a)]:
  - miniflare@4.20250612.0

###
[`v4.20.0`](https://redirect.github.com/cloudflare/workers-sdk/blob/HEAD/packages/wrangler/CHANGELOG.md#4200)

[Compare
Source](https://redirect.github.com/cloudflare/workers-sdk/compare/wrangler@4.19.2...wrangler@4.20.0)

##### Minor Changes

-
[#&#8203;9509](https://redirect.github.com/cloudflare/workers-sdk/pull/9509)
[`0b2ba45`](0b2ba4590c)
Thanks [@&#8203;emily-shen](https://redirect.github.com/emily-shen)! -
feat: add static routing options via 'run\_worker\_first' to Wrangler

Implements the proposal noted here
[https://github.com/cloudflare/workers-sdk/discussions/9143](https://redirect.github.com/cloudflare/workers-sdk/discussions/9143).

This is now usable in `wrangler dev` and in production - just specify
the routes that should hit the worker first with `run_worker_first` in
your Wrangler config. You can also omit certain paths with `!` negative
rules.

##### Patch Changes

-
[#&#8203;9507](https://redirect.github.com/cloudflare/workers-sdk/pull/9507)
[`1914b87`](1914b87e25)
Thanks
[@&#8203;dario-piotrowicz](https://redirect.github.com/dario-piotrowicz)!
- slightly improve wrangler dev bindings loggings

  improve the bindings loggings by:

- removing the unnecessary (and potentially incorrect) `[connected]`
suffix for remote bindings
- making sure that the modes presented in the bindings logs are
correctly aligned

-
[#&#8203;9475](https://redirect.github.com/cloudflare/workers-sdk/pull/9475)
[`931f467`](931f467e39)
Thanks [@&#8203;edmundhung](https://redirect.github.com/edmundhung)! -
add hello world binding that serves as as an explanatory example.

-
[#&#8203;9443](https://redirect.github.com/cloudflare/workers-sdk/pull/9443)
[`95eb47d`](95eb47d2c6)
Thanks
[@&#8203;dario-piotrowicz](https://redirect.github.com/dario-piotrowicz)!
- add workerName option to startMixedModeSession API

-
[#&#8203;9541](https://redirect.github.com/cloudflare/workers-sdk/pull/9541)
[`80b8bd9`](80b8bd93e6)
Thanks
[@&#8203;dario-piotrowicz](https://redirect.github.com/dario-piotrowicz)!
- make workers created with `startWorker` await the `ready` promise on
`dispose`

-
[#&#8203;9443](https://redirect.github.com/cloudflare/workers-sdk/pull/9443)
[`95eb47d`](95eb47d2c6)
Thanks
[@&#8203;dario-piotrowicz](https://redirect.github.com/dario-piotrowicz)!
- add mixed-mode support for mtls bindings

-
[#&#8203;9515](https://redirect.github.com/cloudflare/workers-sdk/pull/9515)
[`9e4cd16`](9e4cd16ce1)
Thanks
[@&#8203;dario-piotrowicz](https://redirect.github.com/dario-piotrowicz)!
- make sure that remote binding errors are surfaced when using mixed
(hybrid) mode

-
[#&#8203;9516](https://redirect.github.com/cloudflare/workers-sdk/pull/9516)
[`92305af`](92305af0a7)
Thanks [@&#8203;IRCody](https://redirect.github.com/IRCody)! - Reorder
deploy output when deploying a container worker so the worker url is
printed last and the worker triggers aren't deployed until the container
has been built and deployed successfully.

- Updated dependencies
\[[`931f467`](931f467e39),
[`95eb47d`](95eb47d2c6),
[`0b2ba45`](0b2ba4590c)]:
  - miniflare@4.20250604.1
-
[@&#8203;cloudflare/unenv-preset](https://redirect.github.com/cloudflare/unenv-preset)@&#8203;2.3.3

###
[`v4.19.2`](https://redirect.github.com/cloudflare/workers-sdk/blob/HEAD/packages/wrangler/CHANGELOG.md#4192)

[Compare
Source](https://redirect.github.com/cloudflare/workers-sdk/compare/wrangler@4.19.1...wrangler@4.19.2)

##### Patch Changes

-
[#&#8203;9461](https://redirect.github.com/cloudflare/workers-sdk/pull/9461)
[`66edd2f`](66edd2f3bd)
Thanks [@&#8203;skepticfx](https://redirect.github.com/skepticfx)! -
Enforce disk limits on container builds

-
[#&#8203;9481](https://redirect.github.com/cloudflare/workers-sdk/pull/9481)
[`d1a1787`](d1a1787b27)
Thanks
[@&#8203;WillTaylorDev](https://redirect.github.com/WillTaylorDev)! -
Force autogenerated aliases to be fully lowercased.

-
[#&#8203;9480](https://redirect.github.com/cloudflare/workers-sdk/pull/9480)
[`1f84092`](1f84092851)
Thanks
[@&#8203;dario-piotrowicz](https://redirect.github.com/dario-piotrowicz)!
- add `experimentalMixedMode` dev option to `unstable_startWorker`

add an new `experimentalMixedMode` dev option to `unstable_startWorker`
  that allows developers to programmatically start a new mixed mode
  session using startWorker.

  Example usage:

  ```js
  // index.mjs
  import { unstable_startWorker } from "wrangler";

  await unstable_startWorker({
  	dev: {
  		experimentalMixedMode: true,
  	},
  });
  ```

  ```json
  // wrangler.jsonc
  {
  	"$schema": "node_modules/wrangler/config-schema.json",
  	"name": "programmatic-start-worker-example",
  	"main": "src/index.ts",
  	"compatibility_date": "2025-06-01",
  	"services": [
{ "binding": "REMOTE_WORKER", "service": "remote-worker", "remote": true
}
  	]
  }
  ```

- Updated dependencies
\[[`4ab5a40`](4ab5a4027d),
[`485cd08`](485cd08679),
[`e3b3ef5`](e3b3ef51cf),
[`3261957`](3261957aba)]:
  - miniflare@4.20250604.0
-
[@&#8203;cloudflare/unenv-preset](https://redirect.github.com/cloudflare/unenv-preset)@&#8203;2.3.3

###
[`v4.19.1`](https://redirect.github.com/cloudflare/workers-sdk/blob/HEAD/packages/wrangler/CHANGELOG.md#4191)

[Compare
Source](https://redirect.github.com/cloudflare/workers-sdk/compare/wrangler@4.19.0...wrangler@4.19.1)

##### Patch Changes

-
[#&#8203;9456](https://redirect.github.com/cloudflare/workers-sdk/pull/9456)
[`db2cdc6`](db2cdc6b1e)
Thanks
[@&#8203;WillTaylorDev](https://redirect.github.com/WillTaylorDev)! -
Fix bug causing preview alias to always be generated.

###
[`v4.19.0`](https://redirect.github.com/cloudflare/workers-sdk/blob/HEAD/packages/wrangler/CHANGELOG.md#4190)

[Compare
Source](https://redirect.github.com/cloudflare/workers-sdk/compare/wrangler@4.18.0...wrangler@4.19.0)

##### Minor Changes

-
[#&#8203;9401](https://redirect.github.com/cloudflare/workers-sdk/pull/9401)
[`03b8c1c`](03b8c1ca53)
Thanks
[@&#8203;WillTaylorDev](https://redirect.github.com/WillTaylorDev)! -
Provide ability for Wrangler to upload preview aliases during version
upload.

##### Patch Changes

-
[#&#8203;9390](https://redirect.github.com/cloudflare/workers-sdk/pull/9390)
[`80e75f4`](80e75f4a67)
Thanks [@&#8203;penalosa](https://redirect.github.com/penalosa)! -
Support additional Mixed Mode resources in Wrangler:

  - AI
  - Browser
  - Images
  - Vectorize
  - Dispatch Namespaces

-
[#&#8203;9395](https://redirect.github.com/cloudflare/workers-sdk/pull/9395)
[`b3be057`](b3be057344)
Thanks [@&#8203;Maximo-Guk](https://redirect.github.com/Maximo-Guk)! -
Add WRANGLER\_CI\_OVERRIDE\_NETWORK\_MODE\_HOST for Workers CI

-
[#&#8203;9410](https://redirect.github.com/cloudflare/workers-sdk/pull/9410)
[`87f3843`](87f38432ee)
Thanks
[@&#8203;dario-piotrowicz](https://redirect.github.com/dario-piotrowicz)!
- enable consumers of `unstable_readConfig` to silence `remote` warnings

- Updated dependencies
\[[`8c7ce77`](8c7ce7728c),
[`80e75f4`](80e75f4a67),
[`80e75f4`](80e75f4a67),
[`fac2f9d`](fac2f9dfa6),
[`92719a5`](92719a535b)]:
  - miniflare@4.20250525.1

###
[`v4.18.0`](https://redirect.github.com/cloudflare/workers-sdk/blob/HEAD/packages/wrangler/CHANGELOG.md#4180)

[Compare
Source](https://redirect.github.com/cloudflare/workers-sdk/compare/wrangler@4.17.0...wrangler@4.18.0)

##### Minor Changes

-
[#&#8203;9393](https://redirect.github.com/cloudflare/workers-sdk/pull/9393)
[`34b6174`](34b61746f2)
Thanks [@&#8203;jamesopstad](https://redirect.github.com/jamesopstad)! -
Hard fail on Node.js < 20. Wrangler no longer supports Node.js 18.x as
it reached end-of-life on 2025-04-30. See
https://github.com/nodejs/release?tab=readme-ov-file#end-of-life-releases.

##### Patch Changes

-
[#&#8203;9308](https://redirect.github.com/cloudflare/workers-sdk/pull/9308)
[`d3a6eb3`](d3a6eb30e5)
Thanks
[@&#8203;dario-piotrowicz](https://redirect.github.com/dario-piotrowicz)!
- expose new utilities and types to aid consumers of the programmatic
mixed-mode API

  Specifically the exports have been added:

- `Experimental_MixedModeSession`: type representing a mixed-mode
session
- `Experimental_ConfigBindingsOptions`: type representing
config-bindings
- `experimental_pickRemoteBindings`: utility for picking only the remote
bindings from a record of start-worker bindings.
- `unstable_convertConfigBindingsToStartWorkerBindings`: utility for
converting config-bindings into start-worker bindings (that can be
passed to `startMixedModeSession`)

-
[#&#8203;9347](https://redirect.github.com/cloudflare/workers-sdk/pull/9347)
[`b8f058c`](b8f058c81e)
Thanks [@&#8203;penalosa](https://redirect.github.com/penalosa)! -
Improve binding display on narrower terminals

- Updated dependencies
\[[`d9d937a`](d9d937ab6f),
[`e39a45f`](e39a45ffa0),
[`fdae3f7`](fdae3f7665)]:
  - miniflare@4.20250525.0

###
[`v4.17.0`](https://redirect.github.com/cloudflare/workers-sdk/blob/HEAD/packages/wrangler/CHANGELOG.md#4170)

[Compare
Source](https://redirect.github.com/cloudflare/workers-sdk/compare/wrangler@4.16.1...wrangler@4.17.0)

##### Minor Changes

-
[#&#8203;9321](https://redirect.github.com/cloudflare/workers-sdk/pull/9321)
[`6c03bde`](6c03bde33f)
Thanks
[@&#8203;petebacondarwin](https://redirect.github.com/petebacondarwin)!
- Add support for FedRAMP High compliance region

Now it is possible to target Wrangler at the FedRAMP High compliance
region.
  There are two ways to signal to Wrangler to run in this mode:

- set `"compliance_region": "fedramp_high"` in a Wrangler configuration
- set `CLOUDFLARE_COMPLIANCE_REGION=fedramp_high` environment variable
when running Wrangler

If both are provided and the values do not match then Wrangler will exit
with an error.

  When in this mode OAuth authentication is not supported.
It is necessary to authenticate using a Cloudflare API Token acquired
from the Cloudflare FedRAMP High dashboard.

  Most bindings and commands are supported in this mode.

- Unsupported commands may result in API requests that are not supported
- possibly 422 Unprocessable Entity responses.
- Unsupported bindings may work in local dev, as there is no local
validation, but will fail at Worker deployment time.

  Resolves DEVX-1921.

-
[#&#8203;9330](https://redirect.github.com/cloudflare/workers-sdk/pull/9330)
[`34c71ce`](34c71ce920)
Thanks [@&#8203;edmundhung](https://redirect.github.com/edmundhung)! -
Updated internal configuration to use Miniflare’s new
`defaultPersistRoot` instead of per-plugin `persist` flags

-
[#&#8203;8973](https://redirect.github.com/cloudflare/workers-sdk/pull/8973)
[`cc7fae4`](cc7fae4cb9)
Thanks
[@&#8203;Caio-Nogueira](https://redirect.github.com/Caio-Nogueira)! -
Show latest instance by default on `workflows instances describe`
command

##### Patch Changes

-
[#&#8203;9335](https://redirect.github.com/cloudflare/workers-sdk/pull/9335)
[`6479fc5`](6479fc5228)
Thanks [@&#8203;penalosa](https://redirect.github.com/penalosa)! -
Redesign `wrangler dev` to more clearly present information and have a
bit of a glow up 
![Screenshot 2025-05-22 at 01 11
43](https://redirect.github.com/user-attachments/assets/26cc6209-37a1-4ecb-8e91-daac2f79a095)

-
[#&#8203;9329](https://redirect.github.com/cloudflare/workers-sdk/pull/9329)
[`410d985`](410d985250)
Thanks [@&#8203;penalosa](https://redirect.github.com/penalosa)! - Hide
logs in the `startMixedModeSession()` API

-
[#&#8203;9325](https://redirect.github.com/cloudflare/workers-sdk/pull/9325)
[`c2678d1`](c2678d1681)
Thanks [@&#8203;edmundhung](https://redirect.github.com/edmundhung)! -
refactor: fallbacks to local image binding from miniflare when local
mode is enabled

- Updated dependencies
\[[`34c71ce`](34c71ce920),
[`f7c82a4`](f7c82a4a9f),
[`7ddd865`](7ddd865fa6),
[`6479fc5`](6479fc5228),
[`e5ae13a`](e5ae13adeb)]:
  - miniflare@4.20250523.0

###
[`v4.16.1`](https://redirect.github.com/cloudflare/workers-sdk/blob/HEAD/packages/wrangler/CHANGELOG.md#4161)

[Compare
Source](https://redirect.github.com/cloudflare/workers-sdk/compare/wrangler@4.16.0...wrangler@4.16.1)

##### Patch Changes

-
[#&#8203;9268](https://redirect.github.com/cloudflare/workers-sdk/pull/9268)
[`7344344`](734434418f)
Thanks [@&#8203;gabivlj](https://redirect.github.com/gabivlj)! -
`wrangler containers delete` handles API errors correctly

###
[`v4.16.0`](https://redirect.github.com/cloudflare/workers-sdk/blob/HEAD/packages/wrangler/CHANGELOG.md#4160)

[Compare
Source](https://redirect.github.com/cloudflare/workers-sdk/compare/wrangler@4.15.2...wrangler@4.16.0)

##### Minor Changes

-
[#&#8203;9288](https://redirect.github.com/cloudflare/workers-sdk/pull/9288)
[`3b8f7f1`](3b8f7f18be)
Thanks
[@&#8203;petebacondarwin](https://redirect.github.com/petebacondarwin)!
- allow --name and --env args on wrangler deploy

Previously it was not possible to provide a Worker name as a command
line argument at the same time as setting the Wrangler environment.
Now specifying `--name` is supported and will override any names set in
the Wrangler config:

  **wrangler.json**

  ```json
  {
  	"name": "config-worker"
  	"env": {
  		"staging": { "name": "config-worker-env" }
  	}
  }
  ```

| Command | Previous (Worker name) | Proposed (Worker name) | Comment |
| ------------------------------------------------ |
---------------------- | ---------------------- |
------------------------------------- |
| wrangler deploy --name=args-worker | "args-worker" | "args-worker" |
CLI arg used |
| wrangler deploy --name=args-worker --env=staging | *Error* |
"args-worker" | CLI arg used |
| wrangler deploy --name=args-worker --env=prod | *Error* |
"args-worker" | CLI arg used |
| wrangler deploy | "config-worker" | "config-worker" | Top-level config
used |
| wrangler deploy --env=staging | "config-worker-env" |
"config-worker-env" | Named env config used |
| wrangler deploy --env=prod | "config-worker-prod" |
"config-worker-prod" | CLI arg and top-level config combined |

-
[#&#8203;9265](https://redirect.github.com/cloudflare/workers-sdk/pull/9265)
[`16de0d5`](16de0d5227)
Thanks [@&#8203;edmundhung](https://redirect.github.com/edmundhung)! -
docs: add documentation links to individual config properties in the
JSON schema of the Wrangler config file

##### Patch Changes

-
[#&#8203;9234](https://redirect.github.com/cloudflare/workers-sdk/pull/9234)
[`2fe6219`](2fe62198d7)
Thanks [@&#8203;emily-shen](https://redirect.github.com/emily-shen)! -
fix: add no-op `props` to `ctx` in `getPlatformProxy` to fix type
mismatch

-
[#&#8203;9269](https://redirect.github.com/cloudflare/workers-sdk/pull/9269)
[`66d975e`](66d975e905)
Thanks
[@&#8203;dario-piotrowicz](https://redirect.github.com/dario-piotrowicz)!
- Wire up mixed-mode remote bindings for multi-worker `wrangler dev`

Under the `--x-mixed-mode` flag, make sure that bindings configurations
with `remote: true` actually generate bindings to remote resources
during a multi-worker `wrangler dev` session, currently the bindings
included in this are: services, kv\_namespaces, r2\_buckets,
d1\_databases, queues and workflows.

Also include the ai binding since the bindings is already remote by
default anyways.

-
[#&#8203;9151](https://redirect.github.com/cloudflare/workers-sdk/pull/9151)
[`5ab035d`](5ab035d8a1)
Thanks [@&#8203;gabivlj](https://redirect.github.com/gabivlj)! -
wrangler containers can be configured with the kind of application
rollout on `apply`

-
[#&#8203;9231](https://redirect.github.com/cloudflare/workers-sdk/pull/9231)
[`02d40ed`](02d40ed3bb)
Thanks
[@&#8203;dario-piotrowicz](https://redirect.github.com/dario-piotrowicz)!
- Wire up mixed-mode remote bindings for (single-worker) `wrangler dev`

Under the `--x-mixed-mode` flag, make sure that bindings configurations
with `remote: true` actually generate bindings to remote resources
during a single-worker `wrangler dev` session, currently the bindings
included in this are: services, kv\_namespaces, r2\_buckets,
d1\_databases, queues and workflows.

Also include the ai binding since the bindings is already remote by
default anyways.

-
[#&#8203;9221](https://redirect.github.com/cloudflare/workers-sdk/pull/9221)
[`2ef31a9`](2ef31a9459)
Thanks [@&#8203;vicb](https://redirect.github.com/vicb)! - bump
`@cloudflare/unenv-preset`

-
[#&#8203;9277](https://redirect.github.com/cloudflare/workers-sdk/pull/9277)
[`db5ea8f`](db5ea8f1f6)
Thanks [@&#8203;penalosa](https://redirect.github.com/penalosa)! -
Support Mixed Mode for more binding types

-
[#&#8203;9266](https://redirect.github.com/cloudflare/workers-sdk/pull/9266)
[`f2a16f1`](f2a16f1126)
Thanks
[@&#8203;petebacondarwin](https://redirect.github.com/petebacondarwin)!
- fix: setting triggers.crons:\[] in Wrangler config should delete
deployed cron schedules

-
[#&#8203;9245](https://redirect.github.com/cloudflare/workers-sdk/pull/9245)
[`b87b472`](b87b472a1a)
Thanks [@&#8203;penalosa](https://redirect.github.com/penalosa)! -
Support Mixed Mode Dispatch Namespaces

- Updated dependencies
\[[`db5ea8f`](db5ea8f1f6),
[`b87b472`](b87b472a1a)]:
  - miniflare@4.20250508.3

###
[`v4.15.2`](https://redirect.github.com/cloudflare/workers-sdk/blob/HEAD/packages/wrangler/CHANGELOG.md#4152)

[Compare
Source](https://redirect.github.com/cloudflare/workers-sdk/compare/wrangler@4.15.1...wrangler@4.15.2)

##### Patch Changes

-
[#&#8203;9257](https://redirect.github.com/cloudflare/workers-sdk/pull/9257)
[`33daa09`](33daa0961f)
Thanks [@&#8203;penalosa](https://redirect.github.com/penalosa)! - Relax
R2 bucket validation for `pages dev` commands

-
[#&#8203;9256](https://redirect.github.com/cloudflare/workers-sdk/pull/9256)
[`3b384e2`](3b384e28c7)
Thanks [@&#8203;penalosa](https://redirect.github.com/penalosa)! - Move
the Analytics Engine simulator implementation from JSRPC to a Wrapped
binding. This fixes a regression introduced in
[https://github.com/cloudflare/workers-sdk/pull/8935](https://redirect.github.com/cloudflare/workers-sdk/pull/8935)
that preventing Analytics Engine bindings working in local dev for
Workers with a compatibility date prior to JSRPC being enabled.

- Updated dependencies
\[[`3b384e2`](3b384e28c7)]:
  - miniflare@4.20250508.2
-
[@&#8203;cloudflare/unenv-preset](https://redirect.github.com/cloudflare/unenv-preset)@&#8203;2.3.2

###
[`v4.15.1`](https://redirect.github.com/cloudflare/workers-sdk/blob/HEAD/packages/wrangler/CHANGELOG.md#4151)

[Compare
Source](https://redirect.github.com/cloudflare/workers-sdk/compare/wrangler@4.15.0...wrangler@4.15.1)

##### Patch Changes

-
[#&#8203;9248](https://redirect.github.com/cloudflare/workers-sdk/pull/9248)
[`07f4010`](07f4010e6d)

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/unraid/api).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MS4yMy4yIiwidXBkYXRlZEluVmVyIjoiNDEuMjMuMiIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-11 15:27:20 -04:00
renovate[bot]
e1a7a3d22d chore(deps): update dependency node to v22 (#1507)
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [node](https://redirect.github.com/actions/node-versions) | uses-with
| major | `20` -> `22` |

---

### Release Notes

<details>
<summary>actions/node-versions (node)</summary>

###
[`v22.17.0`](https://redirect.github.com/actions/node-versions/releases/tag/22.17.0-15866718879):
22.17.0

[Compare
Source](https://redirect.github.com/actions/node-versions/compare/22.16.0-15177438473...22.17.0-15866718879)

Node.js 22.17.0

###
[`v22.16.0`](https://redirect.github.com/actions/node-versions/releases/tag/22.16.0-15177438473):
22.16.0

[Compare
Source](https://redirect.github.com/actions/node-versions/compare/22.15.1-15035854612...22.16.0-15177438473)

Node.js 22.16.0

###
[`v22.15.1`](https://redirect.github.com/actions/node-versions/releases/tag/22.15.1-15035854612):
22.15.1

[Compare
Source](https://redirect.github.com/actions/node-versions/compare/22.15.0-14621731016...22.15.1-15035854612)

Node.js 22.15.1

###
[`v22.15.0`](https://redirect.github.com/actions/node-versions/releases/tag/22.15.0-14621731016):
22.15.0

[Compare
Source](https://redirect.github.com/actions/node-versions/compare/22.14.0-13265982013...22.15.0-14621731016)

Node.js 22.15.0

###
[`v22.14.0`](https://redirect.github.com/actions/node-versions/releases/tag/22.14.0-13265982013):
22.14.0

[Compare
Source](https://redirect.github.com/actions/node-versions/compare/22.13.1-12900459766...22.14.0-13265982013)

Node.js 22.14.0

###
[`v22.13.1`](https://redirect.github.com/actions/node-versions/releases/tag/22.13.1-12900459766):
22.13.1

[Compare
Source](https://redirect.github.com/actions/node-versions/compare/22.13.0-12671059536...22.13.1-12900459766)

Node.js 22.13.1

###
[`v22.13.0`](https://redirect.github.com/actions/node-versions/releases/tag/22.13.0-12671059536):
22.13.0

[Compare
Source](https://redirect.github.com/actions/node-versions/compare/22.12.0-12152383658...22.13.0-12671059536)

Node.js 22.13.0

###
[`v22.12.0`](https://redirect.github.com/actions/node-versions/releases/tag/22.12.0-12152383658):
22.12.0

[Compare
Source](https://redirect.github.com/actions/node-versions/compare/22.11.0-11593095476...22.12.0-12152383658)

Node.js 22.12.0

###
[`v22.11.0`](https://redirect.github.com/actions/node-versions/releases/tag/22.11.0-11593095476):
22.11.0

[Compare
Source](https://redirect.github.com/actions/node-versions/compare/22.10.0-11377615849...22.11.0-11593095476)

Node.js 22.11.0

###
[`v22.10.0`](https://redirect.github.com/actions/node-versions/releases/tag/22.10.0-11377615849):
22.10.0

[Compare
Source](https://redirect.github.com/actions/node-versions/compare/22.9.0-10914884886...22.10.0-11377615849)

Node.js 22.10.0

###
[`v22.9.0`](https://redirect.github.com/actions/node-versions/releases/tag/22.9.0-10914884886):
22.9.0

[Compare
Source](https://redirect.github.com/actions/node-versions/compare/22.8.0-10685632420...22.9.0-10914884886)

Node.js 22.9.0

###
[`v22.8.0`](https://redirect.github.com/actions/node-versions/releases/tag/22.8.0-10685632420):
22.8.0

[Compare
Source](https://redirect.github.com/actions/node-versions/compare/22.7.0-10511334152...22.8.0-10685632420)

Node.js 22.8.0

###
[`v22.7.0`](https://redirect.github.com/actions/node-versions/releases/tag/22.7.0-10511334152):
22.7.0

[Compare
Source](https://redirect.github.com/actions/node-versions/compare/22.6.0-10277432289...22.7.0-10511334152)

Node.js 22.7.0

###
[`v22.6.0`](https://redirect.github.com/actions/node-versions/releases/tag/22.6.0-10277432289):
22.6.0

[Compare
Source](https://redirect.github.com/actions/node-versions/compare/22.5.1-10010673511...22.6.0-10277432289)

Node.js 22.6.0

###
[`v22.5.1`](https://redirect.github.com/actions/node-versions/releases/tag/22.5.1-10010673511):
22.5.1

[Compare
Source](https://redirect.github.com/actions/node-versions/compare/22.5.0-9985144103...22.5.1-10010673511)

Node.js 22.5.1

###
[`v22.5.0`](https://redirect.github.com/actions/node-versions/releases/tag/22.5.0-9985144103):
22.5.0

[Compare
Source](https://redirect.github.com/actions/node-versions/compare/22.4.1-9860948056...22.5.0-9985144103)

Node.js 22.5.0

###
[`v22.4.1`](https://redirect.github.com/actions/node-versions/releases/tag/22.4.1-9860948056):
22.4.1

[Compare
Source](https://redirect.github.com/actions/node-versions/compare/22.4.0-9766506602...22.4.1-9860948056)

Node.js 22.4.1

###
[`v22.4.0`](https://redirect.github.com/actions/node-versions/releases/tag/22.4.0-9766506602):
22.4.0

[Compare
Source](https://redirect.github.com/actions/node-versions/compare/22.3.0-9569309553...22.4.0-9766506602)

Node.js 22.4.0

###
[`v22.3.0`](https://redirect.github.com/actions/node-versions/releases/tag/22.3.0-9569309553):
22.3.0

[Compare
Source](https://redirect.github.com/actions/node-versions/compare/22.2.0-9105861751...22.3.0-9569309553)

Node.js 22.3.0

###
[`v22.2.0`](https://redirect.github.com/actions/node-versions/releases/tag/22.2.0-9105861751):
22.2.0

[Compare
Source](https://redirect.github.com/actions/node-versions/compare/22.1.0-8926142033...22.2.0-9105861751)

Node.js 22.2.0

###
[`v22.1.0`](https://redirect.github.com/actions/node-versions/releases/tag/22.1.0-8926142033):
22.1.0

[Compare
Source](https://redirect.github.com/actions/node-versions/compare/22.0.0-8879734543...22.1.0-8926142033)

Node.js 22.1.0

###
[`v22.0.0`](https://redirect.github.com/actions/node-versions/releases/tag/22.0.0-8879734543):
22.0.0

[Compare
Source](https://redirect.github.com/actions/node-versions/compare/20.19.3-15828158811...22.0.0-8879734543)

Node.js 22.0.0

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/unraid/api).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MS4yMy4yIiwidXBkYXRlZEluVmVyIjoiNDEuMjMuMiIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-11 14:41:02 -04:00
renovate[bot]
53b05ebe5e fix(deps): update all non-major dependencies (#1489)
This PR contains the following updates:

| Package | Change | Age | Confidence | Type | Update |
|---|---|---|---|---|---|
| [@eslint/js](https://eslint.org)
([source](https://redirect.github.com/eslint/eslint/tree/HEAD/packages/js))
| [`9.29.0` ->
`9.30.1`](https://renovatebot.com/diffs/npm/@eslint%2fjs/9.29.0/9.30.1)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/@eslint%2fjs/9.30.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@eslint%2fjs/9.29.0/9.30.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| devDependencies | minor |
| [@faker-js/faker](https://fakerjs.dev)
([source](https://redirect.github.com/faker-js/faker)) | [`9.8.0` ->
`9.9.0`](https://renovatebot.com/diffs/npm/@faker-js%2ffaker/9.8.0/9.9.0)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/@faker-js%2ffaker/9.9.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@faker-js%2ffaker/9.8.0/9.9.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| devDependencies | minor |
| [@floating-ui/dom](https://floating-ui.com)
([source](https://redirect.github.com/floating-ui/floating-ui/tree/HEAD/packages/dom))
| [`1.7.1` ->
`1.7.2`](https://renovatebot.com/diffs/npm/@floating-ui%2fdom/1.7.1/1.7.2)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/@floating-ui%2fdom/1.7.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@floating-ui%2fdom/1.7.1/1.7.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| dependencies | patch |
| [@floating-ui/utils](https://floating-ui.com)
([source](https://redirect.github.com/floating-ui/floating-ui/tree/HEAD/packages/utils))
| [`0.2.9` ->
`0.2.10`](https://renovatebot.com/diffs/npm/@floating-ui%2futils/0.2.9/0.2.10)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/@floating-ui%2futils/0.2.10?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@floating-ui%2futils/0.2.9/0.2.10?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| dependencies | patch |
| [@floating-ui/vue](https://floating-ui.com/docs/vue)
([source](https://redirect.github.com/floating-ui/floating-ui/tree/HEAD/packages/vue))
| [`1.1.6` ->
`1.1.7`](https://renovatebot.com/diffs/npm/@floating-ui%2fvue/1.1.6/1.1.7)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/@floating-ui%2fvue/1.1.7?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@floating-ui%2fvue/1.1.6/1.1.7?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| dependencies | patch |
|
[@graphql-codegen/client-preset](https://redirect.github.com/dotansimha/graphql-code-generator)
([source](https://redirect.github.com/dotansimha/graphql-code-generator/tree/HEAD/packages/presets/client))
| [`4.8.2` ->
`4.8.3`](https://renovatebot.com/diffs/npm/@graphql-codegen%2fclient-preset/4.8.2/4.8.3)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/@graphql-codegen%2fclient-preset/4.8.3?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@graphql-codegen%2fclient-preset/4.8.2/4.8.3?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| devDependencies | patch |
|
[@graphql-codegen/client-preset](https://redirect.github.com/dotansimha/graphql-code-generator)
([source](https://redirect.github.com/dotansimha/graphql-code-generator/tree/HEAD/packages/presets/client))
| [`4.8.2` ->
`4.8.3`](https://renovatebot.com/diffs/npm/@graphql-codegen%2fclient-preset/4.8.2/4.8.3)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/@graphql-codegen%2fclient-preset/4.8.3?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@graphql-codegen%2fclient-preset/4.8.2/4.8.3?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| dependencies | patch |
|
[@graphql-codegen/typed-document-node](https://redirect.github.com/dotansimha/graphql-code-generator)
([source](https://redirect.github.com/dotansimha/graphql-code-generator/tree/HEAD/packages/plugins/typescript/typed-document-node))
| [`5.1.1` ->
`5.1.2`](https://renovatebot.com/diffs/npm/@graphql-codegen%2ftyped-document-node/5.1.1/5.1.2)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/@graphql-codegen%2ftyped-document-node/5.1.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@graphql-codegen%2ftyped-document-node/5.1.1/5.1.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| devDependencies | patch |
|
[@ianvs/prettier-plugin-sort-imports](https://redirect.github.com/ianvs/prettier-plugin-sort-imports)
| [`4.4.2` ->
`4.5.1`](https://renovatebot.com/diffs/npm/@ianvs%2fprettier-plugin-sort-imports/4.4.2/4.5.1)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/@ianvs%2fprettier-plugin-sort-imports/4.5.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@ianvs%2fprettier-plugin-sort-imports/4.4.2/4.5.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| devDependencies | minor |
| [@nuxt/devtools](https://devtools.nuxt.com)
([source](https://redirect.github.com/nuxt/devtools/tree/HEAD/packages/devtools))
| [`2.5.0` ->
`2.6.2`](https://renovatebot.com/diffs/npm/@nuxt%2fdevtools/2.5.0/2.6.2)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/@nuxt%2fdevtools/2.6.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nuxt%2fdevtools/2.5.0/2.6.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| devDependencies | minor |
| [@nuxt/eslint](https://redirect.github.com/nuxt/eslint)
([source](https://redirect.github.com/nuxt/eslint/tree/HEAD/packages/module))
| [`1.4.1` ->
`1.5.2`](https://renovatebot.com/diffs/npm/@nuxt%2feslint/1.4.1/1.5.2) |
[![age](https://developer.mend.io/api/mc/badges/age/npm/@nuxt%2feslint/1.5.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nuxt%2feslint/1.4.1/1.5.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| devDependencies | minor |
| [@nuxt/test-utils](https://redirect.github.com/nuxt/test-utils) |
[`3.19.1` ->
`3.19.2`](https://renovatebot.com/diffs/npm/@nuxt%2ftest-utils/3.19.1/3.19.2)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/@nuxt%2ftest-utils/3.19.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nuxt%2ftest-utils/3.19.1/3.19.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| devDependencies | patch |
| [@rollup/rollup-linux-x64-gnu](https://rollupjs.org/)
([source](https://redirect.github.com/rollup/rollup)) | [`4.44.0` ->
`4.44.2`](https://renovatebot.com/diffs/npm/@rollup%2frollup-linux-x64-gnu/4.44.0/4.44.2)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/@rollup%2frollup-linux-x64-gnu/4.44.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@rollup%2frollup-linux-x64-gnu/4.44.0/4.44.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| optionalDependencies | patch |
| [@swc/core](https://swc.rs)
([source](https://redirect.github.com/swc-project/swc)) | [`1.12.4` ->
`1.12.11`](https://renovatebot.com/diffs/npm/@swc%2fcore/1.12.4/1.12.11)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/@swc%2fcore/1.12.11?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@swc%2fcore/1.12.4/1.12.11?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| devDependencies | patch |
|
[@types/bun](https://redirect.github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/bun)
([source](https://redirect.github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/bun))
| [`1.2.16` ->
`1.2.18`](https://renovatebot.com/diffs/npm/@types%2fbun/1.2.16/1.2.18)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/@types%2fbun/1.2.18?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@types%2fbun/1.2.16/1.2.18?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| devDependencies | patch |
|
[@types/dockerode](https://redirect.github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/dockerode)
([source](https://redirect.github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/dockerode))
| [`3.3.41` ->
`3.3.42`](https://renovatebot.com/diffs/npm/@types%2fdockerode/3.3.41/3.3.42)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/@types%2fdockerode/3.3.42?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@types%2fdockerode/3.3.41/3.3.42?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| devDependencies | patch |
|
[@types/lodash](https://redirect.github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/lodash)
([source](https://redirect.github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/lodash))
| [`4.17.18` ->
`4.17.20`](https://renovatebot.com/diffs/npm/@types%2flodash/4.17.18/4.17.20)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/@types%2flodash/4.17.20?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@types%2flodash/4.17.18/4.17.20?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| devDependencies | patch |
|
[@types/node](https://redirect.github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/node)
([source](https://redirect.github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node))
| [`22.15.32` ->
`22.16.3`](https://renovatebot.com/diffs/npm/@types%2fnode/22.15.32/22.16.3)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/@types%2fnode/22.16.3?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@types%2fnode/22.15.32/22.16.3?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| devDependencies | minor |
|
[@typescript-eslint/eslint-plugin](https://typescript-eslint.io/packages/eslint-plugin)
([source](https://redirect.github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin))
| [`8.34.1` ->
`8.36.0`](https://renovatebot.com/diffs/npm/@typescript-eslint%2feslint-plugin/8.34.1/8.36.0)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/@typescript-eslint%2feslint-plugin/8.36.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@typescript-eslint%2feslint-plugin/8.34.1/8.36.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| devDependencies | minor |
|
[@vueuse/components](https://redirect.github.com/vueuse/vueuse/tree/main/packages/components#readme)
([source](https://redirect.github.com/vueuse/vueuse/tree/HEAD/packages/components))
| [`13.4.0` ->
`13.5.0`](https://renovatebot.com/diffs/npm/@vueuse%2fcomponents/13.4.0/13.5.0)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/@vueuse%2fcomponents/13.5.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@vueuse%2fcomponents/13.4.0/13.5.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| dependencies | minor |
| [@vueuse/core](https://redirect.github.com/vueuse/vueuse)
([source](https://redirect.github.com/vueuse/vueuse/tree/HEAD/packages/core))
| [`13.4.0` ->
`13.5.0`](https://renovatebot.com/diffs/npm/@vueuse%2fcore/13.4.0/13.5.0)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/@vueuse%2fcore/13.5.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@vueuse%2fcore/13.4.0/13.5.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| devDependencies | minor |
| [@vueuse/core](https://redirect.github.com/vueuse/vueuse)
([source](https://redirect.github.com/vueuse/vueuse/tree/HEAD/packages/core))
| [`13.4.0` ->
`13.5.0`](https://renovatebot.com/diffs/npm/@vueuse%2fcore/13.4.0/13.5.0)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/@vueuse%2fcore/13.5.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@vueuse%2fcore/13.4.0/13.5.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| dependencies | minor |
|
[@vueuse/integrations](https://redirect.github.com/vueuse/vueuse/tree/main/packages/integrations#readme)
([source](https://redirect.github.com/vueuse/vueuse/tree/HEAD/packages/integrations))
| [`13.4.0` ->
`13.5.0`](https://renovatebot.com/diffs/npm/@vueuse%2fintegrations/13.4.0/13.5.0)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/@vueuse%2fintegrations/13.5.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@vueuse%2fintegrations/13.4.0/13.5.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| dependencies | minor |
|
[@vueuse/nuxt](https://redirect.github.com/vueuse/vueuse/tree/main/packages/nuxt#readme)
([source](https://redirect.github.com/vueuse/vueuse/tree/HEAD/packages/nuxt))
| [`13.4.0` ->
`13.5.0`](https://renovatebot.com/diffs/npm/@vueuse%2fnuxt/13.4.0/13.5.0)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/@vueuse%2fnuxt/13.5.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@vueuse%2fnuxt/13.4.0/13.5.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| devDependencies | minor |
|
[awalsh128/cache-apt-pkgs-action](https://redirect.github.com/awalsh128/cache-apt-pkgs-action)
| `v1.4.3` -> `v1.5.1` |
[![age](https://developer.mend.io/api/mc/badges/age/github-tags/awalsh128%2fcache-apt-pkgs-action/v1.5.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/github-tags/awalsh128%2fcache-apt-pkgs-action/v1.4.3/v1.5.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| action | minor |
| [cache-manager](https://redirect.github.com/jaredwray/cacheable)
([source](https://redirect.github.com/jaredwray/cacheable/tree/HEAD/packages/cache-manager))
| [`7.0.0` ->
`7.0.1`](https://renovatebot.com/diffs/npm/cache-manager/7.0.0/7.0.1) |
[![age](https://developer.mend.io/api/mc/badges/age/npm/cache-manager/7.0.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/cache-manager/7.0.0/7.0.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| dependencies | patch |
|
[commit-and-tag-version](https://redirect.github.com/absolute-version/commit-and-tag-version)
| [`9.5.0` ->
`9.6.0`](https://renovatebot.com/diffs/npm/commit-and-tag-version/9.5.0/9.6.0)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/commit-and-tag-version/9.6.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/commit-and-tag-version/9.5.0/9.6.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| devDependencies | minor |
|
[concurrently](https://redirect.github.com/open-cli-tools/concurrently)
| [`9.1.2` ->
`9.2.0`](https://renovatebot.com/diffs/npm/concurrently/9.1.2/9.2.0) |
[![age](https://developer.mend.io/api/mc/badges/age/npm/concurrently/9.2.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/concurrently/9.1.2/9.2.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| devDependencies | minor |
| [dotenv](https://redirect.github.com/motdotla/dotenv) | [`17.1.0` ->
`17.2.0`](https://renovatebot.com/diffs/npm/dotenv/17.1.0/17.2.0) |
[![age](https://developer.mend.io/api/mc/badges/age/npm/dotenv/17.2.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/dotenv/17.1.0/17.2.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| dependencies | minor |
| [eslint](https://eslint.org)
([source](https://redirect.github.com/eslint/eslint)) | [`9.29.0` ->
`9.30.1`](https://renovatebot.com/diffs/npm/eslint/9.29.0/9.30.1) |
[![age](https://developer.mend.io/api/mc/badges/age/npm/eslint/9.30.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/eslint/9.29.0/9.30.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| devDependencies | minor |
|
[eslint-plugin-import](https://redirect.github.com/import-js/eslint-plugin-import)
| [`2.31.0` ->
`2.32.0`](https://renovatebot.com/diffs/npm/eslint-plugin-import/2.31.0/2.32.0)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/eslint-plugin-import/2.32.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/eslint-plugin-import/2.31.0/2.32.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| devDependencies | minor |
|
[eslint-plugin-n](https://redirect.github.com/eslint-community/eslint-plugin-n)
| [`17.20.0` ->
`17.21.0`](https://renovatebot.com/diffs/npm/eslint-plugin-n/17.20.0/17.21.0)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/eslint-plugin-n/17.21.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/eslint-plugin-n/17.20.0/17.21.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| devDependencies | minor |
|
[eslint-plugin-prettier](https://redirect.github.com/prettier/eslint-plugin-prettier)
| [`5.5.0` ->
`5.5.1`](https://renovatebot.com/diffs/npm/eslint-plugin-prettier/5.5.0/5.5.1)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/eslint-plugin-prettier/5.5.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/eslint-plugin-prettier/5.5.0/5.5.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| devDependencies | patch |
| [eslint-plugin-vue](https://eslint.vuejs.org)
([source](https://redirect.github.com/vuejs/eslint-plugin-vue)) |
[`10.2.0` ->
`10.3.0`](https://renovatebot.com/diffs/npm/eslint-plugin-vue/10.2.0/10.3.0)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/eslint-plugin-vue/10.3.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/eslint-plugin-vue/10.2.0/10.3.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| devDependencies | minor |
| [fast-check](https://fast-check.dev/)
([source](https://redirect.github.com/dubzzz/fast-check/tree/HEAD/packages/fast-check))
| [`4.1.1` ->
`4.2.0`](https://renovatebot.com/diffs/npm/fast-check/4.1.1/4.2.0) |
[![age](https://developer.mend.io/api/mc/badges/age/npm/fast-check/4.2.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/fast-check/4.1.1/4.2.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| devDependencies | minor |
| [glob](https://redirect.github.com/isaacs/node-glob) | [`11.0.1` ->
`11.0.3`](https://renovatebot.com/diffs/npm/glob/11.0.1/11.0.3) |
[![age](https://developer.mend.io/api/mc/badges/age/npm/glob/11.0.3?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/glob/11.0.1/11.0.3?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| dependencies | patch |
| [happy-dom](https://redirect.github.com/capricorn86/happy-dom) |
[`18.0.0` ->
`18.0.1`](https://renovatebot.com/diffs/npm/happy-dom/18.0.0/18.0.1) |
[![age](https://developer.mend.io/api/mc/badges/age/npm/happy-dom/18.0.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/happy-dom/18.0.0/18.0.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| devDependencies | patch |
|
[inquirer](https://redirect.github.com/SBoudrias/Inquirer.js/blob/main/packages/inquirer/README.md)
([source](https://redirect.github.com/SBoudrias/Inquirer.js)) |
[`12.6.3` ->
`12.7.0`](https://renovatebot.com/diffs/npm/inquirer/12.6.3/12.7.0) |
[![age](https://developer.mend.io/api/mc/badges/age/npm/inquirer/12.7.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/inquirer/12.6.3/12.7.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| dependencies | minor |
|
[isomorphic-dompurify](https://redirect.github.com/kkomelin/isomorphic-dompurify)
| [`2.25.0` ->
`2.26.0`](https://renovatebot.com/diffs/npm/isomorphic-dompurify/2.25.0/2.26.0)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/isomorphic-dompurify/2.26.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/isomorphic-dompurify/2.25.0/2.26.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| dependencies | minor |
| [lucide-vue-next](https://lucide.dev)
([source](https://redirect.github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-vue-next))
| [`0.519.0` ->
`0.525.0`](https://renovatebot.com/diffs/npm/lucide-vue-next/0.519.0/0.525.0)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/lucide-vue-next/0.525.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/lucide-vue-next/0.519.0/0.525.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| dependencies | minor |
|
[marked-base-url](https://redirect.github.com/markedjs/marked-base-url)
| [`1.1.6` ->
`1.1.7`](https://renovatebot.com/diffs/npm/marked-base-url/1.1.6/1.1.7)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/marked-base-url/1.1.7?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/marked-base-url/1.1.6/1.1.7?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| dependencies | patch |
| [node](https://nodejs.org)
([source](https://redirect.github.com/nodejs/node)) | `22.16.0` ->
`22.17.0` |
[![age](https://developer.mend.io/api/mc/badges/age/node-version/node/v22.17.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/node-version/node/v22.16.0/v22.17.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| | minor |
| [nuxt](https://nuxt.com)
([source](https://redirect.github.com/nuxt/nuxt/tree/HEAD/packages/nuxt))
| [`3.17.5` ->
`3.17.6`](https://renovatebot.com/diffs/npm/nuxt/3.17.5/3.17.6) |
[![age](https://developer.mend.io/api/mc/badges/age/npm/nuxt/3.17.6?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/nuxt/3.17.5/3.17.6?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| devDependencies | patch |
| [pnpm](https://pnpm.io)
([source](https://redirect.github.com/pnpm/pnpm/tree/HEAD/pnpm)) |
[`10.12.4` ->
`10.13.1`](https://renovatebot.com/diffs/npm/pnpm/10.12.4/10.13.1) |
[![age](https://developer.mend.io/api/mc/badges/age/npm/pnpm/10.13.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/pnpm/10.12.4/10.13.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| packageManager | minor |
| [pnpm](https://pnpm.io)
([source](https://redirect.github.com/pnpm/pnpm/tree/HEAD/pnpm)) |
[`10.12.4` ->
`10.13.1`](https://renovatebot.com/diffs/npm/pnpm/10.12.4/10.13.1) |
[![age](https://developer.mend.io/api/mc/badges/age/npm/pnpm/10.13.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/pnpm/10.12.4/10.13.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| engines | minor |
| [prettier](https://prettier.io)
([source](https://redirect.github.com/prettier/prettier)) | [`3.5.3` ->
`3.6.2`](https://renovatebot.com/diffs/npm/prettier/3.5.3/3.6.2) |
[![age](https://developer.mend.io/api/mc/badges/age/npm/prettier/3.6.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/prettier/3.5.3/3.6.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| devDependencies | minor |
|
[prettier-plugin-tailwindcss](https://redirect.github.com/tailwindlabs/prettier-plugin-tailwindcss)
| [`0.6.13` ->
`0.6.14`](https://renovatebot.com/diffs/npm/prettier-plugin-tailwindcss/0.6.13/0.6.14)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/prettier-plugin-tailwindcss/0.6.14?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/prettier-plugin-tailwindcss/0.6.13/0.6.14?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| devDependencies | patch |
| [reka-ui](https://redirect.github.com/unovue/reka-ui) | [`2.3.1` ->
`2.3.2`](https://renovatebot.com/diffs/npm/reka-ui/2.3.1/2.3.2) |
[![age](https://developer.mend.io/api/mc/badges/age/npm/reka-ui/2.3.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/reka-ui/2.3.1/2.3.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| dependencies | patch |
| [semver](https://redirect.github.com/npm/node-semver) | [`7.7.1` ->
`7.7.2`](https://renovatebot.com/diffs/npm/semver/7.7.1/7.7.2) |
[![age](https://developer.mend.io/api/mc/badges/age/npm/semver/7.7.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/semver/7.7.1/7.7.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| dependencies | patch |
| [systeminformation](https://systeminformation.io)
([source](https://redirect.github.com/sebhildebrandt/systeminformation))
| [`5.27.6` ->
`5.27.7`](https://renovatebot.com/diffs/npm/systeminformation/5.27.6/5.27.7)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/systeminformation/5.27.7?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/systeminformation/5.27.6/5.27.7?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| dependencies | patch |
| [tsx](https://tsx.is)
([source](https://redirect.github.com/privatenumber/tsx)) | [`4.19.3` ->
`4.20.3`](https://renovatebot.com/diffs/npm/tsx/4.19.3/4.20.3) |
[![age](https://developer.mend.io/api/mc/badges/age/npm/tsx/4.20.3?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/tsx/4.19.3/4.20.3?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| dependencies | minor |
|
[typescript-eslint](https://typescript-eslint.io/packages/typescript-eslint)
([source](https://redirect.github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint))
| [`8.34.1` ->
`8.36.0`](https://renovatebot.com/diffs/npm/typescript-eslint/8.34.1/8.36.0)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/typescript-eslint/8.36.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/typescript-eslint/8.34.1/8.36.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| devDependencies | minor |
| [vite](https://vite.dev)
([source](https://redirect.github.com/vitejs/vite/tree/HEAD/packages/vite))
| [`7.0.3` ->
`7.0.4`](https://renovatebot.com/diffs/npm/vite/7.0.3/7.0.4) |
[![age](https://developer.mend.io/api/mc/badges/age/npm/vite/7.0.4?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/vite/7.0.3/7.0.4?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| devDependencies | patch |
| [vitest](https://redirect.github.com/vitest-dev/vitest)
([source](https://redirect.github.com/vitest-dev/vitest/tree/HEAD/packages/vitest))
| [`3.0.7` ->
`3.2.4`](https://renovatebot.com/diffs/npm/vitest/3.0.7/3.2.4) |
[![age](https://developer.mend.io/api/mc/badges/age/npm/vitest/3.2.4?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/vitest/3.0.7/3.2.4?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| devDependencies | minor |
|
[vue-i18n](https://redirect.github.com/intlify/vue-i18n/tree/master/packages/vue-i18n#readme)
([source](https://redirect.github.com/intlify/vue-i18n/tree/HEAD/packages/vue-i18n))
| [`11.1.6` ->
`11.1.9`](https://renovatebot.com/diffs/npm/vue-i18n/11.1.6/11.1.9) |
[![age](https://developer.mend.io/api/mc/badges/age/npm/vue-i18n/11.1.9?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/vue-i18n/11.1.6/11.1.9?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| dependencies | patch |
| [vue-sonner](https://redirect.github.com/xiaoluoboding/vue-sonner) |
[`1.3.0` ->
`1.3.2`](https://renovatebot.com/diffs/npm/vue-sonner/1.3.0/1.3.2) |
[![age](https://developer.mend.io/api/mc/badges/age/npm/vue-sonner/1.3.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/vue-sonner/1.3.0/1.3.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| dependencies | patch |
| [vuetify](https://vuetifyjs.com)
([source](https://redirect.github.com/vuetifyjs/vuetify/tree/HEAD/packages/vuetify))
| [`3.8.10` ->
`3.9.0`](https://renovatebot.com/diffs/npm/vuetify/3.8.10/3.9.0) |
[![age](https://developer.mend.io/api/mc/badges/age/npm/vuetify/3.9.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/vuetify/3.8.10/3.9.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| dependencies | minor |
| [wrangler](https://redirect.github.com/cloudflare/workers-sdk)
([source](https://redirect.github.com/cloudflare/workers-sdk/tree/HEAD/packages/wrangler))
| [`^3.87.0` ->
`^3.114.10`](https://renovatebot.com/diffs/npm/wrangler/3.114.10/3.114.11)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/wrangler/3.114.11?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/wrangler/3.114.10/3.114.11?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| devDependencies | patch |
| [ws](https://redirect.github.com/websockets/ws) | [`8.18.2` ->
`8.18.3`](https://renovatebot.com/diffs/npm/ws/8.18.2/8.18.3) |
[![age](https://developer.mend.io/api/mc/badges/age/npm/ws/8.18.3?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/ws/8.18.2/8.18.3?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| devDependencies | patch |
| [ws](https://redirect.github.com/websockets/ws) | [`8.18.2` ->
`8.18.3`](https://renovatebot.com/diffs/npm/ws/8.18.2/8.18.3) |
[![age](https://developer.mend.io/api/mc/badges/age/npm/ws/8.18.3?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/ws/8.18.2/8.18.3?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| dependencies | patch |
| [zod](https://zod.dev)
([source](https://redirect.github.com/colinhacks/zod)) | [`3.24.2` ->
`3.25.76`](https://renovatebot.com/diffs/npm/zod/3.24.2/3.25.76) |
[![age](https://developer.mend.io/api/mc/badges/age/npm/zod/3.25.76?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/zod/3.24.2/3.25.76?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| dependencies | minor |
| [zod](https://zod.dev)
([source](https://redirect.github.com/colinhacks/zod)) | [`3.25.67` ->
`3.25.76`](https://renovatebot.com/diffs/npm/zod/3.25.67/3.25.76) |
[![age](https://developer.mend.io/api/mc/badges/age/npm/zod/3.25.76?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/zod/3.25.67/3.25.76?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| dependencies | patch |
| [zx](https://google.github.io/zx/)
([source](https://redirect.github.com/google/zx)) | [`8.3.2` ->
`8.6.2`](https://renovatebot.com/diffs/npm/zx/8.3.2/8.6.2) |
[![age](https://developer.mend.io/api/mc/badges/age/npm/zx/8.6.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/zx/8.3.2/8.6.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| dependencies | minor |
| [zx](https://google.github.io/zx/)
([source](https://redirect.github.com/google/zx)) | [`8.5.5` ->
`8.6.2`](https://renovatebot.com/diffs/npm/zx/8.5.5/8.6.2) |
[![age](https://developer.mend.io/api/mc/badges/age/npm/zx/8.6.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/zx/8.5.5/8.6.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| devDependencies | minor |

---

### Release Notes

<details>
<summary>eslint/eslint (@&#8203;eslint/js)</summary>

###
[`v9.30.1`](https://redirect.github.com/eslint/eslint/compare/v9.30.0...b3dbc16563cb7036d75edff9814e17053a645321)

[Compare
Source](https://redirect.github.com/eslint/eslint/compare/v9.30.0...v9.30.1)

###
[`v9.30.0`](https://redirect.github.com/eslint/eslint/compare/v9.29.0...5a5d5261037fdf84a91f2f22d3726d58572453f4)

[Compare
Source](https://redirect.github.com/eslint/eslint/compare/v9.29.0...v9.30.0)

</details>

<details>
<summary>faker-js/faker (@&#8203;faker-js/faker)</summary>

###
[`v9.9.0`](https://redirect.github.com/faker-js/faker/blob/HEAD/CHANGELOG.md#990-2025-07-01)

[Compare
Source](https://redirect.github.com/faker-js/faker/compare/v9.8.0...v9.9.0)

##### New Locales

- **locale:** add word data to pt\_br and pt\_pt locales
([#&#8203;3531](https://redirect.github.com/faker-js/faker/issues/3531))
([a405ac8](a405ac8740))

##### Features

- **location:** simple coordinate methods
([#&#8203;3528](https://redirect.github.com/faker-js/faker/issues/3528))
([d07d96d](d07d96d018))

</details>

<details>
<summary>floating-ui/floating-ui (@&#8203;floating-ui/dom)</summary>

###
[`v1.7.2`](https://redirect.github.com/floating-ui/floating-ui/blob/HEAD/packages/dom/CHANGELOG.md#172)

[Compare
Source](https://redirect.github.com/floating-ui/floating-ui/compare/@floating-ui/dom@1.7.1...@floating-ui/dom@1.7.2)

##### Patch Changes

- perf: reduce memory allocations
- Update dependencies: `@floating-ui/utils@0.2.10`,
`@floating-ui/core@1.7.2`

</details>

<details>
<summary>floating-ui/floating-ui (@&#8203;floating-ui/utils)</summary>

###
[`v0.2.10`](https://redirect.github.com/floating-ui/floating-ui/blob/HEAD/packages/utils/CHANGELOG.md#0210)

[Compare
Source](https://redirect.github.com/floating-ui/floating-ui/compare/@floating-ui/utils@0.2.9...@floating-ui/utils@0.2.10)

##### Patch Changes

- refactor: small performance improvements
- perf: reduce memory allocations

</details>

<details>
<summary>floating-ui/floating-ui (@&#8203;floating-ui/vue)</summary>

###
[`v1.1.7`](https://redirect.github.com/floating-ui/floating-ui/blob/HEAD/packages/vue/CHANGELOG.md#117)

[Compare
Source](https://redirect.github.com/floating-ui/floating-ui/compare/@floating-ui/vue@1.1.6...@floating-ui/vue@1.1.7)

##### Patch Changes

- Update dependencies: `@floating-ui/utils@0.2.10`,
`@floating-ui/dom@1.7.2`

</details>

<details>
<summary>dotansimha/graphql-code-generator
(@&#8203;graphql-codegen/client-preset)</summary>

###
[`v4.8.3`](https://redirect.github.com/dotansimha/graphql-code-generator/blob/HEAD/packages/presets/client/CHANGELOG.md#483)

[Compare
Source](https://redirect.github.com/dotansimha/graphql-code-generator/compare/@graphql-codegen/client-preset@4.8.2...@graphql-codegen/client-preset@4.8.3)

##### Patch Changes

-
[#&#8203;10362](https://redirect.github.com/dotansimha/graphql-code-generator/pull/10362)
[`3188b8c`](3188b8c39e)
Thanks [@&#8203;Brookke](https://redirect.github.com/Brookke)! - Make
generated type compatible with noImplicitOverride=true

-
[#&#8203;10373](https://redirect.github.com/dotansimha/graphql-code-generator/pull/10373)
[`c3295f9`](c3295f9c60)
Thanks [@&#8203;eddeee888](https://redirect.github.com/eddeee888)! - Fix
client preset not working with exactOptionalPropertyTypes=true when
documentMode=string

- Updated dependencies
\[[`3188b8c`](3188b8c39e),
[`c3295f9`](c3295f9c60)]:
-
[@&#8203;graphql-codegen/typed-document-node](https://redirect.github.com/graphql-codegen/typed-document-node)@&#8203;5.1.2

</details>

<details>
<summary>dotansimha/graphql-code-generator
(@&#8203;graphql-codegen/typed-document-node)</summary>

###
[`v5.1.2`](https://redirect.github.com/dotansimha/graphql-code-generator/blob/HEAD/packages/plugins/typescript/typed-document-node/CHANGELOG.md#512)

[Compare
Source](https://redirect.github.com/dotansimha/graphql-code-generator/compare/@graphql-codegen/typed-document-node@5.1.1...@graphql-codegen/typed-document-node@5.1.2)

##### Patch Changes

-
[#&#8203;10362](https://redirect.github.com/dotansimha/graphql-code-generator/pull/10362)
[`3188b8c`](3188b8c39e)
Thanks [@&#8203;Brookke](https://redirect.github.com/Brookke)! - Make
generated type compatible with noImplicitOverride=true

-
[#&#8203;10373](https://redirect.github.com/dotansimha/graphql-code-generator/pull/10373)
[`c3295f9`](c3295f9c60)
Thanks [@&#8203;eddeee888](https://redirect.github.com/eddeee888)! - Fix
client preset not working with exactOptionalPropertyTypes=true when
documentMode=string

</details>

<details>
<summary>ianvs/prettier-plugin-sort-imports
(@&#8203;ianvs/prettier-plugin-sort-imports)</summary>

###
[`v4.5.1`](https://redirect.github.com/ianvs/prettier-plugin-sort-imports/compare/v4.5.0...040fa5e3a7dd01a90d80bb12072344745e426da6)

[Compare
Source](https://redirect.github.com/ianvs/prettier-plugin-sort-imports/compare/v4.5.0...v4.5.1)

###
[`v4.5.0`](https://redirect.github.com/ianvs/prettier-plugin-sort-imports/compare/v4.4.2...3497e9a87974954e42198d04d69d9a2a24dbebbd)

[Compare
Source](https://redirect.github.com/ianvs/prettier-plugin-sort-imports/compare/v4.4.2...v4.5.0)

</details>

<details>
<summary>nuxt/devtools (@&#8203;nuxt/devtools)</summary>

###
[`v2.6.2`](https://redirect.github.com/nuxt/devtools/blob/HEAD/CHANGELOG.md#262-2025-07-02)

[Compare
Source](https://redirect.github.com/nuxt/devtools/compare/v2.6.1...v2.6.2)

##### Bug Fixes

- panel dragging issue, close
[#&#8203;874](https://redirect.github.com/nuxt/devtools/issues/874),
close
[#&#8203;871](https://redirect.github.com/nuxt/devtools/issues/871),
close
[#&#8203;873](https://redirect.github.com/nuxt/devtools/issues/873)
([619de37](619de37ace))

###
[`v2.6.1`](https://redirect.github.com/nuxt/devtools/blob/HEAD/CHANGELOG.md#261-2025-07-01)

[Compare
Source](https://redirect.github.com/nuxt/devtools/compare/v2.6.0...v2.6.1)

##### Bug Fixes

- **deps:** do not depend on `@nuxt/schema`
([#&#8203;872](https://redirect.github.com/nuxt/devtools/issues/872))
([62443ec](62443ecb12))

###
[`v2.6.0`](https://redirect.github.com/nuxt/devtools/blob/HEAD/CHANGELOG.md#260-2025-06-29)

[Compare
Source](https://redirect.github.com/nuxt/devtools/compare/v2.5.0...v2.6.0)

##### Bug Fixes

- timing labels wrapping
([#&#8203;866](https://redirect.github.com/nuxt/devtools/issues/866))
([fd01e60](fd01e6022a))

##### Features

- update deps
([eef2c09](eef2c09ea1))

</details>

<details>
<summary>nuxt/eslint (@&#8203;nuxt/eslint)</summary>

###
[`v1.5.2`](https://redirect.github.com/nuxt/eslint/releases/tag/v1.5.2)

[Compare
Source](https://redirect.github.com/nuxt/eslint/compare/v1.5.1...v1.5.2)

#####    🚀 Features

- Add option `features.import.plugin` to swap plugin implementation,
close [#&#8203;587](https://redirect.github.com/nuxt/eslint/issues/587)
 -  by [@&#8203;antfu](https://redirect.github.com/antfu) in
[https://github.com/nuxt/eslint/issues/587](https://redirect.github.com/nuxt/eslint/issues/587)
[<samp>(66f5e)</samp>](https://redirect.github.com/nuxt/eslint/commit/66f5ee0)

#####     [View changes on
GitHub](https://redirect.github.com/nuxt/eslint/compare/v1.5.1...v1.5.2)

###
[`v1.5.1`](https://redirect.github.com/nuxt/eslint/releases/tag/v1.5.1)

[Compare
Source](https://redirect.github.com/nuxt/eslint/compare/v1.5.0...v1.5.1)

#####    🐞 Bug Fixes

- **eslint-config**: Replace deprecated vue/object-property-newline
option  -  by [@&#8203;amery](https://redirect.github.com/amery) in
[https://github.com/nuxt/eslint/issues/586](https://redirect.github.com/nuxt/eslint/issues/586)
[<samp>(7805e)</samp>](https://redirect.github.com/nuxt/eslint/commit/7805e0d)

#####     [View changes on
GitHub](https://redirect.github.com/nuxt/eslint/compare/v1.5.0...v1.5.1)

###
[`v1.5.0`](https://redirect.github.com/nuxt/eslint/releases/tag/v1.5.0)

[Compare
Source](https://redirect.github.com/nuxt/eslint/compare/v1.4.1...v1.5.0)

#####    🚀 Features

- Switch to `eslint-plugin-import-lite`, update deps  -  by
[@&#8203;antfu](https://redirect.github.com/antfu)
[<samp>(31bd8)</samp>](https://redirect.github.com/nuxt/eslint/commit/31bd8a0)

#####    🐞 Bug Fixes

- **eslint-config**: Add file type restrictions to prevent CSS parsing
errors  -  by [@&#8203;amery](https://redirect.github.com/amery) in
[https://github.com/nuxt/eslint/issues/584](https://redirect.github.com/nuxt/eslint/issues/584)
[<samp>(40521)</samp>](https://redirect.github.com/nuxt/eslint/commit/40521a1)

#####     [View changes on
GitHub](https://redirect.github.com/nuxt/eslint/compare/v1.4.1...v1.5.0)

</details>

<details>
<summary>nuxt/test-utils (@&#8203;nuxt/test-utils)</summary>

###
[`v3.19.2`](https://redirect.github.com/nuxt/test-utils/releases/tag/v3.19.2)

[Compare
Source](https://redirect.github.com/nuxt/test-utils/compare/v3.19.1...v3.19.2)

> 3.19.2 is the next patch release.
>
> **Timetable**: 1 July

#### 👉 Changelog

[compare
changes](https://redirect.github.com/nuxt/test-utils/compare/v3.19.1...v3.19.2)

##### 🩹 Fixes

- **config:** Add missing mocks for vue-devtools
([#&#8203;1321](https://redirect.github.com/nuxt/test-utils/pull/1321))
- **runtime-utils:** Prevent event duplication
([#&#8203;1328](https://redirect.github.com/nuxt/test-utils/pull/1328))
- **config:** Include tests without `.nuxt.` extension
([#&#8203;1311](https://redirect.github.com/nuxt/test-utils/pull/1311))
- **deps:** Drop `@nuxt/schema` dependeny
([fa3a99b4](https://redirect.github.com/nuxt/test-utils/commit/fa3a99b4))
- **config:** Use 'projects' for `vitest` >= v3.2
([#&#8203;1344](https://redirect.github.com/nuxt/test-utils/pull/1344))
- **module:** Use user `vite` version to merge config
([#&#8203;1345](https://redirect.github.com/nuxt/test-utils/pull/1345))
- **runtime-utils:** Handle computed defined using an object
([#&#8203;1342](https://redirect.github.com/nuxt/test-utils/pull/1342))

##### 🏡 Chore

- Prefer `nuxt` over `nuxi`
([#&#8203;1310](https://redirect.github.com/nuxt/test-utils/pull/1310))
- Pin node types
([93921643](https://redirect.github.com/nuxt/test-utils/commit/93921643))
- Do not include dev-deps in `engines.node` calculation
([2f74359b](https://redirect.github.com/nuxt/test-utils/commit/2f74359b))
- Add type assertions for indexed access
([51b4a4e3](https://redirect.github.com/nuxt/test-utils/commit/51b4a4e3))
- Update installed-check flag
([2b97d885](https://redirect.github.com/nuxt/test-utils/commit/2b97d885))

#####  Tests

- Update stub name for nuxt v4
([e7b07843](https://redirect.github.com/nuxt/test-utils/commit/e7b07843))
- Satisfy typescript
([fb0dea24](https://redirect.github.com/nuxt/test-utils/commit/fb0dea24))
- Update cucumber test for nuxt v4 welcome screen template
([8ec7782f](https://redirect.github.com/nuxt/test-utils/commit/8ec7782f))
- Simplify test
([90278bac](https://redirect.github.com/nuxt/test-utils/commit/90278bac))
- Update workspace example
([02f9b0a0](https://redirect.github.com/nuxt/test-utils/commit/02f9b0a0))
- Make browser tests forward-compat with v4
([574ea5f9](https://redirect.github.com/nuxt/test-utils/commit/574ea5f9))

##### 🤖 CI

- Remove forced corepack installation
([bf19bd3a](https://redirect.github.com/nuxt/test-utils/commit/bf19bd3a))
- Run `knip`
([819aeacc](https://redirect.github.com/nuxt/test-utils/commit/819aeacc))
- Prepare environment before knipping
([ec7d8ddd](https://redirect.github.com/nuxt/test-utils/commit/ec7d8ddd))

##### ❤️ Contributors

- Daniel Roe
([@&#8203;danielroe](https://redirect.github.com/danielroe))
- Tomina ([@&#8203;Thomaash](https://redirect.github.com/Thomaash))
- lutejka ([@&#8203;lutejka](https://redirect.github.com/lutejka))
- J-Michalek
([@&#8203;J-Michalek](https://redirect.github.com/J-Michalek))

</details>

<details>
<summary>rollup/rollup (@&#8203;rollup/rollup-linux-x64-gnu)</summary>

###
[`v4.44.2`](https://redirect.github.com/rollup/rollup/blob/HEAD/CHANGELOG.md#4442)

[Compare
Source](https://redirect.github.com/rollup/rollup/compare/v4.44.1...v4.44.2)

*2025-07-04*

##### Bug Fixes

- Correctly handle `@__PURE__` annotations after `new` keyword
([#&#8203;5998](https://redirect.github.com/rollup/rollup/issues/5998))
- Generate correct source mapping for closing braces of block statements
([#&#8203;5999](https://redirect.github.com/rollup/rollup/issues/5999))

##### Pull Requests

- [#&#8203;5998](https://redirect.github.com/rollup/rollup/pull/5998):
Support `@__PURE__` when nested after new in constructor invocations
([@&#8203;TrickyPi](https://redirect.github.com/TrickyPi))
- [#&#8203;5999](https://redirect.github.com/rollup/rollup/pull/5999):
Add location info for closing brace of block statement
([@&#8203;TrickyPi](https://redirect.github.com/TrickyPi))
- [#&#8203;6002](https://redirect.github.com/rollup/rollup/pull/6002):
chore(deps): update dependency vite to v7
([@&#8203;renovate](https://redirect.github.com/renovate)\[bot],
[@&#8203;lukastaegert](https://redirect.github.com/lukastaegert))
- [#&#8203;6004](https://redirect.github.com/rollup/rollup/pull/6004):
fix(deps): lock file maintenance minor/patch updates
([@&#8203;renovate](https://redirect.github.com/renovate)\[bot],
[@&#8203;lukastaegert](https://redirect.github.com/lukastaegert))

###
[`v4.44.1`](https://redirect.github.com/rollup/rollup/blob/HEAD/CHANGELOG.md#4441)

[Compare
Source](https://redirect.github.com/rollup/rollup/compare/v4.44.0...v4.44.1)

*2025-06-26*

##### Bug Fixes

- Reinstate maxParallelFileOps limit of 1000 to resolve the issue for
some
([#&#8203;5992](https://redirect.github.com/rollup/rollup/issues/5992))

##### Pull Requests

- [#&#8203;5988](https://redirect.github.com/rollup/rollup/pull/5988):
fix(deps): lock file maintenance minor/patch updates
([@&#8203;renovate](https://redirect.github.com/renovate)\[bot],
[@&#8203;lukastaegert](https://redirect.github.com/lukastaegert))
- [#&#8203;5992](https://redirect.github.com/rollup/rollup/pull/5992):
Set maxParallelFileOps to 1000
([@&#8203;lukastaegert](https://redirect.github.com/lukastaegert))

</details>

<details>
<summary>swc-project/swc (@&#8203;swc/core)</summary>

###
[`v1.12.11`](https://redirect.github.com/swc-project/swc/blob/HEAD/CHANGELOG.md#11211---2025-07-08)

[Compare
Source](https://redirect.github.com/swc-project/swc/compare/v1.12.9...v1.12.11)

##### Bug Fixes

- **(ci)** Fix CI
([#&#8203;10790](https://redirect.github.com/swc-project/swc/issues/10790))
([b3f9760](b3f97604b8))

- **(es)** Use `default-features = false` for `swc` crate usages
([#&#8203;10776](https://redirect.github.com/swc-project/swc/issues/10776))
([50b2eac](50b2eacdf7))

- **(es)** Make `swc_typescript` optional
([#&#8203;10792](https://redirect.github.com/swc-project/swc/issues/10792))
([c32569d](c32569dd55))

- **(preset-env)** Fix `default` value for `caniuse`
([#&#8203;10754](https://redirect.github.com/swc-project/swc/issues/10754))
([aa4cd5b](aa4cd5ba7c))

- **(preset-env)** Revert `default` value
([#&#8203;10778](https://redirect.github.com/swc-project/swc/issues/10778))
([7af5824](7af58242c2))

##### Features

- **(es/minifeir)** Inline lazily initialized literals
([#&#8203;10752](https://redirect.github.com/swc-project/swc/issues/10752))
([fd5d2e2](fd5d2e2f33))

- **(es/minifier)** Evaluate `Number.XXX` constants
([#&#8203;10756](https://redirect.github.com/swc-project/swc/issues/10756))
([c47dab5](c47dab5f90))

- **(es/minifier)** Implement partial evaluation of array join
([#&#8203;10758](https://redirect.github.com/swc-project/swc/issues/10758))
([bdf3a98](bdf3a98bb4))

- **(swc\_core)** Expose `swc_ecma_parser/unstable`
([#&#8203;10744](https://redirect.github.com/swc-project/swc/issues/10744))
([db0679e](db0679e5ca))

##### Miscellaneous Tasks

- **(common)** Remove `clone()` in proc macro
([#&#8203;10762](https://redirect.github.com/swc-project/swc/issues/10762))
([12e3180](12e318036c))

- **(deps)** Update `browserslist-rs` to `0.19`
([#&#8203;10750](https://redirect.github.com/swc-project/swc/issues/10750))
([f8bf21c](f8bf21c072))

- **(deps)** Remove unused deps with cargo-shear
([#&#8203;10765](https://redirect.github.com/swc-project/swc/issues/10765))
([f4e4974](f4e4974ffe))

- **(es/module)** Drop `node` feature of `swc_ecma_loader`
([#&#8203;10761](https://redirect.github.com/swc-project/swc/issues/10761))
([44471b5](44471b5151))

- **(plugin/runner)** Remove unused feature and dependency
([#&#8203;10764](https://redirect.github.com/swc-project/swc/issues/10764))
([a7d8a0a](a7d8a0ac89))

##### Performance

- **(es/lexer)** Use `bitflags` for `Syntax`
([#&#8203;10676](https://redirect.github.com/swc-project/swc/issues/10676))
([bf8c722](bf8c722e25))

- **(es/lexer)** Do not scan number if there's no underscore
([#&#8203;10788](https://redirect.github.com/swc-project/swc/issues/10788))
([f5d92ee](f5d92ee1bf))

- **(es/lints)** Make rules not parallel
([#&#8203;10772](https://redirect.github.com/swc-project/swc/issues/10772))
([4e6001c](4e6001c5a4))

- **(es/lints)** Merge critical rules
([#&#8203;10773](https://redirect.github.com/swc-project/swc/issues/10773))
([816e75a](816e75a209))

- **(es/parser)** Reduce the number of context set ops
([#&#8203;10742](https://redirect.github.com/swc-project/swc/issues/10742))
([08b4e8b](08b4e8b285))

- **(es/parser)** Reduce value set operations for context
([#&#8203;10751](https://redirect.github.com/swc-project/swc/issues/10751))
([4976b12](4976b12f93))

- **(es/parser)** Reduce query ops of current token
([#&#8203;10766](https://redirect.github.com/swc-project/swc/issues/10766))
([4304f91](4304f9129c))

- **(es/parser)** Remove useless call in `parse_ident`
([#&#8203;10770](https://redirect.github.com/swc-project/swc/issues/10770))
([4ca12c9](4ca12c9725))

- **(es/renamer)** Reduce time complexity in case of conflict
([#&#8203;10749](https://redirect.github.com/swc-project/swc/issues/10749))
([0279914](02799141bf))

- **(hstr)** Do not compare string during creating atoms
([#&#8203;10791](https://redirect.github.com/swc-project/swc/issues/10791))
([43a4f11](43a4f117cb))

- Replace `rayon` with `par-iter`
([#&#8203;10774](https://redirect.github.com/swc-project/swc/issues/10774))
([a6e6ebe](a6e6ebeaca))

##### Refactor

- **(es)** Make `swc_ecma_lint` optional for `swc` crate
([#&#8203;10767](https://redirect.github.com/swc-project/swc/issues/10767))
([f80415b](f80415baa6))

- **(es/lexer)** Use const fn in `SyntaxFlags`
([#&#8203;10737](https://redirect.github.com/swc-project/swc/issues/10737))
([b9eb23a](b9eb23aec3))

- **(es/parser)** Cleanup `parse_setter_param`
([#&#8203;10745](https://redirect.github.com/swc-project/swc/issues/10745))
([70734f4](70734f40d4))

- **(es/parser)** Cleanup `typed-arena`
([#&#8203;10769](https://redirect.github.com/swc-project/swc/issues/10769))
([ce5138d](ce5138d3aa))

- **(es/parser)** Cleanup for ctx
([#&#8203;10777](https://redirect.github.com/swc-project/swc/issues/10777))
([d60a611](d60a611dc7))

- **(es/parser)** Delete `with_ctx`
([#&#8203;10779](https://redirect.github.com/swc-project/swc/issues/10779))
([ce057c5](ce057c55ef))

- **(es/parser)** Cleanup
([#&#8203;10781](https://redirect.github.com/swc-project/swc/issues/10781))
([176ce36](176ce36d24))

- **(es/preset)** Remove deprecated `preset_env` function and `feature`
module
([#&#8203;10759](https://redirect.github.com/swc-project/swc/issues/10759))
([fa0e0ab](fa0e0abf41))

- **(es/preset-env)** Use phf for corejs3 entry
([#&#8203;10712](https://redirect.github.com/swc-project/swc/issues/10712))
([658b26d](658b26d838))

##### Testing

- **(es/minifier)** Update the terser test list
([#&#8203;10748](https://redirect.github.com/swc-project/swc/issues/10748))
([1eace01](1eace01303))

- **(es/minifier)** Update the passing test list
([#&#8203;10782](https://redirect.github.com/swc-project/swc/issues/10782))
([8aa888b](8aa888bc2a))

- **(es/parser)** Add a test for duplicate labels
([#&#8203;10784](https://redirect.github.com/swc-project/swc/issues/10784))
([28fc643](28fc64310c))

##### Pref

- **(hstr)** Do not compare static tag
([#&#8203;10771](https://redirect.github.com/swc-project/swc/issues/10771))
([5d3ce83](5d3ce83add))

###
[`v1.12.9`](https://redirect.github.com/swc-project/swc/blob/HEAD/CHANGELOG.md#1129---2025-07-01)

[Compare
Source](https://redirect.github.com/swc-project/swc/compare/v1.12.7...v1.12.9)

##### Bug Fixes

- **(es/lexer)** Parse uppercase hex numbers correctly
([#&#8203;10728](https://redirect.github.com/swc-project/swc/issues/10728))
([ead6256](ead62560b0))

- **(es/lexer)** Allow keywords as jsx attribute names
([#&#8203;10730](https://redirect.github.com/swc-project/swc/issues/10730))
([04ef20a](https://redirect.github.com/swc-project/swc/commit/04ef20ad9b

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get
[config
help](https://redirect.github.com/renovatebot/renovate/discussions) if
that's undesired.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/unraid/api).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MS4yMy4yIiwidXBkYXRlZEluVmVyIjoiNDEuMjMuMiIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-11 14:40:42 -04:00
renovate[bot]
2ed1308e40 chore(deps): update dependency vite-plugin-vue-tracer to v1 (#1472)
This PR contains the following updates:

| Package | Change | Age | Confidence |
|---|---|---|---|
|
[vite-plugin-vue-tracer](https://redirect.github.com/antfu/vite-plugin-vue-tracer)
| [`0.1.4` ->
`1.0.0`](https://renovatebot.com/diffs/npm/vite-plugin-vue-tracer/0.1.4/1.0.0)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/vite-plugin-vue-tracer/1.0.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/vite-plugin-vue-tracer/0.1.4/1.0.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|

---

### Release Notes

<details>
<summary>antfu/vite-plugin-vue-tracer (vite-plugin-vue-tracer)</summary>

###
[`v1.0.0`](https://redirect.github.com/antfu/vite-plugin-vue-tracer/releases/tag/v1.0.0)

[Compare
Source](https://redirect.github.com/antfu/vite-plugin-vue-tracer/compare/v0.1.5...v1.0.0)

*No significant changes*

#####     [View changes on
GitHub](https://redirect.github.com/antfu/vite-plugin-vue-tracer/compare/v0.1.5...v1.0.0)

###
[`v0.1.5`](https://redirect.github.com/antfu/vite-plugin-vue-tracer/releases/tag/v0.1.5)

[Compare
Source](https://redirect.github.com/antfu/vite-plugin-vue-tracer/compare/v0.1.4...v0.1.5)

#####    🚀 Features

- Support Vite 7  -  by
[@&#8203;antfu](https://redirect.github.com/antfu)
[<samp>(6927e)</samp>](https://redirect.github.com/antfu/vite-plugin-vue-tracer/commit/6927e8a)

#####     [View changes on
GitHub](https://redirect.github.com/antfu/vite-plugin-vue-tracer/compare/v0.1.4...v0.1.5)

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/unraid/api).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MS4xNy4yIiwidXBkYXRlZEluVmVyIjoiNDEuMjMuMiIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-11 14:17:33 -04:00
Zack Spear
6c03df2b97 tests: server store trial extensions (#1504)
Requested in feature PR
https://github.com/unraid/api/pull/1490#issuecomment-3059002854

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Enhanced trial expiration messaging to clearly communicate when the
trial is expiring, options for extension, and the consequences of
expiration.
* Added dynamic display of trial extension options and actions based on
eligibility and time remaining before expiration.

* **Bug Fixes**
* Improved accuracy of messages and actions related to trial extension
eligibility and renewal windows.

* **Tests**
* Added comprehensive tests for trial extension eligibility, renewal
windows, and related user messages and actions.

* **Documentation**
* Updated English locale strings to reflect new trial expiration and
extension messages.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-11 14:17:09 -04:00
Pujit Mehrotra
074370c42c fix: over-eager cloud query from web components (#1506)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Improved initialization logic to ensure cloud state is only loaded
when the connect plugin is installed, enhancing reliability during
startup.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-11 14:16:53 -04:00
Zack Spear
f34a33bc9f feat: trial extension allowed within 5 days of expiration (#1490) 2025-07-10 17:21:24 -07:00
github-actions[bot]
c7801a9236 chore(main): release 4.9.5 (#1503)
🤖 I have created a release *beep* *boop*
---


## [4.9.5](https://github.com/unraid/api/compare/v4.9.4...v4.9.5)
(2025-07-10)


### Bug Fixes

* **connect:** rm eager restart on `ERROR_RETYING` connection status
([#1502](https://github.com/unraid/api/issues/1502))
([dd759d9](dd759d9f0f))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-07-10 10:27:45 -04:00
Pujit Mehrotra
dd759d9f0f fix(connect): rm eager restart on ERROR_RETYING connection status (#1502)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved connection handling to prevent unnecessary reconnection
attempts during error retry states, ensuring reconnections only occur on
specific failures.

* **Tests**
* Added comprehensive tests to verify connection recovery,
identity-based connection, logout behavior, DDoS prevention, and edge
case handling for connection state changes.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-10 10:21:54 -04:00
github-actions[bot]
74da8d81ef chore(main): release 4.9.4 (#1498)
🤖 I have created a release *beep* *boop*
---


## [4.9.4](https://github.com/unraid/api/compare/v4.9.3...v4.9.4)
(2025-07-09)


### Bug Fixes

* backport `<unraid-modals>` upon plg install when necessary
([#1499](https://github.com/unraid/api/issues/1499))
([33e0b1a](33e0b1ab24))
* DefaultPageLayout patch rollback omits legacy header logo
([#1497](https://github.com/unraid/api/issues/1497))
([ea20d1e](ea20d1e211))
* event emitter setup for writing status
([#1496](https://github.com/unraid/api/issues/1496))
([ca4e2db](ca4e2db1f2))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-07-09 13:40:56 -04:00
Pujit Mehrotra
33e0b1ab24 fix: backport <unraid-modals> upon plg install when necessary (#1499)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Prevented duplicate insertion of the modal component in the page
layout.

* **Chores**
* Improved installation script to ensure the modal component is added
only if missing.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-09 13:32:55 -04:00
Eli Bosley
ca4e2db1f2 fix: event emitter setup for writing status (#1496)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Chores**
* Updated permissions to allow additional Bash command patterns in the
configuration.
* Improved connection status updates by triggering them via event
listeners during application bootstrap.
* Adjusted module provider registrations to reflect service relocation
within the application structure.
* **Tests**
* Added comprehensive unit and integration tests for connection status
writing and cleanup behaviors.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-09 13:16:53 -04:00
Pujit Mehrotra
ea20d1e211 fix: DefaultPageLayout patch rollback omits legacy header logo (#1497)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Enhanced the header by displaying the OS version and additional server
information.
* Introduced a new notification system using a modern UI component for
toasts.
* Automatically creates a root session for local requests when no valid
session exists.

* **Bug Fixes**
* Removed outdated pop-up notification logic and bell icon from the
navigation area.

* **Style**
* Updated header layout and improved formatting for a cleaner
appearance.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-09 13:12:18 -04:00
240 changed files with 8470 additions and 6197 deletions

View File

@@ -11,7 +11,34 @@
"Bash(pnpm type-check:*)",
"Bash(pnpm lint:*)",
"Bash(pnpm --filter ./api lint)",
"Bash(mv:*)"
"Bash(mv:*)",
"Bash(ls:*)",
"mcp__ide__getDiagnostics",
"Bash(pnpm --filter \"*connect*\" test connect-status-writer.service.spec)",
"Bash(pnpm storybook:*)",
"Bash(pnpm add:*)",
"Bash(pnpm install:*)",
"Bash(pkill:*)",
"Bash(true)",
"Bash(timeout 15 pnpm storybook)",
"WebFetch(domain:tailwindcss.com)",
"Bash(pnpm list:*)",
"Bash(pnpm remove:*)",
"WebFetch(domain:github.com)",
"mcp__browsermcp__browser_navigate",
"Bash(clear)",
"Bash(git log:*)",
"Bash(pnpm --filter ./unraid-ui build)",
"Bash(pnpm --filter @unraid/ui build)",
"Bash(pnpm --filter @unraid/web build)",
"Bash(python3:*)",
"Bash(pnpm tailwind:build:*)",
"WebFetch(domain:erangrin.github.io)",
"Bash(pnpm clean:*)",
"Bash(pnpm validate:css:*)",
"Bash(node:*)",
"Bash(rm:*)",
"Bash(pnpm run:*)"
]
},
"enableAllProjectMcpServers": false

View File

@@ -25,7 +25,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
node-version: '22.17.0'
- uses: pnpm/action-setup@v4
name: Install pnpm
@@ -33,7 +33,7 @@ jobs:
run_install: false
- name: Cache APT Packages
uses: awalsh128/cache-apt-pkgs-action@v1.4.3
uses: awalsh128/cache-apt-pkgs-action@v1.5.1
with:
packages: bash procps python3 libvirt-dev jq zstd git build-essential libvirt-daemon-system
version: 1.0

View File

@@ -45,7 +45,7 @@ jobs:
node-version-file: ".nvmrc"
- name: Cache APT Packages
uses: awalsh128/cache-apt-pkgs-action@v1.4.3
uses: awalsh128/cache-apt-pkgs-action@v1.5.1
with:
packages: bash procps python3 libvirt-dev jq zstd git build-essential libvirt-daemon-system
version: 1.0
@@ -190,7 +190,7 @@ jobs:
${{ runner.os }}-pnpm-store-
- name: Cache APT Packages
uses: awalsh128/cache-apt-pkgs-action@v1.4.3
uses: awalsh128/cache-apt-pkgs-action@v1.5.1
with:
packages: bash procps python3 libvirt-dev jq zstd git build-essential
version: 1.0
@@ -267,7 +267,7 @@ jobs:
${{ runner.os }}-pnpm-store-
- name: Cache APT Packages
uses: awalsh128/cache-apt-pkgs-action@v1.4.3
uses: awalsh128/cache-apt-pkgs-action@v1.5.1
with:
packages: bash procps python3 libvirt-dev jq zstd git build-essential
version: 1.0

View File

@@ -31,7 +31,7 @@ jobs:
python-version: "3.13.5"
- name: Cache APT Packages
uses: awalsh128/cache-apt-pkgs-action@v1.4.3
uses: awalsh128/cache-apt-pkgs-action@v1.5.1
with:
packages: libvirt-dev
version: 1.0

2
.nvmrc
View File

@@ -1 +1 @@
22.16.0
22.17.0

View File

@@ -1 +1 @@
{".":"4.9.3"}
{".":"4.10.0"}

View File

@@ -135,3 +135,8 @@ Enables GraphQL playground at `http://tower.local/graphql`
- Place all mock declarations at the top level
- Use factory functions for module mocks to avoid hoisting issues
- Clear mocks between tests to ensure isolation
## Development Memories
- We are using tailwind v4 we do not need a tailwind config anymore
- always search the internet for tailwind v4 documentation when making tailwind related style changes

View File

@@ -1,5 +1,39 @@
# Changelog
## [4.10.0](https://github.com/unraid/api/compare/v4.9.5...v4.10.0) (2025-07-15)
### Features
* trial extension allowed within 5 days of expiration ([#1490](https://github.com/unraid/api/issues/1490)) ([f34a33b](https://github.com/unraid/api/commit/f34a33bc9f1a7e135d453d9d31888789bfc3f878))
### Bug Fixes
* delay `nginx:reload` file mod effect by 10 seconds ([#1512](https://github.com/unraid/api/issues/1512)) ([af33e99](https://github.com/unraid/api/commit/af33e999a0480a77e3e6b2aa833b17b38b835656))
* **deps:** update all non-major dependencies ([#1489](https://github.com/unraid/api/issues/1489)) ([53b05eb](https://github.com/unraid/api/commit/53b05ebe5e2050cb0916fcd65e8d41370aee0624))
* ensure no crash if emhttp state configs are missing ([#1514](https://github.com/unraid/api/issues/1514)) ([1a7d35d](https://github.com/unraid/api/commit/1a7d35d3f6972fd8aff58c17b2b0fb79725e660e))
* **my.servers:** improve DNS resolution robustness for backup server ([#1518](https://github.com/unraid/api/issues/1518)) ([eecd9b1](https://github.com/unraid/api/commit/eecd9b1017a63651d1dc782feaa224111cdee8b6))
* over-eager cloud query from web components ([#1506](https://github.com/unraid/api/issues/1506)) ([074370c](https://github.com/unraid/api/commit/074370c42cdecc4dbc58193ff518aa25735c56b3))
* replace myservers.cfg reads in UpdateFlashBackup.php ([#1517](https://github.com/unraid/api/issues/1517)) ([441e180](https://github.com/unraid/api/commit/441e1805c108a6c1cd35ee093246b975a03f8474))
* rm short-circuit in `rc.unraid-api` if plugin config dir is absent ([#1515](https://github.com/unraid/api/issues/1515)) ([29dcb7d](https://github.com/unraid/api/commit/29dcb7d0f088937cefc5158055f48680e86e5c36))
## [4.9.5](https://github.com/unraid/api/compare/v4.9.4...v4.9.5) (2025-07-10)
### Bug Fixes
* **connect:** rm eager restart on `ERROR_RETYING` connection status ([#1502](https://github.com/unraid/api/issues/1502)) ([dd759d9](https://github.com/unraid/api/commit/dd759d9f0f841b296f8083bc67c6cd3f7a69aa5b))
## [4.9.4](https://github.com/unraid/api/compare/v4.9.3...v4.9.4) (2025-07-09)
### Bug Fixes
* backport `<unraid-modals>` upon plg install when necessary ([#1499](https://github.com/unraid/api/issues/1499)) ([33e0b1a](https://github.com/unraid/api/commit/33e0b1ab24bedb6a2c7b376ea73dbe65bc3044be))
* DefaultPageLayout patch rollback omits legacy header logo ([#1497](https://github.com/unraid/api/issues/1497)) ([ea20d1e](https://github.com/unraid/api/commit/ea20d1e2116fcafa154090fee78b42ec5d9ba584))
* event emitter setup for writing status ([#1496](https://github.com/unraid/api/issues/1496)) ([ca4e2db](https://github.com/unraid/api/commit/ca4e2db1f29126a1fa3784af563832edda64b0ca))
## [4.9.3](https://github.com/unraid/api/compare/v4.9.2...v4.9.3) (2025-07-09)

View File

@@ -1,10 +1,12 @@
{
"version": "4.8.0",
"version": "4.10.0",
"extraOrigins": [
"https://google.com",
"https://test.com"
],
"sandbox": true,
"ssoSubIds": [],
"plugins": ["unraid-api-plugin-connect"]
"plugins": [
"unraid-api-plugin-connect"
]
}

View File

@@ -1,16 +1,12 @@
{
"wanaccess": false,
"wanport": 0,
"wanaccess": true,
"wanport": 8443,
"upnpEnabled": false,
"apikey": "",
"localApiKey": "",
"email": "",
"username": "",
"avatar": "",
"regWizTime": "",
"accesstoken": "",
"idtoken": "",
"refreshtoken": "",
"dynamicRemoteAccessType": "DISABLED",
"ssoSubIds": []
"apikey": "_______________________BIG_API_KEY_HERE_________________________",
"localApiKey": "_______________________LOCAL_API_KEY_HERE_________________________",
"email": "test@example.com",
"username": "zspearmint",
"avatar": "https://via.placeholder.com/200",
"regWizTime": "1611175408732_0951-1653-3509-FBA155FA23C0",
"dynamicRemoteAccessType": "DISABLED"
}

View File

@@ -1,6 +1,6 @@
{
"name": "@unraid/api",
"version": "4.9.3",
"version": "4.10.0",
"main": "src/cli/index.ts",
"type": "module",
"corepack": {
@@ -10,7 +10,7 @@
"author": "Lime Technology, Inc. <unraid.net>",
"license": "GPL-2.0-or-later",
"engines": {
"pnpm": "10.12.4"
"pnpm": "10.13.1"
},
"scripts": {
"// Development": "",
@@ -57,7 +57,7 @@
"@as-integrations/fastify": "2.1.1",
"@fastify/cookie": "11.0.2",
"@fastify/helmet": "13.0.1",
"@graphql-codegen/client-preset": "4.8.2",
"@graphql-codegen/client-preset": "4.8.3",
"@graphql-tools/load-files": "7.0.1",
"@graphql-tools/merge": "9.0.24",
"@graphql-tools/schema": "10.0.23",
@@ -82,7 +82,7 @@
"accesscontrol": "2.2.1",
"bycontract": "2.0.11",
"bytes": "3.1.2",
"cache-manager": "7.0.0",
"cache-manager": "7.0.1",
"cacheable-lookup": "7.0.0",
"camelcase-keys": "9.1.3",
"casbin": "5.38.0",
@@ -94,11 +94,11 @@
"command-exists": "1.2.9",
"convert": "5.12.0",
"cookie": "1.0.2",
"cron": "4.3.1",
"cron": "4.3.2",
"cross-fetch": "4.1.0",
"diff": "8.0.2",
"dockerode": "4.0.7",
"dotenv": "17.1.0",
"dotenv": "17.2.0",
"execa": "9.6.0",
"exit-hook": "4.0.0",
"fastify": "5.4.0",
@@ -112,7 +112,7 @@
"graphql-scalars": "1.24.2",
"graphql-subscriptions": "3.0.0",
"graphql-tag": "2.12.6",
"graphql-ws": "6.0.5",
"graphql-ws": "6.0.6",
"ini": "5.0.0",
"ip": "2.0.1",
"jose": "6.0.11",
@@ -138,11 +138,11 @@
"rxjs": "7.8.2",
"semver": "7.7.2",
"strftime": "0.10.3",
"systeminformation": "5.27.6",
"systeminformation": "5.27.7",
"uuid": "11.1.0",
"ws": "8.18.2",
"ws": "8.18.3",
"zen-observable-ts": "1.1.0",
"zod": "3.25.67"
"zod": "3.25.76"
},
"peerDependencies": {
"unraid-api-plugin-connect": "workspace:*"
@@ -153,35 +153,35 @@
}
},
"devDependencies": {
"@eslint/js": "9.29.0",
"@eslint/js": "9.31.0",
"@graphql-codegen/add": "5.0.3",
"@graphql-codegen/cli": "5.0.7",
"@graphql-codegen/fragment-matcher": "5.1.0",
"@graphql-codegen/import-types-preset": "3.0.1",
"@graphql-codegen/typed-document-node": "5.1.1",
"@graphql-codegen/typed-document-node": "5.1.2",
"@graphql-codegen/typescript": "4.1.6",
"@graphql-codegen/typescript-operations": "4.6.1",
"@graphql-codegen/typescript-resolvers": "4.5.1",
"@graphql-typed-document-node/core": "3.2.0",
"@ianvs/prettier-plugin-sort-imports": "4.4.2",
"@ianvs/prettier-plugin-sort-imports": "4.5.1",
"@nestjs/testing": "11.1.3",
"@originjs/vite-plugin-commonjs": "1.0.3",
"@rollup/plugin-node-resolve": "16.0.1",
"@swc/core": "1.12.4",
"@swc/core": "1.12.14",
"@types/async-exit-hook": "2.0.2",
"@types/bytes": "3.1.5",
"@types/cli-table": "0.3.4",
"@types/command-exists": "1.2.3",
"@types/cors": "2.8.19",
"@types/dockerode": "3.3.41",
"@types/dockerode": "3.3.42",
"@types/graphql-fields": "1.3.9",
"@types/graphql-type-uuid": "0.2.6",
"@types/ini": "4.1.1",
"@types/ip": "1.1.3",
"@types/lodash": "4.17.18",
"@types/lodash": "4.17.20",
"@types/lodash-es": "4.17.12",
"@types/mustache": "4.2.6",
"@types/node": "22.15.32",
"@types/node": "22.16.4",
"@types/pify": "6.1.0",
"@types/semver": "7.7.0",
"@types/sendmail": "1.4.7",
@@ -193,27 +193,27 @@
"@vitest/coverage-v8": "3.2.4",
"@vitest/ui": "3.2.4",
"cz-conventional-changelog": "3.3.0",
"eslint": "9.29.0",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-n": "17.20.0",
"eslint": "9.31.0",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-n": "17.21.0",
"eslint-plugin-no-relative-import-paths": "1.6.1",
"eslint-plugin-prettier": "5.5.0",
"eslint-plugin-prettier": "5.5.1",
"graphql-codegen-typescript-validation-schema": "0.17.1",
"jiti": "2.4.2",
"nodemon": "3.1.10",
"prettier": "3.5.3",
"prettier": "3.6.2",
"rollup-plugin-node-externals": "8.0.1",
"commit-and-tag-version": "9.5.0",
"commit-and-tag-version": "9.6.0",
"tsx": "4.20.3",
"type-fest": "4.41.0",
"typescript": "5.8.3",
"typescript-eslint": "8.34.1",
"typescript-eslint": "8.37.0",
"unplugin-swc": "1.5.5",
"vite": "7.0.3",
"vite": "7.0.4",
"vite-plugin-node": "7.0.0",
"vite-tsconfig-paths": "5.1.4",
"vitest": "3.2.4",
"zx": "8.5.5"
"zx": "8.7.1"
},
"overrides": {
"eslint": {
@@ -228,5 +228,5 @@
}
},
"private": true,
"packageManager": "pnpm@10.12.4"
"packageManager": "pnpm@10.13.1"
}

View File

@@ -1,137 +0,0 @@
import { ConfigService } from '@nestjs/config';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ApiConfigPersistence } from '@app/unraid-api/config/api-config.module.js';
import { ConfigPersistenceHelper } from '@app/unraid-api/config/persistence.helper.js';
describe('ApiConfigPersistence', () => {
let service: ApiConfigPersistence;
let configService: ConfigService;
let persistenceHelper: ConfigPersistenceHelper;
beforeEach(() => {
configService = {
get: vi.fn(),
set: vi.fn(),
} as any;
persistenceHelper = {} as ConfigPersistenceHelper;
service = new ApiConfigPersistence(configService, persistenceHelper);
});
describe('convertLegacyConfig', () => {
it('should migrate sandbox from string "yes" to boolean true', () => {
const legacyConfig = {
local: { sandbox: 'yes' },
api: { extraOrigins: '' },
remote: { ssoSubIds: '' },
};
const result = service.convertLegacyConfig(legacyConfig);
expect(result.sandbox).toBe(true);
});
it('should migrate sandbox from string "no" to boolean false', () => {
const legacyConfig = {
local: { sandbox: 'no' },
api: { extraOrigins: '' },
remote: { ssoSubIds: '' },
};
const result = service.convertLegacyConfig(legacyConfig);
expect(result.sandbox).toBe(false);
});
it('should migrate extraOrigins from comma-separated string to array', () => {
const legacyConfig = {
local: { sandbox: 'no' },
api: { extraOrigins: 'https://example.com,https://test.com' },
remote: { ssoSubIds: '' },
};
const result = service.convertLegacyConfig(legacyConfig);
expect(result.extraOrigins).toEqual(['https://example.com', 'https://test.com']);
});
it('should filter out non-HTTP origins from extraOrigins', () => {
const legacyConfig = {
local: { sandbox: 'no' },
api: {
extraOrigins: 'https://example.com,invalid-origin,http://test.com,ftp://bad.com',
},
remote: { ssoSubIds: '' },
};
const result = service.convertLegacyConfig(legacyConfig);
expect(result.extraOrigins).toEqual(['https://example.com', 'http://test.com']);
});
it('should handle empty extraOrigins string', () => {
const legacyConfig = {
local: { sandbox: 'no' },
api: { extraOrigins: '' },
remote: { ssoSubIds: '' },
};
const result = service.convertLegacyConfig(legacyConfig);
expect(result.extraOrigins).toEqual([]);
});
it('should migrate ssoSubIds from comma-separated string to array', () => {
const legacyConfig = {
local: { sandbox: 'no' },
api: { extraOrigins: '' },
remote: { ssoSubIds: 'user1,user2,user3' },
};
const result = service.convertLegacyConfig(legacyConfig);
expect(result.ssoSubIds).toEqual(['user1', 'user2', 'user3']);
});
it('should handle empty ssoSubIds string', () => {
const legacyConfig = {
local: { sandbox: 'no' },
api: { extraOrigins: '' },
remote: { ssoSubIds: '' },
};
const result = service.convertLegacyConfig(legacyConfig);
expect(result.ssoSubIds).toEqual([]);
});
it('should handle undefined config sections', () => {
const legacyConfig = {};
const result = service.convertLegacyConfig(legacyConfig);
expect(result.sandbox).toBe(false);
expect(result.extraOrigins).toEqual([]);
expect(result.ssoSubIds).toEqual([]);
});
it('should handle complete migration with all fields', () => {
const legacyConfig = {
local: { sandbox: 'yes' },
api: { extraOrigins: 'https://app1.example.com,https://app2.example.com' },
remote: { ssoSubIds: 'sub1,sub2,sub3' },
};
const result = service.convertLegacyConfig(legacyConfig);
expect(result.sandbox).toBe(true);
expect(result.extraOrigins).toEqual([
'https://app1.example.com',
'https://app2.example.com',
]);
expect(result.ssoSubIds).toEqual(['sub1', 'sub2', 'sub3']);
});
});
});

View File

@@ -17,7 +17,6 @@ exports[`Returns paths 1`] = `
"myservers-base",
"myservers-config",
"myservers-config-states",
"myservers-env",
"myservers-keepalive",
"keyfile-base",
"machine-id",

View File

@@ -24,7 +24,6 @@ test('Returns paths', async () => {
'myservers-base': '/boot/config/plugins/dynamix.my.servers/',
'myservers-config': expect.stringContaining('api/dev/Unraid.net/myservers.cfg'),
'myservers-config-states': expect.stringContaining('api/dev/states/myservers.cfg'),
'myservers-env': '/boot/config/plugins/dynamix.my.servers/env',
'myservers-keepalive': './dev/Unraid.net/fb_keepalive',
'keyfile-base': expect.stringContaining('api/dev/Unraid.net'),
'machine-id': expect.stringContaining('api/dev/data/machine-id'),

View File

@@ -67,6 +67,7 @@ export const getPackageJsonDependencies = (): string[] | undefined => {
export const API_VERSION = process.env.npm_package_version ?? getPackageJson().version;
/** Controls how the app is built/run (i.e. in terms of optimization) */
export const NODE_ENV =
(process.env.NODE_ENV as 'development' | 'test' | 'staging' | 'production') ?? 'production';
export const environment = {
@@ -76,6 +77,7 @@ export const CHOKIDAR_USEPOLLING = process.env.CHOKIDAR_USEPOLLING === 'true';
export const IS_DOCKER = process.env.IS_DOCKER === 'true';
export const DEBUG = process.env.DEBUG === 'true';
export const INTROSPECTION = process.env.INTROSPECTION === 'true';
/** Determines the app-level & business logic environment (i.e. what data & infrastructure is used) */
export const ENVIRONMENT = process.env.ENVIRONMENT
? (process.env.ENVIRONMENT as 'production' | 'staging' | 'development')
: 'production';

View File

@@ -1,4 +1,4 @@
import { writeFileSync } from 'fs';
import { writeFile } from 'fs/promises';
import type { ConfigType } from '@app/core/utils/files/config-file-normalizer.js';
import { logger } from '@app/core/log.js';
@@ -17,6 +17,6 @@ export const enableConfigFileListener = (mode: ConfigType) => () =>
const writeableConfig = getWriteableConfig(config, mode);
const serializedConfig = safelySerializeObjectToIni(writeableConfig);
logger.debug('Writing updated config to %s', pathToWrite);
writeFileSync(pathToWrite, serializedConfig);
await writeFile(pathToWrite, serializedConfig);
},
});

View File

@@ -1,6 +1,5 @@
import { F_OK } from 'constants';
import { writeFileSync } from 'fs';
import { access } from 'fs/promises';
import { access, writeFile } from 'fs/promises';
import type { PayloadAction } from '@reduxjs/toolkit';
import { createAsyncThunk, createSlice, isAnyOf } from '@reduxjs/toolkit';
@@ -140,7 +139,7 @@ export const loadConfigFile = createAsyncThunk<
const newConfig = getWriteableConfig(initialState, 'flash');
newConfig.remote.wanaccess = 'no';
const serializedConfig = safelySerializeObjectToIni(newConfig);
writeFileSync(getState().paths['myservers-config'], serializedConfig);
await writeFile(getState().paths['myservers-config'], serializedConfig);
return rejectWithValue({
type: CONFIG_LOAD_ERROR.CONFIG_CORRUPTED,
error: error instanceof Error ? error : new Error('Unknown Error'),

View File

@@ -49,7 +49,6 @@ const initialState = {
resolvePath(process.env.PATHS_STATES ?? ('/usr/local/emhttp/state/' as const)),
'myservers.cfg' as const
),
'myservers-env': '/boot/config/plugins/dynamix.my.servers/env' as const,
'myservers-keepalive':
process.env.PATHS_MY_SERVERS_FB ??
('/boot/config/plugins/dynamix.my.servers/fb_keepalive' as const),

View File

@@ -1,6 +1,6 @@
import { writeFileSync } from 'fs';
import { join } from 'path';
import { ensureWriteSync } from '@unraid/shared/util/file.js';
import { isEqual } from 'lodash-es';
import type { RootState } from '@app/store/index.js';
@@ -27,8 +27,11 @@ export const startStoreSync = async () => {
!isEqual(state, lastState) &&
state.paths['myservers-config-states']
) {
writeFileSync(join(state.paths.states, 'config.log'), JSON.stringify(state.config, null, 2));
writeFileSync(
ensureWriteSync(
join(state.paths.states, 'config.log'),
JSON.stringify(state.config, null, 2)
);
ensureWriteSync(
join(state.paths.states, 'graphql.log'),
JSON.stringify(state.minigraph, null, 2)
);

View File

@@ -1,11 +1,11 @@
import { Injectable } from '@nestjs/common';
import { existsSync } from 'node:fs';
import { mkdir, rm } from 'node:fs/promises';
import { join } from 'node:path';
import type { Options, Result, ResultPromise } from 'execa';
import { execa, ExecaError } from 'execa';
import { fileExists } from '@app/core/utils/files/file-exists.js';
import { LOGS_DIR, PM2_HOME, PM2_PATH } from '@app/environment.js';
import { LogService } from '@app/unraid-api/cli/log.service.js';
@@ -90,7 +90,7 @@ export class PM2Service {
}
async deletePm2Home() {
if (existsSync(PM2_HOME) && existsSync(join(PM2_HOME, 'pm2.log'))) {
if ((await fileExists(PM2_HOME)) && (await fileExists(join(PM2_HOME, 'pm2.log')))) {
await rm(PM2_HOME, { recursive: true, force: true });
this.logger.trace('PM2 home directory cleared.');
} else {

View File

@@ -1,13 +1,13 @@
import { copyFile, readFile, writeFile } from 'fs/promises';
import { copyFile } from 'fs/promises';
import { join } from 'path';
import { Command, CommandRunner, Option } from 'nest-commander';
import { cliLogger } from '@app/core/log.js';
import { fileExistsSync } from '@app/core/utils/files/file-exists.js';
import { ENVIRONMENT } from '@app/environment.js';
import { getters } from '@app/store/index.js';
import { LogService } from '@app/unraid-api/cli/log.service.js';
import { StartCommand } from '@app/unraid-api/cli/start.command.js';
import { StopCommand } from '@app/unraid-api/cli/stop.command.js';
import { RestartCommand } from '@app/unraid-api/cli/restart.command.js';
interface SwitchEnvOptions {
environment?: 'staging' | 'production';
@@ -31,60 +31,43 @@ export class SwitchEnvCommand extends CommandRunner {
constructor(
private readonly logger: LogService,
private readonly stopCommand: StopCommand,
private readonly startCommand: StartCommand
private readonly restartCommand: RestartCommand
) {
super();
}
private async getEnvironmentFromFile(path: string): Promise<'production' | 'staging'> {
const envFile = await readFile(path, 'utf-8').catch(() => '');
this.logger.debug(`Checking ${path} for current ENV, found ${envFile}`);
// Match the env file env="production" which would be [0] = env="production", [1] = env and [2] = production
const matchArray = /([a-zA-Z]+)=["]*([a-zA-Z]+)["]*/.exec(envFile);
// Get item from index 2 of the regex match or return production
const [, , currentEnvInFile] = matchArray && matchArray.length === 3 ? matchArray : [];
return this.parseStringToEnv(currentEnvInFile);
}
private switchToOtherEnv(environment: 'production' | 'staging'): 'production' | 'staging' {
if (environment === 'production') {
return 'staging';
}
return 'production';
}
async run(_, options: SwitchEnvOptions): Promise<void> {
const paths = getters.paths();
const basePath = paths['unraid-api-base'];
const envFlashFilePath = paths['myservers-env'];
const currentEnvPath = join(basePath, '.env');
this.logger.warn('Stopping the Unraid API');
try {
await this.stopCommand.run([], { delete: false });
} catch (err) {
this.logger.warn('Failed to stop the Unraid API (maybe already stopped?)');
// Determine target environment
const currentEnv = ENVIRONMENT;
const targetEnv = options.environment ?? 'production';
this.logger.info(`Switching environment from ${currentEnv} to ${targetEnv}`);
// Check if target environment file exists
const sourceEnvPath = join(basePath, `.env.${targetEnv}`);
if (!fileExistsSync(sourceEnvPath)) {
this.logger.error(
`Environment file ${sourceEnvPath} does not exist. Cannot switch to ${targetEnv} environment.`
);
process.exit(1);
}
const newEnv =
options.environment ??
this.switchToOtherEnv(await this.getEnvironmentFromFile(envFlashFilePath));
this.logger.info(`Setting environment to ${newEnv}`);
// Copy the target environment file to .env
this.logger.debug(`Copying ${sourceEnvPath} to ${currentEnvPath}`);
try {
await copyFile(sourceEnvPath, currentEnvPath);
this.logger.info(`Successfully switched to ${targetEnv} environment`);
} catch (error) {
this.logger.error(`Failed to copy environment file: ${error}`);
process.exit(1);
}
// Write new env to flash
const newEnvLine = `env="${newEnv}"`;
this.logger.debug('Writing %s to %s', newEnvLine, envFlashFilePath);
await writeFile(envFlashFilePath, newEnvLine);
// Copy the new env over to live location before restarting
const source = join(basePath, `.env.${newEnv}`);
const destination = join(basePath, '.env');
cliLogger.debug('Copying %s to %s', source, destination);
await copyFile(source, destination);
cliLogger.info('Now using %s', newEnv);
await this.startCommand.run([], {});
// Restart the API to pick up the new environment
this.logger.info('Restarting Unraid API to apply environment changes...');
await this.restartCommand.run();
}
}

View File

@@ -1,14 +1,12 @@
import { Injectable, Logger, Module } from '@nestjs/common';
import { ConfigService, registerAs } from '@nestjs/config';
import path from 'path';
import type { ApiConfig } from '@unraid/shared/services/api-config.js';
import { ConfigFilePersister } from '@unraid/shared/services/config-file.js';
import { csvStringToArray } from '@unraid/shared/util/data.js';
import { fileExists } from '@unraid/shared/util/file.js';
import { bufferTime } from 'rxjs/operators';
import { API_VERSION } from '@app/environment.js';
import { ApiStateConfig } from '@app/unraid-api/config/factory/api-state.model.js';
import { ConfigPersistenceHelper } from '@app/unraid-api/config/persistence.helper.js';
import { API_VERSION, PATHS_CONFIG_MODULES } from '@app/environment.js';
export { type ApiConfig };
@@ -22,123 +20,72 @@ const createDefaultConfig = (): ApiConfig => ({
plugins: [],
});
export const persistApiConfig = async (config: ApiConfig) => {
const apiConfig = new ApiStateConfig<ApiConfig>(
{
name: 'api',
defaultConfig: config,
parse: (data) => data as ApiConfig,
},
new ConfigPersistenceHelper()
);
return await apiConfig.persist(config);
};
/**
* Simple file-based config loading for plugin discovery (outside of nestjs DI container).
* This avoids complex DI container instantiation during module loading.
*/
export const loadApiConfig = async () => {
const defaultConfig = createDefaultConfig();
const apiHandler = new ApiConfigPersistence(new ConfigService()).getFileHandler();
let diskConfig: Partial<ApiConfig> = {};
try {
const defaultConfig = createDefaultConfig();
const apiConfig = new ApiStateConfig<ApiConfig>(
{
name: 'api',
defaultConfig,
parse: (data) => data as ApiConfig,
},
new ConfigPersistenceHelper()
);
let diskConfig: ApiConfig | undefined;
try {
diskConfig = await apiConfig.parseConfig();
} catch (error) {
logger.error('Failed to load API config from disk, using defaults:', error);
diskConfig = undefined;
// Try to overwrite the invalid config with defaults to fix the issue
try {
const configToWrite = {
...defaultConfig,
version: API_VERSION,
};
const writeSuccess = await apiConfig.persist(configToWrite);
if (writeSuccess) {
logger.log('Successfully overwrote invalid config file with defaults.');
} else {
logger.error(
'Failed to overwrite invalid config file. Continuing with defaults in memory only.'
);
}
} catch (persistError) {
logger.error('Error during config file repair:', persistError);
}
}
return {
...defaultConfig,
...diskConfig,
version: API_VERSION,
};
} catch (outerError) {
// This should never happen, but ensures the config factory never throws
logger.error('Critical error in loadApiConfig, using minimal defaults:', outerError);
return createDefaultConfig();
diskConfig = await apiHandler.loadConfig();
} catch (error) {
logger.warn('Failed to load API config from disk:', error);
}
return {
...defaultConfig,
...diskConfig,
// diskConfig's version may be older, but we still want to use the correct version
version: API_VERSION,
};
};
/**
* Loads the API config from disk. If not found, returns the default config, but does not persist it.
* This is used in the root config module to register the api config.
*/
export const apiConfig = registerAs<ApiConfig>('api', loadApiConfig);
@Injectable()
export class ApiConfigPersistence {
private configModel: ApiStateConfig<ApiConfig>;
private logger = new Logger(ApiConfigPersistence.name);
get filePath() {
return this.configModel.filePath;
}
get config() {
return this.configService.getOrThrow('api');
export class ApiConfigPersistence extends ConfigFilePersister<ApiConfig> {
constructor(configService: ConfigService) {
super(configService);
}
constructor(
private readonly configService: ConfigService,
private readonly persistenceHelper: ConfigPersistenceHelper
) {
this.configModel = new ApiStateConfig<ApiConfig>(
{
name: 'api',
defaultConfig: createDefaultConfig(),
parse: (data) => data as ApiConfig,
},
this.persistenceHelper
);
fileName(): string {
return 'api.json';
}
async onModuleInit() {
try {
if (!(await fileExists(this.filePath))) {
this.migrateFromMyServersConfig();
}
await this.persistenceHelper.persistIfChanged(this.filePath, this.config);
this.configService.changes$.pipe(bufferTime(25)).subscribe({
next: async (changes) => {
if (changes.some((change) => change.path.startsWith('api'))) {
this.logger.verbose(`API Config changed ${JSON.stringify(changes)}`);
try {
await this.persistenceHelper.persistIfChanged(this.filePath, this.config);
} catch (persistError) {
this.logger.error('Error persisting config changes:', persistError);
}
}
},
error: (err) => {
this.logger.error('Error receiving config changes:', err);
},
});
} catch (error) {
this.logger.error('Error during API config module initialization:', error);
}
configKey(): string {
return 'api';
}
/**
* @override
* Since the api config is read outside of the nestjs DI container,
* we need to provide an explicit path instead of relying on the
* default prefix from the configService.
*
* @returns The path to the api config file
*/
configPath(): string {
return path.join(PATHS_CONFIG_MODULES, this.fileName());
}
defaultConfig(): ApiConfig {
return createDefaultConfig();
}
async migrateConfig(): Promise<ApiConfig> {
const legacyConfig = this.configService.get('store.config', {});
const migrated = this.convertLegacyConfig(legacyConfig);
return {
...this.defaultConfig(),
...migrated,
};
}
convertLegacyConfig(
@@ -156,18 +103,11 @@ export class ApiConfigPersistence {
ssoSubIds: csvStringToArray(config?.remote?.ssoSubIds ?? ''),
};
}
migrateFromMyServersConfig() {
const legacyConfig = this.configService.get('store.config', {});
const { sandbox, extraOrigins, ssoSubIds } = this.convertLegacyConfig(legacyConfig);
this.configService.set('api.sandbox', sandbox);
this.configService.set('api.extraOrigins', extraOrigins);
this.configService.set('api.ssoSubIds', ssoSubIds);
}
}
// apiConfig should be registered in root config in app.module.ts, not here.
@Module({
providers: [ApiConfigPersistence, ConfigPersistenceHelper],
providers: [ApiConfigPersistence],
exports: [ApiConfigPersistence],
})
export class ApiConfigModule {}

View File

@@ -4,14 +4,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fileExists } from '@app/core/utils/files/file-exists.js';
import { ApiConfigPersistence, loadApiConfig } from '@app/unraid-api/config/api-config.module.js';
import { ConfigPersistenceHelper } from '@app/unraid-api/config/persistence.helper.js';
// Mock the core file-exists utility used by ApiStateConfig
// Mock file utilities
vi.mock('@app/core/utils/files/file-exists.js', () => ({
fileExists: vi.fn(),
}));
// Mock the shared file-exists utility used by ConfigPersistenceHelper
vi.mock('@unraid/shared/util/file.js', () => ({
fileExists: vi.fn(),
}));
@@ -25,16 +23,56 @@ vi.mock('fs/promises', () => ({
describe('ApiConfigPersistence', () => {
let service: ApiConfigPersistence;
let configService: ConfigService;
let persistenceHelper: ConfigPersistenceHelper;
beforeEach(() => {
configService = {
get: vi.fn(),
set: vi.fn(),
getOrThrow: vi.fn().mockReturnValue('test-config-path'),
} as any;
persistenceHelper = {} as ConfigPersistenceHelper;
service = new ApiConfigPersistence(configService, persistenceHelper);
service = new ApiConfigPersistence(configService);
});
describe('required ConfigFilePersister methods', () => {
it('should return correct file name', () => {
expect(service.fileName()).toBe('api.json');
});
it('should return correct config key', () => {
expect(service.configKey()).toBe('api');
});
it('should return default config', () => {
const defaultConfig = service.defaultConfig();
expect(defaultConfig).toEqual({
version: expect.any(String),
extraOrigins: [],
sandbox: false,
ssoSubIds: [],
plugins: [],
});
});
it('should migrate config from legacy format', async () => {
const mockLegacyConfig = {
local: { sandbox: 'yes' },
api: { extraOrigins: 'https://example.com,https://test.com' },
remote: { ssoSubIds: 'sub1,sub2' },
};
vi.mocked(configService.get).mockReturnValue(mockLegacyConfig);
const result = await service.migrateConfig();
expect(result).toEqual({
version: expect.any(String),
extraOrigins: ['https://example.com', 'https://test.com'],
sandbox: true,
ssoSubIds: ['sub1', 'sub2'],
plugins: [],
});
});
});
describe('convertLegacyConfig', () => {
@@ -154,23 +192,11 @@ describe('ApiConfigPersistence', () => {
});
describe('loadApiConfig', () => {
let readFile: any;
let writeFile: any;
beforeEach(async () => {
vi.clearAllMocks();
// Reset modules to ensure fresh imports
vi.resetModules();
// Get mocked functions
const fsMocks = await import('fs/promises');
readFile = fsMocks.readFile;
writeFile = fsMocks.writeFile;
});
it('should return default config when file does not exist', async () => {
vi.mocked(fileExists).mockResolvedValue(false);
it('should return default config with current API_VERSION', async () => {
const result = await loadApiConfig();
expect(result).toEqual({
@@ -182,39 +208,9 @@ describe('loadApiConfig', () => {
});
});
it('should merge disk config with defaults when file exists', async () => {
const diskConfig = {
extraOrigins: ['https://example.com'],
sandbox: true,
ssoSubIds: ['sub1', 'sub2'],
};
vi.mocked(fileExists).mockResolvedValue(true);
vi.mocked(readFile).mockResolvedValue(JSON.stringify(diskConfig));
it('should handle errors gracefully and return defaults', async () => {
const result = await loadApiConfig();
expect(result).toEqual({
version: expect.any(String),
extraOrigins: ['https://example.com'],
sandbox: true,
ssoSubIds: ['sub1', 'sub2'],
plugins: [],
});
});
it('should use default config and overwrite file when JSON parsing fails', async () => {
const { fileExists: sharedFileExists } = await import('@unraid/shared/util/file.js');
vi.mocked(fileExists).mockResolvedValue(true);
vi.mocked(readFile).mockResolvedValue('{ invalid json }');
vi.mocked(sharedFileExists).mockResolvedValue(false); // For persist operation
vi.mocked(writeFile).mockResolvedValue(undefined);
const result = await loadApiConfig();
// Error logging is handled by NestJS Logger, just verify the config is returned
expect(writeFile).toHaveBeenCalled();
expect(result).toEqual({
version: expect.any(String),
extraOrigins: [],
@@ -223,56 +219,4 @@ describe('loadApiConfig', () => {
plugins: [],
});
});
it('should handle write failure gracefully when JSON parsing fails', async () => {
const { fileExists: sharedFileExists } = await import('@unraid/shared/util/file.js');
vi.mocked(fileExists).mockResolvedValue(true);
vi.mocked(readFile).mockResolvedValue('{ invalid json }');
vi.mocked(sharedFileExists).mockResolvedValue(false); // For persist operation
vi.mocked(writeFile).mockRejectedValue(new Error('Permission denied'));
const result = await loadApiConfig();
// Error logging is handled by NestJS Logger, just verify the config is returned
expect(writeFile).toHaveBeenCalled();
expect(result).toEqual({
version: expect.any(String),
extraOrigins: [],
sandbox: false,
ssoSubIds: [],
plugins: [],
});
});
it('should use default config when file is empty', async () => {
vi.mocked(fileExists).mockResolvedValue(true);
vi.mocked(readFile).mockResolvedValue('');
const result = await loadApiConfig();
// No error logging expected for empty files
expect(result).toEqual({
version: expect.any(String),
extraOrigins: [],
sandbox: false,
ssoSubIds: [],
plugins: [],
});
});
it('should always override version with current API_VERSION', async () => {
const diskConfig = {
version: 'old-version',
extraOrigins: ['https://example.com'],
};
vi.mocked(fileExists).mockResolvedValue(true);
vi.mocked(readFile).mockResolvedValue(JSON.stringify(diskConfig));
const result = await loadApiConfig();
expect(result.version).not.toBe('old-version');
expect(result.version).toBeTruthy();
});
});

View File

@@ -1,364 +0,0 @@
import { Logger } from '@nestjs/common';
import { readFile } from 'node:fs/promises';
import { join } from 'path';
import type { Mock } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fileExists } from '@app/core/utils/files/file-exists.js';
import { ApiStateConfig } from '@app/unraid-api/config/factory/api-state.model.js';
import { ConfigPersistenceHelper } from '@app/unraid-api/config/persistence.helper.js';
vi.mock('node:fs/promises');
vi.mock('@app/core/utils/files/file-exists.js');
vi.mock('@app/environment.js', () => ({
PATHS_CONFIG_MODULES: '/test/config/path',
}));
describe('ApiStateConfig', () => {
let mockPersistenceHelper: ConfigPersistenceHelper;
let mockLogger: Logger;
interface TestConfig {
name: string;
value: number;
enabled: boolean;
}
const defaultConfig: TestConfig = {
name: 'test',
value: 42,
enabled: true,
};
const parseFunction = (data: unknown): TestConfig => {
if (!data || typeof data !== 'object') {
throw new Error('Invalid config format');
}
return data as TestConfig;
};
beforeEach(() => {
vi.clearAllMocks();
mockPersistenceHelper = {
persistIfChanged: vi.fn().mockResolvedValue(true),
} as any;
mockLogger = {
log: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
} as any;
vi.spyOn(Logger.prototype, 'log').mockImplementation(mockLogger.log);
vi.spyOn(Logger.prototype, 'warn').mockImplementation(mockLogger.warn);
vi.spyOn(Logger.prototype, 'error').mockImplementation(mockLogger.error);
vi.spyOn(Logger.prototype, 'debug').mockImplementation(mockLogger.debug);
});
describe('constructor', () => {
it('should initialize with cloned default config', () => {
const config = new ApiStateConfig(
{
name: 'test-config',
defaultConfig,
parse: parseFunction,
},
mockPersistenceHelper
);
expect(config.config).toEqual(defaultConfig);
expect(config.config).not.toBe(defaultConfig);
});
});
describe('token', () => {
it('should generate correct token', () => {
const config = new ApiStateConfig(
{
name: 'my-config',
defaultConfig,
parse: parseFunction,
},
mockPersistenceHelper
);
expect(config.token).toBe('ApiConfig.my-config');
});
});
describe('file paths', () => {
it('should generate correct file name', () => {
const config = new ApiStateConfig(
{
name: 'test-config',
defaultConfig,
parse: parseFunction,
},
mockPersistenceHelper
);
expect(config.fileName).toBe('test-config.json');
});
it('should generate correct file path', () => {
const config = new ApiStateConfig(
{
name: 'test-config',
defaultConfig,
parse: parseFunction,
},
mockPersistenceHelper
);
expect(config.filePath).toBe(join('/test/config/path', 'test-config.json'));
});
});
describe('parseConfig', () => {
let config: ApiStateConfig<TestConfig>;
beforeEach(() => {
config = new ApiStateConfig(
{
name: 'test-config',
defaultConfig,
parse: parseFunction,
},
mockPersistenceHelper
);
});
it('should return undefined when file does not exist', async () => {
(fileExists as Mock).mockResolvedValue(false);
const result = await config.parseConfig();
expect(result).toBeUndefined();
expect(readFile).not.toHaveBeenCalled();
});
it('should parse valid JSON config', async () => {
const validConfig = { name: 'custom', value: 100, enabled: false };
(fileExists as Mock).mockResolvedValue(true);
(readFile as Mock).mockResolvedValue(JSON.stringify(validConfig));
const result = await config.parseConfig();
expect(result).toEqual(validConfig);
expect(readFile).toHaveBeenCalledWith(config.filePath, 'utf8');
});
it('should return undefined for empty file', async () => {
(fileExists as Mock).mockResolvedValue(true);
(readFile as Mock).mockResolvedValue('');
const result = await config.parseConfig();
expect(result).toBeUndefined();
expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('is empty'));
});
it('should return undefined for whitespace-only file', async () => {
(fileExists as Mock).mockResolvedValue(true);
(readFile as Mock).mockResolvedValue(' \n\t ');
const result = await config.parseConfig();
expect(result).toBeUndefined();
expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('is empty'));
});
it('should throw error for invalid JSON', async () => {
(fileExists as Mock).mockResolvedValue(true);
(readFile as Mock).mockResolvedValue('{ invalid json }');
await expect(config.parseConfig()).rejects.toThrow();
expect(mockLogger.error).toHaveBeenCalledWith(
expect.stringContaining('Failed to parse JSON')
);
expect(mockLogger.debug).toHaveBeenCalledWith(expect.stringContaining('{ invalid json }'));
});
it('should throw error for incomplete JSON', async () => {
(fileExists as Mock).mockResolvedValue(true);
(readFile as Mock).mockResolvedValue('{ "name": "test"');
await expect(config.parseConfig()).rejects.toThrow();
expect(mockLogger.error).toHaveBeenCalledWith(
expect.stringContaining('Failed to parse JSON')
);
});
it('should use custom file path when provided', async () => {
const customPath = '/custom/path/config.json';
(fileExists as Mock).mockResolvedValue(true);
(readFile as Mock).mockResolvedValue(JSON.stringify(defaultConfig));
await config.parseConfig({ filePath: customPath });
expect(fileExists).toHaveBeenCalledWith(customPath);
expect(readFile).toHaveBeenCalledWith(customPath, 'utf8');
});
});
describe('persist', () => {
let config: ApiStateConfig<TestConfig>;
beforeEach(() => {
config = new ApiStateConfig(
{
name: 'test-config',
defaultConfig,
parse: parseFunction,
},
mockPersistenceHelper
);
});
it('should persist current config when no argument provided', async () => {
const result = await config.persist();
expect(result).toBe(true);
expect(mockPersistenceHelper.persistIfChanged).toHaveBeenCalledWith(
config.filePath,
defaultConfig
);
});
it('should persist provided config', async () => {
const customConfig = { name: 'custom', value: 999, enabled: false };
const result = await config.persist(customConfig);
expect(result).toBe(true);
expect(mockPersistenceHelper.persistIfChanged).toHaveBeenCalledWith(
config.filePath,
customConfig
);
});
it('should return false and log error on persistence failure', async () => {
(mockPersistenceHelper.persistIfChanged as Mock).mockResolvedValue(false);
const result = await config.persist();
expect(result).toBe(false);
expect(mockLogger.error).toHaveBeenCalledWith(
expect.stringContaining('Could not write config')
);
});
});
describe('load', () => {
let config: ApiStateConfig<TestConfig>;
beforeEach(() => {
config = new ApiStateConfig(
{
name: 'test-config',
defaultConfig,
parse: parseFunction,
},
mockPersistenceHelper
);
});
it('should load config from file when it exists', async () => {
const savedConfig = { name: 'saved', value: 200, enabled: true };
(fileExists as Mock).mockResolvedValue(true);
(readFile as Mock).mockResolvedValue(JSON.stringify(savedConfig));
await config.load();
expect(config.config).toEqual(savedConfig);
});
it('should create default config when file does not exist', async () => {
(fileExists as Mock).mockResolvedValue(false);
await config.load();
expect(config.config).toEqual(defaultConfig);
expect(mockLogger.log).toHaveBeenCalledWith(
expect.stringContaining('Config file does not exist')
);
expect(mockPersistenceHelper.persistIfChanged).toHaveBeenCalledWith(
config.filePath,
defaultConfig
);
});
it('should not modify config when file is invalid', async () => {
(fileExists as Mock).mockResolvedValue(true);
(readFile as Mock).mockResolvedValue('invalid json');
await config.load();
expect(config.config).toEqual(defaultConfig);
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.any(Error),
expect.stringContaining('is invalid')
);
});
it('should not throw even when persist fails', async () => {
(fileExists as Mock).mockResolvedValue(false);
(mockPersistenceHelper.persistIfChanged as Mock).mockResolvedValue(false);
await expect(config.load()).resolves.not.toThrow();
expect(config.config).toEqual(defaultConfig);
});
});
describe('update', () => {
let config: ApiStateConfig<TestConfig>;
beforeEach(() => {
config = new ApiStateConfig(
{
name: 'test-config',
defaultConfig,
parse: parseFunction,
},
mockPersistenceHelper
);
});
it('should update config with partial values', () => {
config.update({ value: 123 });
expect(config.config).toEqual({
name: 'test',
value: 123,
enabled: true,
});
});
it('should return self for chaining', () => {
const result = config.update({ enabled: false });
expect(result).toBe(config);
});
it('should validate updated config through parse function', () => {
const badParseFunction = vi.fn().mockImplementation(() => {
throw new Error('Validation failed');
});
const strictConfig = new ApiStateConfig(
{
name: 'strict-config',
defaultConfig,
parse: badParseFunction,
},
mockPersistenceHelper
);
expect(() => strictConfig.update({ value: -1 })).toThrow('Validation failed');
});
});
});

View File

@@ -1,122 +0,0 @@
import { Logger } from '@nestjs/common';
import { readFile } from 'node:fs/promises';
import { join } from 'path';
import { fileExists } from '@app/core/utils/files/file-exists.js';
import { PATHS_CONFIG_MODULES } from '@app/environment.js';
import { makeConfigToken } from '@app/unraid-api/config/factory/config.injection.js';
import { ConfigPersistenceHelper } from '@app/unraid-api/config/persistence.helper.js';
export interface ApiStateConfigOptions<T> {
/**
* The name of the config.
*
* - Must be unique.
* - Should be the key representing this config in the `ConfigFeatures` interface.
* - Used for logging and dependency injection.
*/
name: string;
defaultConfig: T;
parse: (data: unknown) => T;
}
export class ApiStateConfig<T> {
#config: T;
private logger: Logger;
constructor(
readonly options: ApiStateConfigOptions<T>,
readonly persistenceHelper: ConfigPersistenceHelper
) {
// avoid sharing a reference with the given default config. This allows us to re-use it.
this.#config = structuredClone(options.defaultConfig);
this.logger = new Logger(this.token);
}
/** Unique token for this config. Used for Dependency Injection & logging. */
get token() {
return makeConfigToken(this.options.name);
}
get fileName() {
return `${this.options.name}.json`;
}
get filePath() {
return join(PATHS_CONFIG_MODULES, this.fileName);
}
get config() {
return this.#config;
}
/**
* Persists the config to the file system. Will never throw.
* @param config - The config to persist.
* @returns True if the config was written successfully, false otherwise.
*/
async persist(config = this.#config) {
const success = await this.persistenceHelper.persistIfChanged(this.filePath, config);
if (!success) {
this.logger.error(`Could not write config to ${this.filePath}.`);
}
return success;
}
/**
* Reads the config from a path (defaults to the default file path of the config).
* @param opts - The options for the read operation.
* @param opts.filePath - The path to the config file.
* @returns The parsed config or undefined if the file does not exist.
* @throws If the file exists but is invalid.
*/
async parseConfig(opts: { filePath?: string } = {}): Promise<T | undefined> {
const { filePath = this.filePath } = opts;
if (!(await fileExists(filePath))) return undefined;
const fileContent = await readFile(filePath, 'utf8');
if (!fileContent || fileContent.trim() === '') {
this.logger.warn(`Config file '${filePath}' is empty.`);
return undefined;
}
try {
const rawConfig = JSON.parse(fileContent);
return this.options.parse(rawConfig);
} catch (error) {
this.logger.error(
`Failed to parse JSON from '${filePath}': ${error instanceof Error ? error.message : String(error)}`
);
this.logger.debug(`File content: ${fileContent.substring(0, 100)}...`);
throw error;
}
}
/**
* Loads config from the file system. If the file does not exist, it will be created with the default config.
* If the config is invalid or corrupt, no action will be taken. The error will be logged.
*
* Will never throw.
*/
async load() {
try {
const config = await this.parseConfig();
if (config) {
this.#config = config;
} else {
this.logger.log(`Config file does not exist. Writing default config.`);
this.#config = this.options.defaultConfig;
await this.persist();
}
} catch (error) {
this.logger.warn(error, `Config file '${this.filePath}' is invalid. Not modifying config.`);
}
}
update(config: Partial<T>) {
const proposedConfig = this.options.parse({ ...this.#config, ...config });
this.#config = proposedConfig;
return this;
}
}

View File

@@ -1,54 +0,0 @@
import type { DynamicModule, Provider } from '@nestjs/common';
import { SchedulerRegistry } from '@nestjs/schedule';
import type { ApiStateConfigOptions } from '@app/unraid-api/config/factory/api-state.model.js';
import type { ApiStateConfigPersistenceOptions } from '@app/unraid-api/config/factory/api-state.service.js';
import { ApiStateConfig } from '@app/unraid-api/config/factory/api-state.model.js';
import { ScheduledConfigPersistence } from '@app/unraid-api/config/factory/api-state.service.js';
import { makeConfigToken } from '@app/unraid-api/config/factory/config.injection.js';
import { ConfigPersistenceHelper } from '@app/unraid-api/config/persistence.helper.js';
type ApiStateRegisterOptions<ConfigType> = ApiStateConfigOptions<ConfigType> & {
persistence?: ApiStateConfigPersistenceOptions;
};
export class ApiStateConfigModule {
static async register<ConfigType>(
options: ApiStateRegisterOptions<ConfigType>
): Promise<DynamicModule> {
const { persistence, ...configOptions } = options;
const configToken = makeConfigToken(options.name);
const persistenceToken = makeConfigToken(options.name, ScheduledConfigPersistence.name);
const ConfigProvider = {
provide: configToken,
useFactory: async (helper: ConfigPersistenceHelper) => {
const config = new ApiStateConfig(configOptions, helper);
await config.load();
return config;
},
inject: [ConfigPersistenceHelper],
};
const providers: Provider[] = [ConfigProvider, ConfigPersistenceHelper];
const exports = [configToken];
if (persistence) {
providers.push({
provide: persistenceToken,
useFactory: (
schedulerRegistry: SchedulerRegistry,
config: ApiStateConfig<ConfigType>
) => {
return new ScheduledConfigPersistence(schedulerRegistry, config, persistence);
},
inject: [SchedulerRegistry, configToken],
});
exports.push(persistenceToken);
}
return {
module: ApiStateConfigModule,
providers,
exports,
};
}
}

View File

@@ -1,82 +0,0 @@
import type { OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { Logger } from '@nestjs/common';
import { SchedulerRegistry } from '@nestjs/schedule';
import type { ApiStateConfig } from '@app/unraid-api/config/factory/api-state.model.js';
import { makeConfigToken } from '@app/unraid-api/config/factory/config.injection.js';
export interface ApiStateConfigPersistenceOptions {
/** How often to persist the config to the file system, in milliseconds. Defaults to 10 seconds. */
intervalMs?: number;
/** How many consecutive failed persistence attempts to tolerate before stopping. Defaults to 5. */
maxConsecutiveFailures?: number;
/** By default, the config will be persisted to the file system when the module is initialized and destroyed.
* Set this to true to disable this behavior.
*/
disableLifecycleHooks?: boolean;
}
export class ScheduledConfigPersistence<T> implements OnModuleInit, OnModuleDestroy {
private consecutiveFailures = 0;
private logger: Logger;
constructor(
private readonly schedulerRegistry: SchedulerRegistry,
private readonly config: ApiStateConfig<T>,
private readonly options: ApiStateConfigPersistenceOptions
) {
this.logger = new Logger(this.token);
}
get token() {
return makeConfigToken(this.configName, ScheduledConfigPersistence.name);
}
get configName() {
return this.config.options.name;
}
onModuleInit() {
if (this.options.disableLifecycleHooks) return;
this.setup();
}
async onModuleDestroy() {
if (this.options.disableLifecycleHooks) return;
this.stop();
await this.config.persist();
}
stop() {
if (this.schedulerRegistry.getInterval(this.token)) {
this.schedulerRegistry.deleteInterval(this.token);
}
}
setup() {
const interval = this.schedulerRegistry.getInterval(this.token);
if (interval) {
this.logger.warn(`Persistence interval for '${this.token}' already exists. Aborting setup.`);
return;
}
const ONE_MINUTE = 60_000;
const { intervalMs = ONE_MINUTE, maxConsecutiveFailures = 3 } = this.options;
const callback = async () => {
const success = await this.config.persist();
if (success) {
this.consecutiveFailures = 0;
return;
}
this.consecutiveFailures++;
if (this.consecutiveFailures > maxConsecutiveFailures) {
this.logger.warn(
`Failed to persist '${this.configName}' too many times in a row (${this.consecutiveFailures} attempts). Disabling persistence.`
);
this.schedulerRegistry.deleteInterval(this.token);
}
};
this.schedulerRegistry.addInterval(this.token, setInterval(callback, intervalMs));
}
}

View File

@@ -1,22 +0,0 @@
import { Inject } from '@nestjs/common';
import type { ConfigFeatures } from '@app/unraid-api/config/factory/config.interface.js';
/**
* Creates a string token representation of the arguements. Pure function.
*
* @param configName - The name of the config.
* @returns A colon-separated string
*/
export function makeConfigToken(configName: string, ...details: string[]) {
return ['ApiConfig', configName, ...details].join('.');
}
/**
* Custom decorator to inject a config by name.
* @param feature - The name of the config to inject.
* @returns Dependency injector for the config.
*/
export function InjectConfig<K extends keyof ConfigFeatures>(feature: K) {
return Inject(makeConfigToken(feature));
}

View File

@@ -1,15 +0,0 @@
/**
* Container record of config names to their types. Used for type completion on registered configs.
* Config authors should redeclare/merge this interface with their config names as the keys
* and implementation models as the types.
*/
export interface ConfigFeatures {}
export interface ConfigMetadata<T = unknown> {
/** Unique token for this config. Used for Dependency Injection, logging, etc. */
token: string;
/** The path to the config file. */
filePath?: string;
/** Validates a config of type `T`. */
validate: (config: unknown) => Promise<T>;
}

View File

@@ -1,70 +0,0 @@
import { Injectable } from '@nestjs/common';
import { readFile, writeFile } from 'fs/promises';
import { fileExists } from '@unraid/shared/util/file.js';
import { isEqual } from 'lodash-es';
@Injectable()
export class ConfigPersistenceHelper {
/**
* Persist the config to disk if the given data is different from the data on-disk.
* This helps preserve the boot flash drive's life by avoiding unnecessary writes.
*
* @param filePath - The path to the config file.
* @param data - The data to persist.
* @returns `true` if the config was persisted, `false` if no changes were needed or if persistence failed.
*
* This method is designed to never throw errors. If the existing file is corrupted or unreadable,
* it will attempt to overwrite it with the new data. If write operations fail, it returns false
* but does not crash the application.
*/
async persistIfChanged(filePath: string, data: unknown): Promise<boolean> {
if (!(await fileExists(filePath))) {
try {
const jsonString = JSON.stringify(data ?? {}, null, 2);
await writeFile(filePath, jsonString);
return true;
} catch (error) {
// JSON serialization or write failed, but don't crash - just return false
return false;
}
}
let currentData: unknown;
try {
const fileContent = await readFile(filePath, 'utf8');
currentData = JSON.parse(fileContent);
} catch (error) {
// If existing file is corrupted, treat it as if it doesn't exist
// and write the new data
try {
const jsonString = JSON.stringify(data ?? {}, null, 2);
await writeFile(filePath, jsonString);
return true;
} catch (writeError) {
// JSON serialization or write failed, but don't crash - just return false
return false;
}
}
let stagedData: unknown;
try {
stagedData = JSON.parse(JSON.stringify(data));
} catch (error) {
// If data can't be serialized to JSON, we can't persist it
return false;
}
if (isEqual(currentData, stagedData)) {
return false;
}
try {
await writeFile(filePath, JSON.stringify(stagedData, null, 2));
return true;
} catch (error) {
// Write failed, but don't crash - just return false
return false;
}
}
}

View File

@@ -1,6 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { existsSync } from 'fs';
import { stat } from 'fs/promises';
import { execa } from 'execa';
@@ -8,25 +8,26 @@ import { execa } from 'execa';
export class LogRotateService {
private readonly logger = new Logger(LogRotateService.name);
logRotatePath: string = '/usr/sbin/logrotate';
configPath: string = '/etc/logrotate.conf';
@Cron('0 * * * *')
private readonly logFilePath = '/var/log/graphql-api.log';
private readonly maxSizeBytes = 5 * 1024 * 1024; // 5MB
@Cron('*/20 * * * *') // Every 20 minutes
async handleCron() {
try {
if (!existsSync(this.logRotatePath)) {
throw new Error(`Logrotate binary not found at ${this.logRotatePath}`);
const stats = await stat(this.logFilePath);
if (stats.size > this.maxSizeBytes) {
this.logger.debug(`Log file size (${stats.size} bytes) exceeds limit, truncating`);
await execa('truncate', ['-s', '0', this.logFilePath]);
this.logger.debug('Log file truncated successfully');
} else {
this.logger.debug(`Log file size (${stats.size} bytes) within limit`);
}
if (!existsSync(this.configPath)) {
throw new Error(`Logrotate config not found at ${this.configPath}`);
}
this.logger.debug('Running logrotate');
const result = await execa(this.logRotatePath, [this.configPath]);
if (result.failed) {
throw new Error(`Logrotate execution failed: ${result.stderr}`);
}
this.logger.debug('Logrotate completed successfully');
} catch (error) {
this.logger.debug('Failed to run logrotate with error' + error);
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
this.logger.debug('Log file does not exist, skipping truncation');
} else {
this.logger.debug('Failed to check/truncate log file: ' + error);
}
}
}
}

View File

@@ -1,10 +1,10 @@
import { Injectable } from '@nestjs/common';
import { existsSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { type DynamixConfig } from '@app/core/types/ini.js';
import { toBoolean } from '@app/core/utils/casting.js';
import { fileExists } from '@app/core/utils/files/file-exists.js';
import { loadState } from '@app/core/utils/misc/load-state.js';
import { getters } from '@app/store/index.js';
import { ThemeName } from '@app/unraid-api/graph/resolvers/customization/theme.model.js';
@@ -80,7 +80,7 @@ export class DisplayService {
// If the config file doesn't exist then it's a new OS install
// Default to "default"
if (!existsSync(configFilePath)) {
if (!(await fileExists(configFilePath))) {
return states.default;
}

View File

@@ -0,0 +1,228 @@
// Unit Test File for NotificationsService: loadNotificationFile
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { NotificationIni } from '@app/core/types/states/notification.js';
import {
Notification,
NotificationImportance,
NotificationType,
} from '@app/unraid-api/graph/resolvers/notifications/notifications.model.js';
import { NotificationsService } from '@app/unraid-api/graph/resolvers/notifications/notifications.service.js';
// Only mock getters.dynamix and Logger
vi.mock('@app/store/index.js', () => ({
getters: {
dynamix: vi.fn().mockReturnValue({
notify: { path: '/test/notifications' },
display: {
date: 'Y-m-d',
time: 'H:i:s',
},
}),
},
}));
vi.mock('@nestjs/common', async (importOriginal) => {
const original = await importOriginal<typeof import('@nestjs/common')>();
return {
...original,
Logger: vi.fn(() => ({
log: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
verbose: vi.fn(),
})),
};
});
describe('NotificationsService - loadNotificationFile (minimal mocks)', () => {
let service: NotificationsService;
beforeEach(() => {
service = new NotificationsService();
});
it('should load and validate a valid notification file', async () => {
const mockNotificationIni: NotificationIni = {
timestamp: '1609459200',
event: 'Test Event',
subject: 'Test Subject',
description: 'Test Description',
importance: 'alert',
link: 'http://example.com',
};
vi.spyOn(await import('@app/core/utils/misc/parse-config.js'), 'parseConfig').mockReturnValue(
mockNotificationIni
);
const result = await (service as any).loadNotificationFile(
'/test/path/test.notify',
NotificationType.UNREAD
);
expect(result).toEqual(
expect.objectContaining({
id: 'test.notify',
type: NotificationType.UNREAD,
title: 'Test Event',
subject: 'Test Subject',
description: 'Test Description',
importance: NotificationImportance.ALERT,
link: 'http://example.com',
timestamp: '2021-01-01T00:00:00.000Z',
})
);
});
it('should return masked warning notification on validation error (missing required fields)', async () => {
const invalidNotificationIni: Omit<NotificationIni, 'event'> = {
timestamp: '1609459200',
// event: 'Missing Event', // missing required field
subject: 'Test Subject',
description: 'Test Description',
importance: 'alert',
};
vi.spyOn(await import('@app/core/utils/misc/parse-config.js'), 'parseConfig').mockReturnValue(
invalidNotificationIni
);
const result = await (service as any).loadNotificationFile(
'/test/path/invalid.notify',
NotificationType.UNREAD
);
expect(result.id).toBe('invalid.notify');
expect(result.importance).toBe(NotificationImportance.WARNING);
expect(result.description).toContain('invalid and cannot be displayed');
});
it('should handle invalid enum values', async () => {
const invalidNotificationIni: NotificationIni = {
timestamp: '1609459200',
event: 'Test Event',
subject: 'Test Subject',
description: 'Test Description',
importance: 'not-a-valid-enum' as any,
};
vi.spyOn(await import('@app/core/utils/misc/parse-config.js'), 'parseConfig').mockReturnValue(
invalidNotificationIni
);
const result = await (service as any).loadNotificationFile(
'/test/path/invalid-enum.notify',
NotificationType.UNREAD
);
expect(result.id).toBe('invalid-enum.notify');
// Implementation falls back to INFO for unknown importance
expect(result.importance).toBe(NotificationImportance.INFO);
// Should not be a masked warning notification, just fallback to INFO
expect(result.description).toBe('Test Description');
});
it('should handle missing description field (should return masked warning notification)', async () => {
const mockNotificationIni: Omit<NotificationIni, 'description'> = {
timestamp: '1609459200',
event: 'Test Event',
subject: 'Test Subject',
importance: 'normal',
};
vi.spyOn(await import('@app/core/utils/misc/parse-config.js'), 'parseConfig').mockReturnValue(
mockNotificationIni
);
const result = await (service as any).loadNotificationFile(
'/test/path/test.notify',
NotificationType.UNREAD
);
// Should be a masked warning notification
expect(result.description).toContain('invalid and cannot be displayed');
expect(result.importance).toBe(NotificationImportance.WARNING);
});
it('should preserve passthrough data from notification file (only known fields)', async () => {
const mockNotificationIni: NotificationIni & { customField: string } = {
timestamp: '1609459200',
event: 'Test Event',
subject: 'Test Subject',
description: 'Test Description',
importance: 'normal',
link: 'http://example.com',
customField: 'custom value',
};
vi.spyOn(await import('@app/core/utils/misc/parse-config.js'), 'parseConfig').mockReturnValue(
mockNotificationIni
);
const result = await (service as any).loadNotificationFile(
'/test/path/test.notify',
NotificationType.UNREAD
);
expect(result).toEqual(
expect.objectContaining({
link: 'http://example.com',
// customField should NOT be present
description: 'Test Description',
id: 'test.notify',
type: NotificationType.UNREAD,
title: 'Test Event',
subject: 'Test Subject',
importance: NotificationImportance.INFO,
timestamp: '2021-01-01T00:00:00.000Z',
})
);
expect((result as any).customField).toBeUndefined();
});
it('should handle missing timestamp field gracefully', async () => {
const mockNotificationIni: Omit<NotificationIni, 'timestamp'> = {
// timestamp is missing
event: 'Test Event',
subject: 'Test Subject',
description: 'Test Description',
importance: 'alert',
};
vi.spyOn(await import('@app/core/utils/misc/parse-config.js'), 'parseConfig').mockReturnValue(
mockNotificationIni
);
const result = await (service as any).loadNotificationFile(
'/test/path/missing-timestamp.notify',
NotificationType.UNREAD
);
expect(result.id).toBe('missing-timestamp.notify');
expect(result.importance).toBe(NotificationImportance.ALERT);
expect(result.description).toBe('Test Description');
expect(result.timestamp).toBeUndefined(); // Missing timestamp results in undefined
expect(result.formattedTimestamp).toBe(undefined); // Also undefined since timestamp is missing
});
it('should handle malformed timestamp field gracefully', async () => {
const mockNotificationIni: NotificationIni = {
timestamp: 'not-a-timestamp',
event: 'Test Event',
subject: 'Test Subject',
description: 'Test Description',
importance: 'alert',
};
vi.spyOn(await import('@app/core/utils/misc/parse-config.js'), 'parseConfig').mockReturnValue(
mockNotificationIni
);
const result = await (service as any).loadNotificationFile(
'/test/path/malformed-timestamp.notify',
NotificationType.UNREAD
);
expect(result.id).toBe('malformed-timestamp.notify');
expect(result.importance).toBe(NotificationImportance.ALERT);
expect(result.description).toBe('Test Description');
expect(result.timestamp).toBeUndefined(); // Malformed timestamp results in undefined
expect(result.formattedTimestamp).toBe('not-a-timestamp'); // Returns original string when parsing fails
});
});

View File

@@ -1,3 +1,11 @@
// Integration Test File for NotificationsService
// ------------------------------------------------
// This file contains integration-style tests for the NotificationsService.
// It uses the full NestJS TestingModule, mocks only the minimum required dependencies,
// and interacts with the real filesystem (in /tmp/test/notifications).
// These tests cover end-to-end service behavior, including notification creation,
// archiving, unarchiving, deletion, and legacy CLI compatibility.
import type { TestingModule } from '@nestjs/testing';
import { Test } from '@nestjs/testing';
import { existsSync } from 'fs';

View File

@@ -1,10 +1,10 @@
import { Injectable, Logger } from '@nestjs/common';
import { statSync } from 'fs';
import { readdir, rename, unlink, writeFile } from 'fs/promises';
import { readdir, rename, stat, unlink, writeFile } from 'fs/promises';
import { basename, join } from 'path';
import type { Stats } from 'fs';
import { FSWatcher, watch } from 'chokidar';
import { ValidationError } from 'class-validator';
import { execa } from 'execa';
import { emptyDir } from 'fs-extra';
import { encode as encodeIni } from 'ini';
@@ -581,12 +581,15 @@ export class NotificationsService {
sortFn: SortFn<Stats> = (fileA, fileB) => fileB.birthtimeMs - fileA.birthtimeMs // latest first
): Promise<string[]> {
const contents = narrowContent(await readdir(folderPath));
return contents
.map((content) => {
const contentStats = await Promise.all(
contents.map(async (content) => {
// pre-map each file's stats to avoid excess calls during sorting
const path = join(folderPath, content);
return { path, stats: statSync(path) };
const stats = await stat(path);
return { path, stats };
})
);
return contentStats
.sort((fileA, fileB) => sortFn(fileA.stats, fileB.stats))
.map(({ path }) => path);
}
@@ -635,10 +638,14 @@ export class NotificationsService {
* Loads a notification file from disk, parses it to a Notification object, and
* validates the object against the NotificationSchema.
*
* If the file contains invalid data (doesn't conform to the Notification schema),
* instead of throwing, returns a masked warning notification with details masked,
* and logs a warning. This allows the system to gracefully handle corrupt or malformed notifications.
*
* @param path The path to the notification file on disk.
* @param type The type of the notification that is being loaded.
* @returns A parsed Notification object, or throws an error if the object is invalid.
* @throws An error if the object is invalid (doesn't conform to the graphql NotificationSchema).
* @returns A parsed Notification object, or a masked warning notification if invalid.
* @throws File system errors (file not found, permission issues) or unexpected validation errors.
*/
private async loadNotificationFile(path: string, type: NotificationType): Promise<Notification> {
const notificationFile = parseConfig<NotificationIni>({
@@ -656,8 +663,28 @@ export class NotificationsService {
// The contents of the file, and therefore the notification, may not always be a valid notification.
// so we parse it through the schema to make sure it is
const validatedNotification = await validateObject(Notification, notification);
return validatedNotification;
try {
const validatedNotification = await validateObject(Notification, notification);
return validatedNotification;
} catch (error) {
if (!(error instanceof ValidationError)) {
throw error;
}
const errorsToLog = error.children?.length ? error.children : error;
this.logger.warn(errorsToLog, `notification file at ${path} is invalid. Will mask.`);
const nameMask = this.getIdFromPath(path);
const dateMask = new Date();
return {
id: nameMask,
type,
title: nameMask,
subject: nameMask,
description: `This notification is invalid and cannot be displayed! For details, see the logs and the notification file at ${path}`,
importance: NotificationImportance.WARNING,
timestamp: dateMask.toISOString(),
formattedTimestamp: this.formatDatetime(dateMask),
};
}
}
private getIdFromPath(path: string) {
@@ -729,19 +756,22 @@ export class NotificationsService {
}
private formatTimestamp(timestamp: string) {
const { display: settings } = getters.dynamix();
const date = this.parseNotificationDateToIsoDate(timestamp);
if (!date) {
this.logger.warn(`[formatTimestamp] Could not parse date from timestamp: ${date}`);
return timestamp;
}
return this.formatDatetime(date);
}
private formatDatetime(date: Date) {
const { display: settings } = getters.dynamix();
if (!settings) {
this.logger.warn(
'[formatTimestamp] Dynamix display settings not found. Cannot apply user settings.'
);
return timestamp;
} else if (!date) {
this.logger.warn(`[formatTimestamp] Could not parse date from timestamp: ${date}`);
return timestamp;
return date.toISOString();
}
// this.logger.debug(`[formatTimestamp] ${settings.date} :: ${settings.time} :: ${date}`);
return formatDatetime(date, {
dateFormat: settings.date,
timeFormat: settings.time,

View File

@@ -1,7 +1,6 @@
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import crypto from 'crypto';
import { ChildProcess } from 'node:child_process';
import { existsSync } from 'node:fs';
import { mkdir, rm, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
@@ -10,6 +9,7 @@ import got, { HTTPError } from 'got';
import pRetry from 'p-retry';
import { sanitizeParams } from '@app/core/log.js';
import { fileExists } from '@app/core/utils/files/file-exists.js';
import {
CreateRCloneRemoteDto,
DeleteRCloneRemoteDto,
@@ -104,7 +104,7 @@ export class RCloneApiService implements OnModuleInit, OnModuleDestroy {
private async startRcloneSocket(socketPath: string, logFilePath: string): Promise<boolean> {
try {
// Make log file exists
if (!existsSync(logFilePath)) {
if (!(await fileExists(logFilePath))) {
this.logger.debug(`Creating log file: ${logFilePath}`);
await mkdir(dirname(logFilePath), { recursive: true });
await writeFile(logFilePath, '', 'utf-8');
@@ -187,7 +187,7 @@ export class RCloneApiService implements OnModuleInit, OnModuleDestroy {
}
// Clean up the socket file if it exists
if (this.rcloneSocketPath && existsSync(this.rcloneSocketPath)) {
if (this.rcloneSocketPath && (await fileExists(this.rcloneSocketPath))) {
this.logger.log(`Removing RClone socket file: ${this.rcloneSocketPath}`);
try {
await rm(this.rcloneSocketPath, { force: true });
@@ -201,7 +201,7 @@ export class RCloneApiService implements OnModuleInit, OnModuleDestroy {
* Checks if the RClone socket exists
*/
private async checkRcloneSocketExists(socketPath: string): Promise<boolean> {
const socketExists = existsSync(socketPath);
const socketExists = await fileExists(socketPath);
if (!socketExists) {
this.logger.warn(`RClone socket does not exist at: ${socketPath}`);
return false;

View File

@@ -10,6 +10,7 @@ export class NginxService {
async reload() {
try {
await execa('/etc/rc.d/rc.nginx', ['reload']);
this.logger.log('Nginx reloaded');
return true;
} catch (err: unknown) {
this.logger.warn('Failed to reload Nginx with error: ', err);

View File

@@ -4,13 +4,14 @@ import { ConfigService } from '@nestjs/config';
import { ApiConfig } from '@unraid/shared/services/api-config.js';
import { DependencyService } from '@app/unraid-api/app/dependency.service.js';
import { persistApiConfig } from '@app/unraid-api/config/api-config.module.js';
import { ApiConfigPersistence } from '@app/unraid-api/config/api-config.module.js';
@Injectable()
export class PluginManagementService {
constructor(
private readonly configService: ConfigService<{ api: ApiConfig }, true>,
private readonly dependencyService: DependencyService
private readonly dependencyService: DependencyService,
private readonly apiConfigPersistence: ApiConfigPersistence
) {}
get plugins() {
@@ -111,6 +112,6 @@ export class PluginManagementService {
}
private async persistConfig() {
return await persistApiConfig(this.configService.get('api', { infer: true }));
return await this.apiConfigPersistence.persist();
}
}

View File

@@ -1,6 +1,7 @@
import { DynamicModule, Logger, Module } from '@nestjs/common';
import { DependencyService } from '@app/unraid-api/app/dependency.service.js';
import { ApiConfigModule } from '@app/unraid-api/config/api-config.module.js';
import { ResolversModule } from '@app/unraid-api/graph/resolvers/resolvers.module.js';
import { GlobalDepsModule } from '@app/unraid-api/plugin/global-deps.module.js';
import { PluginManagementService } from '@app/unraid-api/plugin/plugin-management.service.js';
@@ -22,7 +23,7 @@ export class PluginModule {
return {
module: PluginModule,
imports: [GlobalDepsModule, ResolversModule, ...apiModules],
imports: [GlobalDepsModule, ResolversModule, ApiConfigModule, ...apiModules],
providers: [PluginService, PluginManagementService, DependencyService, PluginResolver],
exports: [PluginService, PluginManagementService, DependencyService, GlobalDepsModule],
};
@@ -44,7 +45,7 @@ export class PluginCliModule {
return {
module: PluginCliModule,
imports: [GlobalDepsModule, ...cliModules],
imports: [GlobalDepsModule, ApiConfigModule, ...cliModules],
providers: [PluginManagementService, DependencyService],
exports: [PluginManagementService, DependencyService, GlobalDepsModule],
};

View File

@@ -1,14 +1,18 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { ONE_SECOND_MS } from '@app/consts.js';
import { NginxService } from '@app/unraid-api/nginx/nginx.service.js';
import { ModificationEffect } from '@app/unraid-api/unraid-file-modifier/file-modification.js';
@Injectable()
export class FileModificationEffectService {
private readonly logger = new Logger(FileModificationEffectService.name);
constructor(private readonly nginxService: NginxService) {}
async runEffect(effect: ModificationEffect): Promise<void> {
switch (effect) {
case 'nginx:reload':
this.logger.log('Reloading Nginx in 10 seconds...');
await new Promise((resolve) => setTimeout(resolve, 10 * ONE_SECOND_MS));
await this.nginxService.reload();
break;
}

View File

@@ -8,7 +8,6 @@ import { describe, expect, test, vi } from 'vitest';
import { FileModification } from '@app/unraid-api/unraid-file-modifier/file-modification.js';
import AuthRequestModification from '@app/unraid-api/unraid-file-modifier/modifications/auth-request.modification.js';
import DefaultPageLayoutModification from '@app/unraid-api/unraid-file-modifier/modifications/default-page-layout.modification.js';
import LogRotateModification from '@app/unraid-api/unraid-file-modifier/modifications/log-rotate.modification.js';
import NotificationsPageModification from '@app/unraid-api/unraid-file-modifier/modifications/notifications-page.modification.js';
import RcNginxModification from '@app/unraid-api/unraid-file-modifier/modifications/rc-nginx.modification.js';
import SSOFileModification from '@app/unraid-api/unraid-file-modifier/modifications/sso.modification.js';
@@ -56,13 +55,7 @@ const patchTestCases: ModificationTestCase[] = [
];
/** Modifications that simply add a new file & remove it on rollback. */
const simpleTestCases: ModificationTestCase[] = [
{
ModificationClass: LogRotateModification,
fileUrl: 'logrotate.conf',
fileName: 'logrotate.conf',
},
];
const simpleTestCases: ModificationTestCase[] = [];
const downloadOrRetrieveOriginalFile = async (filePath: string, fileUrl: string): Promise<string> => {
let originalContent = '';

View File

@@ -711,7 +711,7 @@ $.ajaxPrefilter(function(s, orig, xhr){
<div class="upgrade_notice" style="display:none"></div>
<div id="header" class="<?=$display['banner']?>">
<div class="logo">
<a href="https://unraid.net" target="_blank"><?readfile("$docroot/webGui/images/UN-logotype-gradient.svg")?></a>
<unraid-i18n-host><unraid-header-os-version></unraid-header-os-version></unraid-i18n-host>
</div>
<?include "$docroot/plugins/dynamix.my.servers/include/myservers2.php"?>

View File

@@ -1,7 +1,7 @@
import { existsSync } from 'fs';
import { readFile } from 'fs/promises';
import { join } from 'node:path';
import { fileExists } from '@app/core/utils/files/file-exists.js';
import {
FileModification,
ShouldApplyWithReason,
@@ -45,7 +45,7 @@ export default class AuthRequestModification extends FileModification {
const filesToAdd = [getters.paths().webgui.logo.assetPath, ...jsFiles];
if (!existsSync(this.filePath)) {
if (!(await fileExists(this.filePath))) {
throw new Error(`File ${this.filePath} not found.`);
}

View File

@@ -66,8 +66,19 @@ if (is_localhost() && !is_good_session()) {
}
private addModalsWebComponent(source: string): string {
if (source.includes('<unraid-modals>')) {
return source;
}
return source.replace('<body>', '<body>\n<unraid-modals></unraid-modals>');
}
private hideHeaderLogo(source: string): string {
return source.replace(
'<a href="https://unraid.net" target="_blank"><?readfile("$docroot/webGui/images/UN-logotype-gradient.svg")?></a>',
''
);
}
private applyToSource(fileContent: string): string {
const transformers = [
this.removeNotificationBell.bind(this),
@@ -75,6 +86,7 @@ if (is_localhost() && !is_good_session()) {
this.addToaster.bind(this),
this.patchGuiBootAuth.bind(this),
this.addModalsWebComponent.bind(this),
this.hideHeaderLogo.bind(this),
];
return transformers.reduce((content, transformer) => transformer(content), fileContent);

View File

@@ -1,70 +0,0 @@
import { Logger } from '@nestjs/common';
import { readFile, rm, writeFile } from 'node:fs/promises';
import { fileExists } from '@app/core/utils/files/file-exists.js';
import {
FileModification,
ShouldApplyWithReason,
} from '@app/unraid-api/unraid-file-modifier/file-modification.js';
export default class LogRotateModification extends FileModification {
id: string = 'log-rotate';
public readonly filePath: string = '/etc/logrotate.d/unraid-api' as const;
private readonly logRotateConfig: string = `
/var/log/unraid-api/*.log {
rotate 1
missingok
size 1M
su root root
compress
delaycompress
copytruncate
create 0640 root root
}
/var/log/graphql-api.log {
rotate 1
missingok
size 1M
su root root
compress
delaycompress
copytruncate
create 0640 root root
}
`.trimStart();
constructor(logger: Logger) {
super(logger);
}
protected async generatePatch(overridePath?: string): Promise<string> {
const currentContent = (await fileExists(this.filePath))
? await readFile(this.filePath, 'utf8')
: '';
return this.createPatchWithDiff(
overridePath ?? this.filePath,
currentContent,
this.logRotateConfig
);
}
async shouldApply(): Promise<ShouldApplyWithReason> {
const alreadyConfigured = await fileExists(this.filePath);
if (alreadyConfigured) {
return { shouldApply: false, reason: 'LogRotate configuration already exists' };
}
return { shouldApply: true, reason: 'No LogRotate config for the API configured yet' };
}
async apply(): Promise<string> {
await this.rollback();
await writeFile(this.filePath, this.logRotateConfig, { mode: 0o644 });
return this.logRotateConfig;
}
async rollback(): Promise<void> {
await rm(this.getPathToAppliedPatch(), { force: true });
await rm(this.filePath, { force: true });
}
}

View File

@@ -53,7 +53,7 @@ Index: /usr/local/emhttp/plugins/dynamix/include/DefaultPageLayout.php
}
function closeNotifier() {
@@ -695,10 +704,11 @@
@@ -695,15 +704,16 @@
});
</script>
<?include "$docroot/plugins/dynamix.my.servers/include/myservers1.php"?>
@@ -64,7 +64,13 @@ Index: /usr/local/emhttp/plugins/dynamix/include/DefaultPageLayout.php
<div class="upgrade_notice" style="display:none"></div>
<div id="header" class="<?=$display['banner']?>">
<div class="logo">
<a href="https://unraid.net" target="_blank"><?readfile("$docroot/webGui/images/UN-logotype-gradient.svg")?></a>
- <a href="https://unraid.net" target="_blank"><?readfile("$docroot/webGui/images/UN-logotype-gradient.svg")?></a>
+
<unraid-i18n-host><unraid-header-os-version></unraid-header-os-version></unraid-i18n-host>
</div>
<?include "$docroot/plugins/dynamix.my.servers/include/myservers2.php"?>
</div>
<a href="#" class="move_to_end" title="<?=_('Move To End')?>"><i class="fa fa-arrow-circle-down"></i></a>
@@ -748,12 +758,12 @@
}
// create list of nchan scripts to be started

View File

@@ -1,25 +0,0 @@
Index: /etc/logrotate.d/unraid-api
===================================================================
--- /etc/logrotate.d/unraid-api original
+++ /etc/logrotate.d/unraid-api modified
@@ -0,0 +1,20 @@
+/var/log/unraid-api/*.log {
+ rotate 1
+ missingok
+ size 1M
+ su root root
+ compress
+ delaycompress
+ copytruncate
+ create 0640 root root
+}
+/var/log/graphql-api.log {
+ rotate 1
+ missingok
+ size 1M
+ su root root
+ compress
+ delaycompress
+ copytruncate
+ create 0640 root root
+}

View File

@@ -1,6 +1,6 @@
import { existsSync } from 'fs';
import { readFile } from 'fs/promises';
import { fileExists } from '@app/core/utils/files/file-exists.js';
import {
FileModification,
ShouldApplyWithReason,
@@ -25,7 +25,7 @@ export default class RcNginxModification extends FileModification {
* @returns The patch for the rc.nginx file
*/
protected async generatePatch(overridePath?: string): Promise<string> {
if (!existsSync(this.filePath)) {
if (!(await fileExists(this.filePath))) {
throw new Error(`File ${this.filePath} not found.`);
}
const fileContent = await readFile(this.filePath, 'utf8');

View File

@@ -1,7 +1,7 @@
{
"name": "unraid-monorepo",
"private": true,
"version": "4.9.3",
"version": "4.10.0",
"scripts": {
"build": "pnpm -r build",
"build:watch": " pnpm -r --parallel build:watch",
@@ -26,6 +26,7 @@
"@nestjs/core",
"@parcel/watcher",
"@swc/core",
"@tailwindcss/oxide",
"@unraid/libvirt",
"core-js",
"cpu-features",
@@ -33,10 +34,12 @@
"esbuild",
"nestjs-pino",
"protobufjs",
"sharp",
"simple-git-hooks",
"ssh2",
"unrs-resolver",
"vue-demi"
"vue-demi",
"workerd"
]
},
"dependencies": {
@@ -57,5 +60,5 @@
"pnpm lint:fix"
]
},
"packageManager": "pnpm@10.12.4"
"packageManager": "pnpm@10.13.1"
}

View File

@@ -25,10 +25,10 @@
"description": "Unraid Connect plugin for Unraid API",
"devDependencies": {
"@apollo/client": "3.13.8",
"@faker-js/faker": "9.8.0",
"@faker-js/faker": "9.9.0",
"@graphql-codegen/cli": "5.0.7",
"@graphql-typed-document-node/core": "3.2.0",
"@ianvs/prettier-plugin-sort-imports": "4.4.2",
"@ianvs/prettier-plugin-sort-imports": "4.5.1",
"@jsonforms/core": "3.6.0",
"@nestjs/apollo": "13.1.0",
"@nestjs/common": "11.1.3",
@@ -41,29 +41,29 @@
"@types/ini": "4.1.1",
"@types/ip": "1.1.3",
"@types/lodash-es": "4.17.12",
"@types/node": "22.15.32",
"@types/node": "22.16.4",
"@types/ws": "8.18.1",
"camelcase-keys": "9.1.3",
"class-transformer": "0.5.1",
"class-validator": "0.14.2",
"execa": "9.6.0",
"fast-check": "4.1.1",
"fast-check": "4.2.0",
"got": "14.4.7",
"graphql": "16.11.0",
"graphql-scalars": "1.24.2",
"graphql-subscriptions": "3.0.0",
"graphql-ws": "6.0.5",
"graphql-ws": "6.0.6",
"ini": "5.0.0",
"jose": "6.0.11",
"lodash-es": "4.17.21",
"nest-authz": "2.17.0",
"prettier": "3.5.3",
"prettier": "3.6.2",
"rimraf": "6.0.1",
"rxjs": "7.8.2",
"type-fest": "4.41.0",
"typescript": "5.8.3",
"vitest": "3.2.4",
"ws": "8.18.2",
"ws": "8.18.3",
"zen-observable-ts": "1.1.0"
},
"dependencies": {
@@ -91,13 +91,13 @@
"graphql": "16.11.0",
"graphql-scalars": "1.24.2",
"graphql-subscriptions": "3.0.0",
"graphql-ws": "6.0.5",
"graphql-ws": "6.0.6",
"ini": "5.0.0",
"jose": "6.0.11",
"lodash-es": "4.17.21",
"nest-authz": "2.17.0",
"rxjs": "7.8.2",
"ws": "^8.18.0",
"ws": "8.18.3",
"zen-observable-ts": "1.1.0"
}
}

View File

@@ -0,0 +1,269 @@
import { EventEmitter2 } from '@nestjs/event-emitter';
import { PubSub } from 'graphql-subscriptions';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { MinigraphStatus } from '../config/connect.config.js';
import { EVENTS, GRAPHQL_PUBSUB_CHANNEL } from '../helper/nest-tokens.js';
import { MothershipConnectionService } from '../mothership-proxy/connection.service.js';
import { MothershipController } from '../mothership-proxy/mothership.controller.js';
import { MothershipHandler } from '../mothership-proxy/mothership.events.js';
describe('MothershipHandler - Behavioral Tests', () => {
let handler: MothershipHandler;
let connectionService: MothershipConnectionService;
let mothershipController: MothershipController;
let pubSub: PubSub;
let eventEmitter: EventEmitter2;
// Track actual state changes and effects
let connectionAttempts: Array<{ timestamp: number; reason: string }> = [];
let publishedMessages: Array<{ channel: string; data: any }> = [];
let controllerStops: Array<{ timestamp: number; reason?: string }> = [];
beforeEach(() => {
// Reset tracking arrays
connectionAttempts = [];
publishedMessages = [];
controllerStops = [];
// Create real event emitter for integration testing
eventEmitter = new EventEmitter2();
// Mock connection service with realistic behavior
connectionService = {
getIdentityState: vi.fn(),
getConnectionState: vi.fn(),
} as any;
// Mock controller that tracks behavior instead of just method calls
mothershipController = {
initOrRestart: vi.fn().mockImplementation(() => {
connectionAttempts.push({
timestamp: Date.now(),
reason: 'initOrRestart called',
});
return Promise.resolve();
}),
stop: vi.fn().mockImplementation(() => {
controllerStops.push({
timestamp: Date.now(),
});
return Promise.resolve();
}),
} as any;
// Mock PubSub that tracks published messages
pubSub = {
publish: vi.fn().mockImplementation((channel: string, data: any) => {
publishedMessages.push({ channel, data });
return Promise.resolve();
}),
} as any;
handler = new MothershipHandler(connectionService, mothershipController, pubSub);
});
describe('Connection Recovery Behavior', () => {
it('should attempt reconnection when ping fails', async () => {
// Given: Connection is in ping failure state
vi.mocked(connectionService.getConnectionState).mockReturnValue({
status: MinigraphStatus.PING_FAILURE,
error: 'Ping timeout after 3 minutes',
});
// When: Connection status change event occurs
await handler.onMothershipConnectionStatusChanged();
// Then: System should attempt to recover the connection
expect(connectionAttempts).toHaveLength(1);
expect(connectionAttempts[0].reason).toBe('initOrRestart called');
});
it('should NOT interfere with exponential backoff during error retry state', async () => {
// Given: Connection is in error retry state (GraphQL client managing backoff)
vi.mocked(connectionService.getConnectionState).mockReturnValue({
status: MinigraphStatus.ERROR_RETRYING,
error: 'Network error',
timeout: 20000,
timeoutStart: Date.now(),
});
// When: Connection status change event occurs
await handler.onMothershipConnectionStatusChanged();
// Then: System should NOT interfere with ongoing retry logic
expect(connectionAttempts).toHaveLength(0);
});
it('should remain stable during normal connection states', async () => {
const stableStates = [MinigraphStatus.CONNECTED, MinigraphStatus.CONNECTING];
for (const status of stableStates) {
// Reset for each test
connectionAttempts.length = 0;
// Given: Connection is in a stable state
vi.mocked(connectionService.getConnectionState).mockReturnValue({
status,
error: null,
});
// When: Connection status change event occurs
await handler.onMothershipConnectionStatusChanged();
// Then: System should not trigger unnecessary reconnection attempts
expect(connectionAttempts).toHaveLength(0);
}
});
});
describe('Identity-Based Connection Behavior', () => {
it('should establish connection when valid API key becomes available', async () => {
// Given: Valid API key is present
vi.mocked(connectionService.getIdentityState).mockReturnValue({
state: {
apiKey: 'valid-unraid-key-12345',
unraidVersion: '6.12.0',
flashGuid: 'test-flash-guid',
apiVersion: '1.0.0',
},
isLoaded: true,
});
// When: Identity changes
await handler.onIdentityChanged();
// Then: System should establish mothership connection
expect(connectionAttempts).toHaveLength(1);
});
it('should not attempt connection without valid credentials', async () => {
const invalidCredentials = [{ apiKey: undefined }, { apiKey: '' }];
for (const credentials of invalidCredentials) {
// Reset for each test
connectionAttempts.length = 0;
// Given: Invalid or missing API key
vi.mocked(connectionService.getIdentityState).mockReturnValue({
state: credentials,
isLoaded: false,
});
// When: Identity changes
await handler.onIdentityChanged();
// Then: System should not attempt connection
expect(connectionAttempts).toHaveLength(0);
}
});
});
describe('Logout Behavior', () => {
it('should properly clean up connections and notify subscribers on logout', async () => {
// When: User logs out
await handler.logout({ reason: 'User initiated logout' });
// Then: System should clean up connections
expect(controllerStops).toHaveLength(1);
// And: Subscribers should be notified of empty state
expect(publishedMessages).toHaveLength(2);
const serversMessage = publishedMessages.find(
(m) => m.channel === GRAPHQL_PUBSUB_CHANNEL.SERVERS
);
const ownerMessage = publishedMessages.find(
(m) => m.channel === GRAPHQL_PUBSUB_CHANNEL.OWNER
);
expect(serversMessage?.data).toEqual({ servers: [] });
expect(ownerMessage?.data).toEqual({
owner: { username: 'root', url: '', avatar: '' },
});
});
it('should handle logout gracefully even without explicit reason', async () => {
// When: System logout occurs without reason
await handler.logout({});
// Then: Cleanup should still occur properly
expect(controllerStops).toHaveLength(1);
expect(publishedMessages).toHaveLength(2);
});
});
describe('DDoS Prevention Behavior', () => {
it('should demonstrate exponential backoff is respected during network errors', async () => {
// Given: Multiple rapid network errors occur
const errorStates = [
{ status: MinigraphStatus.ERROR_RETRYING, error: 'Network error 1' },
{ status: MinigraphStatus.ERROR_RETRYING, error: 'Network error 2' },
{ status: MinigraphStatus.ERROR_RETRYING, error: 'Network error 3' },
];
// When: Rapid error retry states occur
for (const state of errorStates) {
vi.mocked(connectionService.getConnectionState).mockReturnValue(state);
await handler.onMothershipConnectionStatusChanged();
}
// Then: No linear retry attempts should be made (respecting exponential backoff)
expect(connectionAttempts).toHaveLength(0);
});
it('should differentiate between network errors and ping failures', async () => {
// Given: Network error followed by ping failure
vi.mocked(connectionService.getConnectionState).mockReturnValue({
status: MinigraphStatus.ERROR_RETRYING,
error: 'Network error',
});
// When: Network error occurs
await handler.onMothershipConnectionStatusChanged();
// Then: No immediate reconnection attempt
expect(connectionAttempts).toHaveLength(0);
// Given: Ping failure occurs (different issue)
vi.mocked(connectionService.getConnectionState).mockReturnValue({
status: MinigraphStatus.PING_FAILURE,
error: 'Ping timeout',
});
// When: Ping failure occurs
await handler.onMothershipConnectionStatusChanged();
// Then: Immediate reconnection attempt should occur
expect(connectionAttempts).toHaveLength(1);
});
});
describe('Edge Cases and Error Handling', () => {
it('should handle missing connection state gracefully', async () => {
// Given: Connection service returns undefined
vi.mocked(connectionService.getConnectionState).mockReturnValue(undefined);
// When: Connection status change occurs
await handler.onMothershipConnectionStatusChanged();
// Then: No errors should occur, no reconnection attempts
expect(connectionAttempts).toHaveLength(0);
});
it('should handle malformed connection state', async () => {
// Given: Malformed connection state
vi.mocked(connectionService.getConnectionState).mockReturnValue({
status: 'UNKNOWN_STATUS' as any,
error: 'Malformed state',
});
// When: Connection status change occurs
await handler.onMothershipConnectionStatusChanged();
// Then: Should not trigger reconnection for unknown states
expect(connectionAttempts).toHaveLength(0);
});
});
});

View File

@@ -1,82 +1,48 @@
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { existsSync, readFileSync } from 'fs';
import { writeFile } from 'fs/promises';
import path from 'path';
import { ConfigFilePersister } from '@unraid/shared/services/config-file.js';
import { plainToInstance } from 'class-transformer';
import { validateOrReject } from 'class-validator';
import { parse as parseIni } from 'ini';
import { isEqual } from 'lodash-es';
import { bufferTime } from 'rxjs/operators';
import type { MyServersConfig as LegacyConfig } from './my-servers.config.js';
import { ConfigType, MyServersConfig } from './connect.config.js';
import { emptyMyServersConfig, MyServersConfig } from './connect.config.js';
@Injectable()
export class ConnectConfigPersister implements OnModuleInit, OnModuleDestroy {
constructor(private readonly configService: ConfigService<ConfigType, true>) {}
private logger = new Logger(ConnectConfigPersister.name);
get configPath() {
// PATHS_CONFIG_MODULES is a required environment variable.
// It is the directory where custom config files are stored.
return path.join(this.configService.getOrThrow('PATHS_CONFIG_MODULES'), 'connect.json');
}
async onModuleDestroy() {
await this.persist();
}
async onModuleInit() {
this.logger.verbose(`Config path: ${this.configPath}`);
await this.loadOrMigrateConfig();
// Persist changes to the config.
this.configService.changes$.pipe(bufferTime(25)).subscribe({
next: async (changes) => {
const connectConfigChanged = changes.some(({ path }) =>
path.startsWith('connect.config')
);
if (connectConfigChanged) {
await this.persist();
}
},
error: (err) => {
this.logger.error('Error receiving config changes:', err);
},
});
export class ConnectConfigPersister extends ConfigFilePersister<MyServersConfig> {
constructor(configService: ConfigService) {
super(configService);
}
/**
* Persist the config to disk if the given data is different from the data on-disk.
* This helps preserve the boot flash drive's life by avoiding unnecessary writes.
*
* @param config - The config object to persist.
* @returns `true` if the config was persisted, `false` otherwise.
* @override
* @returns The name of the config file.
*/
async persist(config = this.configService.get<MyServersConfig>('connect.config')) {
try {
if (isEqual(config, await this.loadConfig())) {
this.logger.verbose(`Config is unchanged, skipping persistence`);
return false;
}
} catch (error) {
this.logger.error(error, `Error loading config (will overwrite file)`);
}
const data = JSON.stringify(config, null, 2);
this.logger.verbose(`Persisting config to ${this.configPath}: ${data}`);
try {
await writeFile(this.configPath, data);
this.logger.verbose(`Config persisted to ${this.configPath}`);
return true;
} catch (error) {
this.logger.error(error, `Error persisting config to '${this.configPath}'`);
return false;
}
fileName(): string {
return 'connect.json';
}
/**
* @override
* @returns The key of the config in the config service.
*/
configKey(): string {
return 'connect.config';
}
/**
* @override
* @returns The default config object.
*/
defaultConfig(): MyServersConfig {
return emptyMyServersConfig();
}
/**
* Validate the config object.
* @override
* @param config - The config object to validate.
* @returns The validated config instance.
*/
@@ -89,49 +55,21 @@ export class ConnectConfigPersister implements OnModuleInit, OnModuleDestroy {
enableImplicitConversion: true,
});
}
await validateOrReject(instance);
await validateOrReject(instance, { whitelist: true });
return instance;
}
/**
* Load the config from the filesystem, or migrate the legacy config file to the new config format.
* When unable to load or migrate the config, messages are logged at WARN level, but no other action is taken.
* @returns true if the config was loaded successfully, false otherwise.
* @override
* @returns The migrated config object.
*/
private async loadOrMigrateConfig() {
try {
const config = await this.loadConfig();
this.configService.set('connect.config', config);
this.logger.verbose(`Config loaded from ${this.configPath}`);
return true;
} catch (error) {
this.logger.warn(error, 'Error loading config');
}
try {
await this.migrateLegacyConfig();
return this.persist();
} catch (error) {
this.logger.warn('Error migrating legacy config:', error);
}
this.logger.error(
'Failed to load or migrate config from filesystem. Config is not persisted. Using defaults in-memory.'
);
return false;
async migrateConfig(): Promise<MyServersConfig> {
return await this.migrateLegacyConfig();
}
/**
* Load the JSON config from the filesystem
* @throws {Error} - If the config file does not exist.
* @throws {Error} - If the config file is not parse-able.
* @throws {Error} - If the config file is not valid.
*/
private async loadConfig(configFilePath = this.configPath) {
if (!existsSync(configFilePath))
throw new Error(`Config file does not exist at '${configFilePath}'`);
return this.validate(JSON.parse(readFileSync(configFilePath, 'utf8')));
}
/**-----------------------------------------------------
* Helpers for migrating myservers.cfg to connect.json
*------------------------------------------------------**/
/**
* Migrate the legacy config file to the new config format.
@@ -143,8 +81,7 @@ export class ConnectConfigPersister implements OnModuleInit, OnModuleDestroy {
private async migrateLegacyConfig(filePath?: string) {
const myServersCfgFile = await this.readLegacyConfig(filePath);
const legacyConfig = this.parseLegacyConfig(myServersCfgFile);
const newConfig = await this.convertLegacyConfig(legacyConfig);
this.configService.set('connect.config', newConfig);
return await this.convertLegacyConfig(legacyConfig);
}
/**

View File

@@ -0,0 +1,158 @@
import { ConfigService } from '@nestjs/config';
import { access, constants, mkdir, readFile, rm } from 'fs/promises';
import { join } from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ConfigType } from '../config/connect.config.js';
import { ConnectStatusWriterService } from './connect-status-writer.service.js';
describe('ConnectStatusWriterService Config Behavior', () => {
let service: ConnectStatusWriterService;
let configService: ConfigService<ConfigType, true>;
const testDir = '/tmp/connect-status-config-test';
const testFilePath = join(testDir, 'connectStatus.json');
// Simulate config changes
let configStore: any = {};
beforeEach(async () => {
vi.clearAllMocks();
// Reset config store
configStore = {};
// Create test directory
await mkdir(testDir, { recursive: true });
// Create a ConfigService mock that behaves like the real one
configService = {
get: vi.fn().mockImplementation((key: string) => {
console.log(`ConfigService.get('${key}') called, returning:`, configStore[key]);
return configStore[key];
}),
set: vi.fn().mockImplementation((key: string, value: any) => {
console.log(`ConfigService.set('${key}', ${JSON.stringify(value)}) called`);
configStore[key] = value;
}),
} as unknown as ConfigService<ConfigType, true>;
service = new ConnectStatusWriterService(configService);
// Override the status file path to use our test location
Object.defineProperty(service, 'statusFilePath', {
get: () => testFilePath,
});
});
afterEach(async () => {
await service.onModuleDestroy();
await rm(testDir, { recursive: true, force: true });
});
it('should write status when config is updated directly', async () => {
// Initialize service - should write PRE_INIT
await service.onApplicationBootstrap();
await new Promise(resolve => setTimeout(resolve, 50));
let content = await readFile(testFilePath, 'utf-8');
let data = JSON.parse(content);
console.log('Initial status:', data);
expect(data.connectionStatus).toBe('PRE_INIT');
// Update config directly (simulating what ConnectionService does)
console.log('\n=== Updating config to CONNECTED ===');
configService.set('connect.mothership', {
status: 'CONNECTED',
error: null,
lastPing: Date.now(),
});
// Call the writeStatus method directly (since @OnEvent handles the event)
await service['writeStatus']();
content = await readFile(testFilePath, 'utf-8');
data = JSON.parse(content);
console.log('Status after config update:', data);
expect(data.connectionStatus).toBe('CONNECTED');
});
it('should test the actual flow with multiple status updates', async () => {
await service.onApplicationBootstrap();
await new Promise(resolve => setTimeout(resolve, 50));
const statusUpdates = [
{ status: 'CONNECTING', error: null, lastPing: null },
{ status: 'CONNECTED', error: null, lastPing: Date.now() },
{ status: 'DISCONNECTED', error: 'Lost connection', lastPing: Date.now() - 10000 },
{ status: 'RECONNECTING', error: null, lastPing: Date.now() - 10000 },
{ status: 'CONNECTED', error: null, lastPing: Date.now() },
];
for (const update of statusUpdates) {
console.log(`\n=== Updating to ${update.status} ===`);
// Update config
configService.set('connect.mothership', update);
// Call writeStatus directly
await service['writeStatus']();
const content = await readFile(testFilePath, 'utf-8');
const data = JSON.parse(content);
console.log(`Status file shows: ${data.connectionStatus}`);
expect(data.connectionStatus).toBe(update.status);
}
});
it('should handle case where config is not set before event', async () => {
await service.onApplicationBootstrap();
await new Promise(resolve => setTimeout(resolve, 50));
// Delete the config
delete configStore['connect.mothership'];
// Call writeStatus without config
console.log('\n=== Calling writeStatus with no config ===');
await service['writeStatus']();
const content = await readFile(testFilePath, 'utf-8');
const data = JSON.parse(content);
console.log('Status with no config:', data);
expect(data.connectionStatus).toBe('PRE_INIT');
// Now set config and call writeStatus again
console.log('\n=== Setting config and calling writeStatus ===');
configService.set('connect.mothership', {
status: 'CONNECTED',
error: null,
lastPing: Date.now(),
});
await service['writeStatus']();
const content2 = await readFile(testFilePath, 'utf-8');
const data2 = JSON.parse(content2);
console.log('Status after setting config:', data2);
expect(data2.connectionStatus).toBe('CONNECTED');
});
describe('cleanup on shutdown', () => {
it('should delete status file on module destroy', async () => {
await service.onApplicationBootstrap();
await new Promise(resolve => setTimeout(resolve, 50));
// Verify file exists
await expect(access(testFilePath, constants.F_OK)).resolves.not.toThrow();
// Cleanup
await service.onModuleDestroy();
// Verify file is deleted
await expect(access(testFilePath, constants.F_OK)).rejects.toThrow();
});
it('should handle cleanup when file does not exist', async () => {
// Don't bootstrap (so no file is written)
await expect(service.onModuleDestroy()).resolves.not.toThrow();
});
});
});

View File

@@ -0,0 +1,167 @@
import { ConfigService } from '@nestjs/config';
import { access, constants, mkdir, readFile, rm } from 'fs/promises';
import { join } from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ConfigType } from '../config/connect.config.js';
import { ConnectStatusWriterService } from './connect-status-writer.service.js';
describe('ConnectStatusWriterService Integration', () => {
let service: ConnectStatusWriterService;
let configService: ConfigService<ConfigType, true>;
const testDir = '/tmp/connect-status-test';
const testFilePath = join(testDir, 'connectStatus.json');
beforeEach(async () => {
vi.clearAllMocks();
// Create test directory
await mkdir(testDir, { recursive: true });
configService = {
get: vi.fn().mockImplementation((key: string) => {
console.log(`ConfigService.get called with key: ${key}`);
return {
status: 'CONNECTED',
error: null,
lastPing: Date.now(),
};
}),
} as unknown as ConfigService<ConfigType, true>;
service = new ConnectStatusWriterService(configService);
// Override the status file path to use our test location
Object.defineProperty(service, 'statusFilePath', {
get: () => testFilePath,
});
});
afterEach(async () => {
await service.onModuleDestroy();
await rm(testDir, { recursive: true, force: true });
});
it('should write initial PRE_INIT status, then update on event', async () => {
// First, mock the config to return undefined (no connection metadata)
vi.mocked(configService.get).mockReturnValue(undefined);
console.log('=== Starting onApplicationBootstrap ===');
await service.onApplicationBootstrap();
// Wait a bit for the initial write to complete
await new Promise(resolve => setTimeout(resolve, 50));
// Read initial status
const initialContent = await readFile(testFilePath, 'utf-8');
const initialData = JSON.parse(initialContent);
console.log('Initial status written:', initialData);
expect(initialData.connectionStatus).toBe('PRE_INIT');
expect(initialData.error).toBeNull();
expect(initialData.lastPing).toBeNull();
// Now update the mock to return CONNECTED status
vi.mocked(configService.get).mockReturnValue({
status: 'CONNECTED',
error: null,
lastPing: 1234567890,
});
console.log('=== Calling writeStatus directly ===');
await service['writeStatus']();
// Read updated status
const updatedContent = await readFile(testFilePath, 'utf-8');
const updatedData = JSON.parse(updatedContent);
console.log('Updated status after writeStatus:', updatedData);
expect(updatedData.connectionStatus).toBe('CONNECTED');
expect(updatedData.lastPing).toBe(1234567890);
});
it('should handle rapid status changes correctly', async () => {
const statusChanges = [
{ status: 'PRE_INIT', error: null, lastPing: null },
{ status: 'CONNECTING', error: null, lastPing: null },
{ status: 'CONNECTED', error: null, lastPing: Date.now() },
{ status: 'DISCONNECTED', error: 'Connection lost', lastPing: Date.now() - 5000 },
{ status: 'CONNECTED', error: null, lastPing: Date.now() },
];
let changeIndex = 0;
vi.mocked(configService.get).mockImplementation(() => {
const change = statusChanges[changeIndex];
console.log(`Returning status ${changeIndex}: ${change.status}`);
return change;
});
await service.onApplicationBootstrap();
await new Promise(resolve => setTimeout(resolve, 50));
// Simulate the final status change
changeIndex = statusChanges.length - 1;
console.log(`=== Calling writeStatus for final status: ${statusChanges[changeIndex].status} ===`);
await service['writeStatus']();
// Read final status
const finalContent = await readFile(testFilePath, 'utf-8');
const finalData = JSON.parse(finalContent);
console.log('Final status after status change:', finalData);
// Should have the last status
expect(finalData.connectionStatus).toBe('CONNECTED');
expect(finalData.error).toBeNull();
});
it('should handle multiple write calls correctly', async () => {
const writes: number[] = [];
const originalWriteStatus = service['writeStatus'].bind(service);
service['writeStatus'] = async function() {
const timestamp = Date.now();
writes.push(timestamp);
console.log(`writeStatus called at ${timestamp}`);
return originalWriteStatus();
};
await service.onApplicationBootstrap();
await new Promise(resolve => setTimeout(resolve, 50));
const initialWrites = writes.length;
console.log(`Initial writes: ${initialWrites}`);
// Make multiple write calls
for (let i = 0; i < 3; i++) {
console.log(`Calling writeStatus ${i}`);
await service['writeStatus']();
}
console.log(`Total writes: ${writes.length}`);
console.log('Write timestamps:', writes);
// Should have initial write + 3 additional writes
expect(writes.length).toBe(initialWrites + 3);
});
describe('cleanup on shutdown', () => {
it('should delete status file on module destroy', async () => {
await service.onApplicationBootstrap();
await new Promise(resolve => setTimeout(resolve, 50));
// Verify file exists
await expect(access(testFilePath, constants.F_OK)).resolves.not.toThrow();
// Cleanup
await service.onModuleDestroy();
// Verify file is deleted
await expect(access(testFilePath, constants.F_OK)).rejects.toThrow();
});
it('should handle cleanup gracefully when file does not exist', async () => {
// Don't bootstrap (so no file is created)
await expect(service.onModuleDestroy()).resolves.not.toThrow();
});
});
});

View File

@@ -0,0 +1,140 @@
import { ConfigService } from '@nestjs/config';
import { unlink, writeFile } from 'fs/promises';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ConfigType } from '../config/connect.config.js';
import { ConnectStatusWriterService } from './connect-status-writer.service.js';
vi.mock('fs/promises', () => ({
writeFile: vi.fn(),
unlink: vi.fn(),
}));
describe('ConnectStatusWriterService', () => {
let service: ConnectStatusWriterService;
let configService: ConfigService<ConfigType, true>;
let writeFileMock: ReturnType<typeof vi.fn>;
let unlinkMock: ReturnType<typeof vi.fn>;
beforeEach(async () => {
vi.clearAllMocks();
vi.useFakeTimers();
writeFileMock = vi.mocked(writeFile);
unlinkMock = vi.mocked(unlink);
configService = {
get: vi.fn().mockReturnValue({
status: 'CONNECTED',
error: null,
lastPing: Date.now(),
}),
} as unknown as ConfigService<ConfigType, true>;
service = new ConnectStatusWriterService(configService);
});
afterEach(async () => {
vi.useRealTimers();
});
describe('onApplicationBootstrap', () => {
it('should write initial status on bootstrap', async () => {
await service.onApplicationBootstrap();
expect(writeFileMock).toHaveBeenCalledTimes(1);
expect(writeFileMock).toHaveBeenCalledWith(
'/var/local/emhttp/connectStatus.json',
expect.stringContaining('CONNECTED')
);
});
it('should handle event-driven status changes', async () => {
await service.onApplicationBootstrap();
writeFileMock.mockClear();
// The service uses @OnEvent decorator, so we need to call the method directly
await service['writeStatus']();
expect(writeFileMock).toHaveBeenCalledTimes(1);
});
});
describe('write content', () => {
it('should write correct JSON structure with all fields', async () => {
const mockMetadata = {
status: 'CONNECTED',
error: 'Some error',
lastPing: 1234567890,
};
vi.mocked(configService.get).mockReturnValue(mockMetadata);
await service.onApplicationBootstrap();
const writeCall = writeFileMock.mock.calls[0];
const writtenData = JSON.parse(writeCall[1] as string);
expect(writtenData).toMatchObject({
connectionStatus: 'CONNECTED',
error: 'Some error',
lastPing: 1234567890,
allowedOrigins: '',
});
expect(writtenData.timestamp).toBeDefined();
expect(typeof writtenData.timestamp).toBe('number');
});
it('should handle missing connection metadata', async () => {
vi.mocked(configService.get).mockReturnValue(undefined);
await service.onApplicationBootstrap();
const writeCall = writeFileMock.mock.calls[0];
const writtenData = JSON.parse(writeCall[1] as string);
expect(writtenData).toMatchObject({
connectionStatus: 'PRE_INIT',
error: null,
lastPing: null,
allowedOrigins: '',
});
});
});
describe('error handling', () => {
it('should handle write errors gracefully', async () => {
writeFileMock.mockRejectedValue(new Error('Write failed'));
await expect(service.onApplicationBootstrap()).resolves.not.toThrow();
// Test direct write error handling
await expect(service['writeStatus']()).resolves.not.toThrow();
});
});
describe('cleanup on shutdown', () => {
it('should delete status file on module destroy', async () => {
await service.onModuleDestroy();
expect(unlinkMock).toHaveBeenCalledTimes(1);
expect(unlinkMock).toHaveBeenCalledWith('/var/local/emhttp/connectStatus.json');
});
it('should handle file deletion errors gracefully', async () => {
unlinkMock.mockRejectedValue(new Error('File not found'));
await expect(service.onModuleDestroy()).resolves.not.toThrow();
expect(unlinkMock).toHaveBeenCalledTimes(1);
});
it('should ensure file is deleted even if it was never written', async () => {
// Don't bootstrap (so no file is written)
await service.onModuleDestroy();
expect(unlinkMock).toHaveBeenCalledTimes(1);
expect(unlinkMock).toHaveBeenCalledWith('/var/local/emhttp/connectStatus.json');
});
});
});

View File

@@ -1,11 +1,14 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { Injectable, Logger, OnApplicationBootstrap, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { OnEvent } from '@nestjs/event-emitter';
import { unlink } from 'fs/promises';
import { writeFile } from 'fs/promises';
import { ConnectionMetadata, ConfigType } from './connect.config.js';
import { ConfigType, ConnectionMetadata } from '../config/connect.config.js';
import { EVENTS } from '../helper/nest-tokens.js';
@Injectable()
export class ConnectStatusWriterService implements OnModuleInit {
export class ConnectStatusWriterService implements OnApplicationBootstrap, OnModuleDestroy {
constructor(private readonly configService: ConfigService<ConfigType, true>) {}
private logger = new Logger(ConnectStatusWriterService.name);
@@ -15,30 +18,27 @@ export class ConnectStatusWriterService implements OnModuleInit {
return '/var/local/emhttp/connectStatus.json';
}
async onModuleInit() {
async onApplicationBootstrap() {
this.logger.verbose(`Status file path: ${this.statusFilePath}`);
// Write initial status
await this.writeStatus();
// Listen for changes to connection status
this.configService.changes$.subscribe({
next: async (change) => {
const connectionChanged = change.path && change.path.startsWith('connect.mothership');
if (connectionChanged) {
await this.writeStatus();
}
},
error: (err) => {
this.logger.error('Error receiving config changes:', err);
},
});
}
async onModuleDestroy() {
try {
await unlink(this.statusFilePath);
this.logger.verbose(`Status file deleted: ${this.statusFilePath}`);
} catch (error) {
this.logger.debug(`Could not delete status file: ${error}`);
}
}
@OnEvent(EVENTS.MOTHERSHIP_CONNECTION_STATUS_CHANGED, { async: true })
private async writeStatus() {
try {
const connectionMetadata = this.configService.get<ConnectionMetadata>('connect.mothership');
// Try to get allowed origins from the store
let allowedOrigins = '';
try {
@@ -48,22 +48,22 @@ export class ConnectStatusWriterService implements OnModuleInit {
} catch (error) {
this.logger.debug('Could not get allowed origins:', error);
}
const statusData = {
connectionStatus: connectionMetadata?.status || 'PRE_INIT',
error: connectionMetadata?.error || null,
lastPing: connectionMetadata?.lastPing || null,
allowedOrigins: allowedOrigins,
timestamp: Date.now()
timestamp: Date.now(),
};
const data = JSON.stringify(statusData, null, 2);
this.logger.verbose(`Writing connection status: ${data}`);
await writeFile(this.statusFilePath, data);
this.logger.verbose(`Status written to ${this.statusFilePath}`);
} catch (error) {
this.logger.error(error, `Error writing status to '${this.statusFilePath}'`);
}
}
}
}

View File

@@ -3,7 +3,6 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
import { ConnectConfigPersister } from './config/config.persistence.js';
import { configFeature } from './config/connect.config.js';
import { ConnectStatusWriterService } from './config/connect-status-writer.service.js';
import { MothershipModule } from './mothership-proxy/mothership.module.js';
import { ConnectModule } from './unraid-connect/connect.module.js';
@@ -11,7 +10,7 @@ export const adapter = 'nestjs';
@Module({
imports: [ConfigModule.forFeature(configFeature), ConnectModule, MothershipModule],
providers: [ConnectConfigPersister, ConnectStatusWriterService],
providers: [ConnectConfigPersister],
exports: [],
})
class ConnectPluginModule {

View File

@@ -130,11 +130,19 @@ export class MothershipConnectionService implements OnModuleInit, OnModuleDestro
}
async onModuleInit() {
// Crash on startup if these config values are not set initially
// Warn on startup if these config values are not set initially
const { unraidVersion, flashGuid, apiVersion } = this.configKeys;
const warnings: string[] = [];
[unraidVersion, flashGuid, apiVersion].forEach((key) => {
this.configService.getOrThrow(key);
try {
this.configService.getOrThrow(key);
} catch (error) {
warnings.push(`${key} is not set`);
}
});
if (warnings.length > 0) {
this.logger.warn('Missing config values: %s', warnings.join(', '));
}
// Setup IDENTITY_CHANGED & METADATA_CHANGED events
this.setupIdentitySubscription();
this.setupMetadataChangedEvent();

View File

@@ -32,7 +32,7 @@ export class MothershipHandler {
const state = this.connectionService.getConnectionState();
if (
state &&
[MinigraphStatus.PING_FAILURE, MinigraphStatus.ERROR_RETRYING].includes(state.status)
[MinigraphStatus.PING_FAILURE].includes(state.status)
) {
this.logger.verbose(
'Mothership connection status changed to %s; setting up mothership subscription',

View File

@@ -3,18 +3,20 @@ import { Module } from '@nestjs/common';
import { ConnectApiKeyService } from '../authn/connect-api-key.service.js';
import { CloudResolver } from '../connection-status/cloud.resolver.js';
import { CloudService } from '../connection-status/cloud.service.js';
import { ConnectStatusWriterService } from '../connection-status/connect-status-writer.service.js';
import { TimeoutCheckerJob } from '../connection-status/timeout-checker.job.js';
import { InternalClientService } from '../internal-rpc/internal.client.js';
import { RemoteAccessModule } from '../remote-access/remote-access.module.js';
import { MothershipConnectionService } from './connection.service.js';
import { MothershipGraphqlClientService } from './graphql.client.js';
import { MothershipSubscriptionHandler } from './mothership-subscription.handler.js';
import { MothershipHandler } from './mothership.events.js';
import { MothershipController } from './mothership.controller.js';
import { MothershipHandler } from './mothership.events.js';
@Module({
imports: [RemoteAccessModule],
providers: [
ConnectStatusWriterService,
ConnectApiKeyService,
MothershipConnectionService,
MothershipGraphqlClientService,

View File

@@ -15,7 +15,7 @@
"commander": "14.0.0",
"create-create-app": "7.3.0",
"fs-extra": "11.3.0",
"inquirer": "12.6.3",
"inquirer": "12.7.0",
"validate-npm-package-name": "6.0.1"
},
"devDependencies": {
@@ -25,7 +25,7 @@
"@nestjs/graphql": "13.1.0",
"@types/fs-extra": "11.0.4",
"@types/inquirer": "9.0.8",
"@types/node": "22.15.32",
"@types/node": "22.16.4",
"@types/validate-npm-package-name": "4.0.2",
"class-transformer": "0.5.1",
"class-validator": "0.14.2",

View File

@@ -1,81 +1,25 @@
import { Logger, Injectable, OnModuleInit } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { existsSync, readFileSync } from "fs";
import { writeFile } from "fs/promises";
import path from "path";
import { bufferTime } from "rxjs/operators";
import { Injectable } from "@nestjs/common";
import { ConfigFilePersister } from "@unraid/shared/services/config-file.js"; // npm install @unraid/shared
import { PluginNameConfig } from "./config.entity.js";
import { ConfigService } from "@nestjs/config";
@Injectable()
export class PluginNameConfigPersister implements OnModuleInit {
constructor(private readonly configService: ConfigService) {}
private logger = new Logger(PluginNameConfigPersister.name);
/** the file path to the config file for this plugin */
get configPath() {
return path.join(
this.configService.get("PATHS_CONFIG_MODULES")!,
"plugin-name.json" // Use kebab-case for the filename
);
export class PluginNameConfigPersister extends ConfigFilePersister<PluginNameConfig> {
constructor(configService: ConfigService) {
super(configService);
}
onModuleInit() {
this.logger.debug(`Config path: ${this.configPath}`);
// Load the config from the file if it exists, otherwise initialize it with defaults.
if (existsSync(this.configPath)) {
try {
const configFromFile = JSON.parse(
readFileSync(this.configPath, "utf8")
);
this.configService.set("plugin-name", configFromFile);
this.logger.verbose(`Config loaded from ${this.configPath}`);
} catch (error) {
this.logger.error(
`Error reading or parsing config file at ${this.configPath}. Using defaults.`,
error
);
// If loading fails, ensure default config is set and persisted
this.persist();
}
} else {
this.logger.log(
`Config file ${this.configPath} does not exist. Writing default config...`
);
// Persist the default configuration provided by configFeature
this.persist();
}
// Automatically persist changes to the config file after a short delay.
this.configService.changes$.pipe(bufferTime(25)).subscribe({
next: async (changes) => {
const pluginNameConfigChanged = changes.some(({ path }) =>
path.startsWith("plugin-name.")
);
if (pluginNameConfigChanged) {
this.logger.verbose("Plugin config changed");
await this.persist();
}
},
error: (err) => {
this.logger.error("Error receiving config changes:", err);
},
});
fileName(): string {
return "plugin-name.json"; // Use kebab-case for the filename
}
async persist(
config = this.configService.get<PluginNameConfig>("plugin-name")
) {
const data = JSON.stringify(config, null, 2);
this.logger.verbose(`Persisting config to ${this.configPath}: ${data}`);
try {
await writeFile(this.configPath, data);
this.logger.verbose(`Config change persisted to ${this.configPath}`);
} catch (error) {
this.logger.error(
`Error persisting config to '${this.configPath}':`,
error
);
}
configKey(): string {
return "plugin-name";
}
defaultConfig(): PluginNameConfig {
// Return the default configuration for your plugin
// This should match the structure defined in your config.entity.ts
return {} as PluginNameConfig;
}
}

View File

@@ -0,0 +1,9 @@
# Justfile for unraid-shared
# Default recipe to run when just is called without arguments
default:
@just --list
# Watch for changes in src files and run clean + build
watch:
watchexec -r -e ts,tsx -w src -- pnpm build

View File

@@ -31,9 +31,9 @@
"@jsonforms/core": "3.6.0",
"@nestjs/common": "11.1.3",
"@nestjs/graphql": "13.1.0",
"@types/bun": "1.2.16",
"@types/bun": "1.2.18",
"@types/lodash-es": "4.17.12",
"@types/node": "22.15.32",
"@types/node": "22.16.4",
"class-validator": "0.14.2",
"graphql": "16.11.0",
"graphql-scalars": "1.24.2",
@@ -47,11 +47,13 @@
"@graphql-tools/utils": "10.8.6",
"@jsonforms/core": "3.6.0",
"@nestjs/common": "11.1.3",
"@nestjs/config": "4.0.2",
"@nestjs/graphql": "13.1.0",
"class-validator": "0.14.2",
"graphql": "16.11.0",
"graphql-scalars": "1.24.2",
"lodash-es": "4.17.21",
"nest-authz": "2.17.0"
"nest-authz": "2.17.0",
"rxjs": "7.8.2"
}
}

View File

@@ -0,0 +1,495 @@
import { expect, test, describe, beforeEach, afterEach } from "bun:test";
import { Subject } from "rxjs";
import { readFile, writeFile, mkdir, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { ConfigFilePersister } from "../config-file.js";
/**
* TEST SCOPE: ConfigFilePersister NestJS Integration
*
* BEHAVIORS TESTED:
* • NestJS lifecycle integration (OnModuleInit, OnModuleDestroy)
* • Reactive config change subscription with 25ms buffering
* • ConfigService integration for path resolution and config storage
* • Automatic config loading with migration priority over defaults
* • Config change detection and selective persistence (matching configKey only)
* • Graceful error handling for all failure scenarios
* • Flash drive optimization through change detection
* • Standalone file access via getFileHandler() delegation
* • Proper cleanup of subscriptions and final state persistence
*
* INTEGRATION SCENARIOS:
* ✓ Module initialization with existing/missing/invalid config files
* ✓ Reactive config change processing with proper filtering
* ✓ Module destruction with subscription cleanup and final persistence
* ✓ Error resilience (file system errors, validation failures, service errors)
* ✓ Migration vs defaults priority during initialization
* ✓ Full application lifecycle from startup to shutdown
*
* COVERAGE FOCUS:
* • NestJS framework integration correctness
* • Reactive configuration management
* • Production-like error scenarios
* • Memory leak prevention (subscription management)
* • Data persistence guarantees during shutdown
*
* NOT TESTED (covered in other files):
* • Low-level file operations (ConfigFileHandler)
* • Abstract class behavior (ConfigDefinition)
*/
interface TestConfig {
name: string;
version: number;
enabled: boolean;
settings: {
timeout: number;
retries: number;
};
}
class TestConfigFilePersister extends ConfigFilePersister<TestConfig> {
constructor(configService: any) {
super(configService);
}
fileName(): string {
return "test-config.json";
}
configKey(): string {
return "testConfig";
}
defaultConfig(): TestConfig {
return {
name: "test",
version: 1,
enabled: false,
settings: {
timeout: 5000,
retries: 3,
},
};
}
async validate(config: object): Promise<TestConfig> {
const testConfig = config as TestConfig;
if (testConfig.version < 1) {
throw new Error("Invalid version: must be >= 1");
}
if (testConfig.settings.timeout < 1000) {
throw new Error("Invalid timeout: must be >= 1000");
}
return testConfig;
}
async migrateConfig(): Promise<TestConfig> {
return {
name: "migrated",
version: 2,
enabled: true,
settings: {
timeout: 3000,
retries: 5,
},
};
}
}
describe("ConfigFilePersister Integration Tests", () => {
let configService: any;
let persister: TestConfigFilePersister;
let testDir: string;
let configPath: string;
let changesSubject: Subject<any>;
let configStore: Record<string, any>;
beforeEach(async () => {
// Setup test directory
testDir = join(tmpdir(), `config-test-${Date.now()}`);
await mkdir(testDir, { recursive: true });
configPath = join(testDir, "test-config.json");
// Setup config store
configStore = {};
// Setup rxjs subject for config changes
changesSubject = new Subject();
// Mock ConfigService
configService = {
get: (key: string) => configStore[key],
set: (key: string, value: any) => {
configStore[key] = value;
},
getOrThrow: (key: string) => {
if (key === "PATHS_CONFIG_MODULES") return testDir;
throw new Error(`Config key ${key} not found`);
},
changes$: changesSubject.asObservable(),
};
persister = new TestConfigFilePersister(configService);
});
afterEach(async () => {
// Proper cleanup
changesSubject.complete();
await persister.onModuleDestroy?.();
await rm(testDir, { recursive: true, force: true });
});
test("configPath returns correct path", () => {
expect(persister.configPath()).toBe(configPath);
});
test("loads existing config from file", async () => {
const existingConfig = {
name: "existing",
version: 2,
enabled: true,
settings: {
timeout: 3000,
retries: 5,
},
};
await writeFile(configPath, JSON.stringify(existingConfig, null, 2));
await persister.onModuleInit();
// Should load existing config
expect(configStore.testConfig).toEqual(existingConfig);
});
test("handles invalid config by attempting migration", async () => {
const invalidConfig = {
name: "invalid",
version: 0, // Invalid version
enabled: true,
settings: {
timeout: 500, // Invalid timeout
retries: 5,
},
};
await writeFile(configPath, JSON.stringify(invalidConfig, null, 2));
await persister.onModuleInit();
// Should call migrate and set migrated config
expect(configStore.testConfig).toEqual({
name: "migrated",
version: 2,
enabled: true,
settings: {
timeout: 3000,
retries: 5,
},
});
});
test("persists config to file", async () => {
const config = {
name: "persist-test",
version: 2,
enabled: true,
settings: {
timeout: 4000,
retries: 4,
},
};
const result = await persister.persist(config);
expect(result).toBe(true);
const fileContent = await readFile(configPath, "utf8");
const parsedConfig = JSON.parse(fileContent);
expect(parsedConfig).toEqual(config);
});
test("skips persistence when config is unchanged", async () => {
const config = {
name: "unchanged",
version: 1,
enabled: false,
settings: {
timeout: 5000,
retries: 3,
},
};
// Write initial config
await writeFile(configPath, JSON.stringify(config, null, 2));
const result = await persister.persist(config);
expect(result).toBe(false);
});
test("loads and validates config from file", async () => {
const config = {
name: "file-test",
version: 3,
enabled: true,
settings: {
timeout: 2000,
retries: 1,
},
};
await writeFile(configPath, JSON.stringify(config));
const result = await persister.getFileHandler().readConfigFile();
expect(result).toEqual(config);
});
test("throws error when file doesn't exist", async () => {
await expect(persister.getFileHandler().readConfigFile()).rejects.toThrow(
"Config file does not exist"
);
});
test("throws error when file contains invalid JSON", async () => {
await writeFile(configPath, "{ invalid json");
await expect(persister.getFileHandler().readConfigFile()).rejects.toThrow();
});
test("throws error when config is invalid", async () => {
const invalidConfig = {
name: "invalid",
version: -1,
enabled: true,
settings: {
timeout: 100,
retries: 1,
},
};
await writeFile(configPath, JSON.stringify(invalidConfig));
await expect(persister.getFileHandler().readConfigFile()).rejects.toThrow(
"Invalid version"
);
});
test("base class migration throws not implemented error", async () => {
const basePersister = new (class extends ConfigFilePersister<TestConfig> {
fileName() {
return "base-test.json";
}
configKey() {
return "baseTest";
}
defaultConfig() {
return persister.defaultConfig();
}
})(configService);
await expect(basePersister.migrateConfig()).rejects.toThrow(
"Not implemented"
);
});
test("unsubscribes from config changes and persists final state", async () => {
await persister.onModuleInit();
// Setup final config state
configStore["testConfig"] = {
name: "final",
version: 4,
enabled: false,
settings: {
timeout: 1000,
retries: 10,
},
};
await persister.onModuleDestroy();
// Should persist final state
const fileContent = await readFile(configPath, "utf8");
const parsedConfig = JSON.parse(fileContent);
expect(parsedConfig.name).toBe("final");
});
test("handles destroy when not initialized", async () => {
// Should not throw error
await expect(persister.onModuleDestroy()).resolves.toBeUndefined();
});
test("config change subscription is properly set up", async () => {
// Pre-create config file to avoid migration
const initialConfig = persister.defaultConfig();
await writeFile(configPath, JSON.stringify(initialConfig, null, 2));
await persister.onModuleInit();
// Verify that the config observer is active by checking internal state
// This tests that the subscription was created without relying on timing
expect((persister as any).configObserver).toBeDefined();
expect((persister as any).configObserver.closed).toBe(false);
// Test that non-matching changes are ignored (synchronous test)
configStore["testConfig"] = persister.defaultConfig();
const initialFileContent = await readFile(configPath, "utf8");
// Emit a non-matching config change
changesSubject.next([{ path: "otherConfig.setting" }]);
// Wait briefly to ensure no processing occurs
await new Promise((resolve) => setTimeout(resolve, 30));
// File should remain unchanged
const afterFileContent = await readFile(configPath, "utf8");
expect(afterFileContent).toBe(initialFileContent);
});
test("ignores non-matching config changes", async () => {
// Pre-create config file
const initialConfig = persister.defaultConfig();
await writeFile(configPath, JSON.stringify(initialConfig, null, 2));
await persister.onModuleInit();
// Set initial config and write to file
configStore["testConfig"] = persister.defaultConfig();
// Get initial modification time
const stats1 = await import("fs/promises").then((fs) =>
fs.stat(configPath)
);
// Wait a bit to ensure timestamp difference
await new Promise((resolve) => setTimeout(resolve, 10));
// Emit change for different config key
changesSubject.next([{ path: "otherConfig.setting" }]);
// Wait for buffer time
await new Promise((resolve) => setTimeout(resolve, 50));
// File should remain unchanged (same modification time)
const stats2 = await import("fs/promises").then((fs) =>
fs.stat(configPath)
);
expect(stats2.mtime).toEqual(stats1.mtime);
});
test("handles config service errors gracefully", async () => {
// Mock config service to throw error on get
const errorConfigService = {
...configService,
get: () => {
throw new Error("Config service error");
},
};
const errorPersister = new TestConfigFilePersister(errorConfigService);
// Should still initialize (migration will be called due to no file)
await errorPersister.onModuleInit();
// Should have migrated config since get failed
const expectedMigrated = await errorPersister.migrateConfig();
expect(configStore.testConfig).toEqual(expectedMigrated);
});
test("handles persistence errors gracefully", async () => {
await persister.onModuleInit();
// Create a persister that points to invalid directory
const invalidPersister = new TestConfigFilePersister({
...configService,
getOrThrow: (key: string) => {
if (key === "PATHS_CONFIG_MODULES")
return "/invalid/path/that/does/not/exist";
throw new Error(`Config key ${key} not found`);
},
});
const config = { ...persister.defaultConfig(), name: "error-test" };
// Should not throw despite write error
const result = await invalidPersister.persist(config);
expect(result).toBe(false);
});
test("migration priority over defaults when file doesn't exist", async () => {
// No file exists, should trigger migration path
await persister.onModuleInit();
// ConfigFilePersister prioritizes migration over defaults when file doesn't exist
expect(configStore.testConfig).toEqual({
name: "migrated",
version: 2,
enabled: true,
settings: {
timeout: 3000,
retries: 5,
},
});
// Should persist migrated config to file
const fileContent = await readFile(configPath, "utf8");
const parsedConfig = JSON.parse(fileContent);
expect(parsedConfig).toEqual({
name: "migrated",
version: 2,
enabled: true,
settings: {
timeout: 3000,
retries: 5,
},
});
});
test("full lifecycle integration", async () => {
// Initialize - will use migration since no file exists
await persister.onModuleInit();
// Verify initial state (migrated, not defaults)
expect(configStore.testConfig).toEqual({
name: "migrated",
version: 2,
enabled: true,
settings: {
timeout: 3000,
retries: 5,
},
});
// Simulate config change
configStore["testConfig"] = {
name: "lifecycle-test",
version: 5,
enabled: true,
settings: {
timeout: 1500,
retries: 7,
},
};
// Trigger change notification
changesSubject.next([{ path: "testConfig.enabled" }]);
// Wait for persistence
await new Promise((resolve) => setTimeout(resolve, 50));
// Cleanup
await persister.onModuleDestroy();
// Verify final persisted state
const fileContent = await readFile(configPath, "utf8");
const parsedConfig = JSON.parse(fileContent);
expect(parsedConfig).toEqual({
name: "lifecycle-test",
version: 5,
enabled: true,
settings: {
timeout: 1500,
retries: 7,
},
});
});
});

View File

@@ -0,0 +1,147 @@
import {
Logger,
type OnModuleDestroy,
type OnModuleInit,
} from "@nestjs/common";
import type { ConfigService } from "@nestjs/config";
import path from "node:path";
import { bufferTime } from "rxjs/operators";
import type { Subscription } from "rxjs";
import { ConfigFileHandler } from "../util/config-file-handler.js";
import { ConfigDefinition } from "../util/config-definition.js";
/**
* Abstract base class for persisting configuration objects to JSON files.
*
* Provides NestJS integration with reactive config updates, standalone file operations,
* and lifecycle management with automatic persistence.
*
* @template T The configuration object type that extends object
*
* @example
* ```typescript
* @Injectable()
* class MyConfigPersister extends ConfigFilePersister<MyConfig> {
* constructor(configService: ConfigService) {
* super(configService);
* }
*
* fileName() { return "my-config.json"; }
* configKey() { return "myConfig"; }
* defaultConfig(): MyConfig {
* return { enabled: false, timeout: 5000 };
* }
* }
* ```
*/
export abstract class ConfigFilePersister<T extends object>
extends ConfigDefinition<T>
implements OnModuleInit, OnModuleDestroy
{
private configObserver?: Subscription;
private fileHandler: ConfigFileHandler<T>;
/**
* Creates a new ConfigFilePersister instance.
*
* @param configService The NestJS ConfigService instance for reactive config management
*/
constructor(protected readonly configService: ConfigService) {
super();
this.logger = new Logger(`ConfigFilePersister:${this.fileName()}`);
this.fileHandler = new ConfigFileHandler(this);
}
/**
* Returns the configuration key used in the ConfigService.
*
* This key is used to:
* - Store/retrieve config from the ConfigService
* - Filter config change events to only process relevant changes
* - Namespace configuration to avoid conflicts
*
* @returns The config key string (e.g., "userPreferences", "apiSettings")
* @example "myModuleConfig"
*/
abstract configKey(): string;
/**
* Returns the absolute path to the configuration file.
* Combines `PATHS_CONFIG_MODULES` environment variable with the filename.
*
* @throws Error if `PATHS_CONFIG_MODULES` environment variable is not set
*/
configPath(): string {
return path.join(
this.configService.getOrThrow("PATHS_CONFIG_MODULES"),
this.fileName()
);
}
/**
* Returns a standalone ConfigFileHandler for direct file operations outside NestJS.
*/
getFileHandler(): ConfigFileHandler<T> {
return this.fileHandler;
}
/**
* NestJS lifecycle hook for cleanup.
* Unsubscribes from config changes and persists final state.
*/
async onModuleDestroy() {
this.configObserver?.unsubscribe();
await this.persist();
}
/**
* NestJS lifecycle hook for initialization.
* Loads config from disk and sets up reactive change subscription.
*/
async onModuleInit() {
this.logger.verbose(`Config path: ${this.configPath()}`);
await this.loadOrMigrateConfig();
this.configObserver = this.configService.changes$
.pipe(bufferTime(25))
.subscribe({
next: async (changes) => {
const configChanged = changes.some(({ path }) =>
path?.startsWith(this.configKey())
);
if (configChanged) {
await this.persist();
}
},
error: (err) => {
this.logger.error("Error receiving config changes:", err);
},
});
}
/**
* Persists configuration to disk with change detection optimization.
*
* @param config - The config object to persist (defaults to current config from service)
* @returns `true` if persisted to disk, `false` if skipped or failed
*/
async persist(
config = this.configService.get(this.configKey())
): Promise<boolean> {
if (!config) {
this.logger.warn(`Cannot persist undefined config`);
return false;
}
return await this.fileHandler.writeConfigFile(config);
}
/**
* Load or migrate configuration and set it in ConfigService.
*/
private async loadOrMigrateConfig() {
const config = await this.fileHandler.loadConfig();
this.configService.set(this.configKey(), config);
return this.persist(config);
}
}

View File

@@ -0,0 +1,192 @@
import { expect, test, describe, beforeEach } from "bun:test";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { ConfigDefinition } from "../config-definition.js";
/**
* TEST SCOPE: ConfigDefinition Abstract Base Class
*
* BEHAVIORS TESTED:
* • Core abstract method implementations (fileName, configPath, defaultConfig)
* • Default validation behavior (passthrough without transformation)
* • Custom validation with data transformation and error throwing
* • Default migration behavior (throws "Not implemented" error)
* • Custom migration implementation with success and failure scenarios
* • Error propagation from validation and migration methods
*
* COVERAGE FOCUS:
* • Abstract class contract enforcement
* • Extension point behavior (validate, migrate methods)
* • Error handling patterns for implementors
* • Type safety and configuration structure validation
*
* NOT TESTED (covered in other files):
* • File I/O operations (ConfigFileHandler)
* • NestJS integration (ConfigFilePersister)
* • Reactive config changes
*/
interface TestConfig {
name: string;
version: number;
enabled: boolean;
timeout: number;
}
class TestConfigDefinition extends ConfigDefinition<TestConfig> {
constructor(private configDir: string, loggerName?: string) {
super(loggerName);
}
fileName(): string {
return "test-config.json";
}
configPath(): string {
return join(this.configDir, this.fileName());
}
defaultConfig(): TestConfig {
return {
name: "test",
version: 1,
enabled: false,
timeout: 5000,
};
}
}
class ValidatingConfigDefinition extends TestConfigDefinition {
async validate(config: object): Promise<TestConfig> {
const testConfig = config as TestConfig;
if (typeof testConfig.name !== "string" || testConfig.name.length === 0) {
throw new Error("Name must be a non-empty string");
}
if (typeof testConfig.version !== "number" || testConfig.version < 1) {
throw new Error("Version must be a number >= 1");
}
if (typeof testConfig.timeout !== "number" || testConfig.timeout < 1000) {
throw new Error("Timeout must be a number >= 1000");
}
// Test data transformation
return {
...testConfig,
name: testConfig.name.trim(),
timeout: Math.max(testConfig.timeout, 1000),
};
}
}
class MigratingConfigDefinition extends TestConfigDefinition {
public migrationShouldFail = false;
public migrationCallCount = 0;
async migrateConfig(): Promise<TestConfig> {
this.migrationCallCount++;
if (this.migrationShouldFail) {
throw new Error("Migration failed");
}
return {
name: "migrated",
version: 2,
enabled: true,
timeout: 3000,
};
}
}
describe("ConfigDefinition", () => {
let testDir: string;
let configDefinition: TestConfigDefinition;
beforeEach(() => {
testDir = join(tmpdir(), `config-def-test-${Date.now()}`);
configDefinition = new TestConfigDefinition(testDir);
});
describe("Core Functionality", () => {
test("abstract methods are implemented correctly", () => {
expect(configDefinition.fileName()).toBe("test-config.json");
expect(configDefinition.configPath()).toBe(
join(testDir, "test-config.json")
);
expect(configDefinition.defaultConfig()).toEqual({
name: "test",
version: 1,
enabled: false,
timeout: 5000,
});
});
test("default validation is passthrough", async () => {
const config = { name: "test", version: 2, enabled: true, timeout: 3000 };
const result = await configDefinition.validate(config);
expect(result).toEqual(config);
});
});
describe("Validation Behavior", () => {
test("validation can transform and validate config", async () => {
const validatingDefinition = new ValidatingConfigDefinition(testDir);
const config = {
name: " test-name ", // Should be trimmed
version: 2,
enabled: true,
timeout: 1500,
};
const result = await validatingDefinition.validate(config);
expect(result.name).toBe("test-name"); // Trimmed
expect(result.timeout).toBe(1500);
});
test("validation errors are thrown for invalid configs", async () => {
const validatingDefinition = new ValidatingConfigDefinition(testDir);
const invalidConfig = {
name: "",
version: 0,
enabled: false,
timeout: 500,
};
await expect(
validatingDefinition.validate(invalidConfig)
).rejects.toThrow();
});
});
describe("Migration Behavior", () => {
test("default migration throws not implemented error", async () => {
await expect(configDefinition.migrateConfig()).rejects.toThrow(
"Not implemented"
);
});
test("custom migration works when implemented", async () => {
const migratingDefinition = new MigratingConfigDefinition(testDir);
const result = await migratingDefinition.migrateConfig();
expect(result).toEqual({
name: "migrated",
version: 2,
enabled: true,
timeout: 3000,
});
});
test("migration failures are propagated as errors", async () => {
const migratingDefinition = new MigratingConfigDefinition(testDir);
migratingDefinition.migrationShouldFail = true;
await expect(migratingDefinition.migrateConfig()).rejects.toThrow(
"Migration failed"
);
});
});
});

View File

@@ -0,0 +1,466 @@
import { expect, test, describe, beforeEach, afterEach } from "bun:test";
import { readFile, writeFile, mkdir, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { ConfigFileHandler } from "../config-file-handler.js";
import { ConfigDefinition } from "../config-definition.js";
/**
* TEST SCOPE: ConfigFileHandler Standalone File Operations
*
* BEHAVIORS TESTED:
* • Configuration loading with error recovery cascade:
* - File exists & valid → load directly
* - File read fails → attempt migration → fallback to defaults
* - File valid but merged config fails validation → attempt migration
* - Migration succeeds but merged result fails validation → fallback to defaults
* - Migration fails → fallback to defaults
* • File I/O operations (read, write) with validation
* • Flash drive optimization (skip writes when config unchanged)
* • Partial config updates with deep merging
* • Error resilience (invalid JSON, validation failures, file system errors)
* • End-to-end workflows (load → update → reload cycles)
*
* CRITICAL ERROR RECOVERY PATHS:
* ✓ read failed → migration failed → defaults written
* ✓ read failed → migration succeeded but combo validation failed → defaults written
* ✓ read succeeded but merged validation failed → migration → recovery
*
* COVERAGE FOCUS:
* • Data integrity during all error scenarios
* • Performance optimization (change detection)
* • Configuration persistence reliability
* • Validation error handling at all stages
*
* NOT TESTED (covered in other files):
* • NestJS integration and reactive changes (ConfigFilePersister)
* • Abstract class behavior (ConfigDefinition)
*/
interface TestConfig {
name: string;
version: number;
enabled: boolean;
timeout: number;
maxRetries?: number; // Optional field for testing merge validation
}
class TestConfigDefinition extends ConfigDefinition<TestConfig> {
public migrationCallCount = 0;
public migrationShouldFail = false;
public validationShouldFail = false;
public mergeValidationShouldFail = false; // New flag for the edge case
constructor(private configDir: string) {
super("TestConfigDefinition");
}
fileName(): string {
return "test-config.json";
}
configPath(): string {
return join(this.configDir, this.fileName());
}
defaultConfig(): TestConfig {
return {
name: "test",
version: 1,
enabled: false,
timeout: 5000,
maxRetries: 3, // Default includes maxRetries
};
}
async validate(config: object): Promise<TestConfig> {
if (this.validationShouldFail) {
throw new Error("Validation failed");
}
const testConfig = config as TestConfig;
// Basic validation
if (typeof testConfig.version !== "number" || testConfig.version < 1) {
throw new Error("Invalid version: must be >= 1");
}
if (typeof testConfig.timeout !== "number" || testConfig.timeout < 1000) {
throw new Error("Invalid timeout: must be >= 1000");
}
// Critical edge case: maxRetries validation that could fail after merge
if (testConfig.maxRetries !== undefined && testConfig.maxRetries < 0) {
throw new Error("Invalid maxRetries: must be >= 0");
}
// Simulate a validation that fails specifically for merged configs
if (this.mergeValidationShouldFail && testConfig.maxRetries === -1) {
throw new Error("Merged validation failed: maxRetries cannot be -1");
}
return testConfig;
}
async migrateConfig(): Promise<TestConfig> {
this.migrationCallCount++;
if (this.migrationShouldFail) {
throw new Error("Migration failed");
}
return {
name: "migrated",
version: 2,
enabled: true,
timeout: 3000,
maxRetries: 5,
};
}
}
describe("ConfigFileHandler", () => {
let testDir: string;
let configPath: string;
let configDefinition: TestConfigDefinition;
let fileHandler: ConfigFileHandler<TestConfig>;
beforeEach(async () => {
testDir = join(tmpdir(), `config-handler-test-${Date.now()}`);
await mkdir(testDir, { recursive: true });
configPath = join(testDir, "test-config.json");
configDefinition = new TestConfigDefinition(testDir);
fileHandler = new ConfigFileHandler(configDefinition);
});
afterEach(async () => {
await rm(testDir, { recursive: true, force: true });
});
describe("Critical loadConfig Scenarios", () => {
test("loads valid config from file successfully", async () => {
const validConfig = {
name: "existing",
version: 2,
enabled: true,
timeout: 3000,
maxRetries: 2,
};
await writeFile(configPath, JSON.stringify(validConfig));
const result = await fileHandler.loadConfig();
expect(result.name).toBe("existing");
expect(result.version).toBe(2);
expect(result.maxRetries).toBe(2);
});
test("falls back to migration when file doesn't exist", async () => {
const result = await fileHandler.loadConfig();
expect(configDefinition.migrationCallCount).toBe(1);
expect(result.name).toBe("migrated");
expect(result.version).toBe(2);
// Should persist migrated config
const persistedContent = await readFile(configPath, "utf8");
const persistedConfig = JSON.parse(persistedContent);
expect(persistedConfig.name).toBe("migrated");
});
test("falls back to defaults when migration fails", async () => {
configDefinition.migrationShouldFail = true;
const result = await fileHandler.loadConfig();
expect(result.name).toBe("test"); // From defaults
expect(result.version).toBe(1);
});
test("CRITICAL: file valid but merged config fails validation - triggers migration", async () => {
// File contains valid config but defaults have invalid maxRetries
const fileConfig = {
name: "file-valid",
version: 2,
enabled: true,
timeout: 2000,
// Note: no maxRetries in file
};
await writeFile(configPath, JSON.stringify(fileConfig));
// Override defaults to include invalid value that fails after merge
const originalDefaults = configDefinition.defaultConfig;
configDefinition.defaultConfig = () => ({
name: "test",
version: 1,
enabled: false,
timeout: 5000,
maxRetries: -1, // This will cause merged validation to fail!
});
configDefinition.mergeValidationShouldFail = true;
// This should NOT throw - should catch validation error and migrate
const result = await fileHandler.loadConfig();
// Should have triggered migration due to validation failure
expect(configDefinition.migrationCallCount).toBe(1);
expect(result.name).toBe("migrated");
expect(result.maxRetries).toBe(5); // From migration
// Restore original method
configDefinition.defaultConfig = originalDefaults;
});
test("handles invalid JSON by migrating", async () => {
await writeFile(configPath, "{ invalid json");
const result = await fileHandler.loadConfig();
expect(configDefinition.migrationCallCount).toBe(1);
expect(result.name).toBe("migrated");
});
test("CRITICAL: read failed → migration succeeded but merged validation fails → defaults used", async () => {
// No file exists (read will fail)
// Migration will succeed but return config that passes its own validation
// But when merged with defaults, the result fails validation
// Create a special definition for this edge case
class SpecialMigrationDefinition extends TestConfigDefinition {
async migrateConfig(): Promise<TestConfig> {
this.migrationCallCount++;
// Return a config that's valid on its own
return {
name: "migration-success",
version: 2,
enabled: true,
timeout: 2000,
// Missing maxRetries - will be merged from defaults
};
}
async validate(config: object): Promise<TestConfig> {
const testConfig = config as TestConfig;
// Basic validation
if (
typeof testConfig.version !== "number" ||
testConfig.version < 1
) {
throw new Error("Invalid version: must be >= 1");
}
if (
typeof testConfig.timeout !== "number" ||
testConfig.timeout < 1000
) {
throw new Error("Invalid timeout: must be >= 1000");
}
// This validation will fail after merge when maxRetries comes from defaults
if (
testConfig.maxRetries !== undefined &&
testConfig.name === "migration-success" &&
testConfig.maxRetries === 3
) {
throw new Error(
"Special validation failure: migration + defaults combo invalid"
);
}
return testConfig;
}
}
const specialDefinition = new SpecialMigrationDefinition(testDir);
const specialHandler = new ConfigFileHandler(specialDefinition);
// Should NOT throw - should catch validation error and fall back to defaults
const result = await specialHandler.loadConfig();
// Should have attempted migration
expect(specialDefinition.migrationCallCount).toBe(1);
// But result should be from defaults due to validation failure
expect(result.name).toBe("test"); // From defaults
expect(result.version).toBe(1); // From defaults
expect(result.maxRetries).toBe(3); // From defaults
});
});
describe("File Operations", () => {
test("readConfigFile validates config from disk", async () => {
const config = {
name: "read-test",
version: 2,
enabled: true,
timeout: 2000,
};
await writeFile(configPath, JSON.stringify(config));
const result = await fileHandler.readConfigFile();
expect(result).toEqual(config);
});
test("readConfigFile throws for invalid config", async () => {
const invalidConfig = {
name: "invalid",
version: -1,
enabled: true,
timeout: 2000,
};
await writeFile(configPath, JSON.stringify(invalidConfig));
await expect(fileHandler.readConfigFile()).rejects.toThrow(
"Invalid version"
);
});
test("writeConfigFile persists config to disk", async () => {
const config = {
name: "write-test",
version: 3,
enabled: true,
timeout: 4000,
};
const success = await fileHandler.writeConfigFile(config);
expect(success).toBe(true);
const fileContent = await readFile(configPath, "utf8");
expect(JSON.parse(fileContent)).toEqual(config);
});
test("writeConfigFile skips write when config unchanged (flash drive optimization)", async () => {
const config = {
name: "unchanged",
version: 1,
enabled: false,
timeout: 5000,
};
await writeFile(configPath, JSON.stringify(config, null, 2));
const success = await fileHandler.writeConfigFile(config);
expect(success).toBe(false); // Skipped
});
test("writeConfigFile proceeds with write when existing file has invalid JSON", async () => {
// Pre-existing file with invalid JSON
await writeFile(configPath, "{ invalid json");
const config = {
name: "write-despite-invalid",
version: 2,
enabled: true,
timeout: 4000,
};
// Should proceed with write despite invalid existing file
const success = await fileHandler.writeConfigFile(config);
expect(success).toBe(true);
// Should have written valid config
const fileContent = await readFile(configPath, "utf8");
expect(JSON.parse(fileContent)).toEqual(config);
});
test("writeConfigFile handles validation errors", async () => {
configDefinition.validationShouldFail = true;
const config = {
name: "invalid",
version: 1,
enabled: false,
timeout: 5000,
};
const success = await fileHandler.writeConfigFile(config);
expect(success).toBe(false);
});
});
describe("updateConfig Operations", () => {
test("updates existing config with partial changes", async () => {
const existing = {
name: "existing",
version: 1,
enabled: false,
timeout: 5000,
};
await writeFile(configPath, JSON.stringify(existing));
const success = await fileHandler.updateConfig({
enabled: true,
timeout: 8000,
});
expect(success).toBe(true);
const updated = JSON.parse(await readFile(configPath, "utf8"));
expect(updated.name).toBe("existing"); // Preserved
expect(updated.enabled).toBe(true); // Updated
expect(updated.timeout).toBe(8000); // Updated
});
test("creates config when file doesn't exist (via migration)", async () => {
const updates = { name: "new", enabled: true };
const success = await fileHandler.updateConfig(updates);
expect(success).toBe(true);
const created = JSON.parse(await readFile(configPath, "utf8"));
expect(created.name).toBe("new"); // From update
expect(created.version).toBe(2); // From migration (no file existed)
});
test("handles validation errors during update", async () => {
const existing = {
name: "existing",
version: 1,
enabled: false,
timeout: 5000,
};
await writeFile(configPath, JSON.stringify(existing));
const success = await fileHandler.updateConfig({ version: -1 }); // Invalid
expect(success).toBe(false);
// Original should be unchanged
const unchanged = JSON.parse(await readFile(configPath, "utf8"));
expect(unchanged.version).toBe(1);
});
});
describe("Error Resilience", () => {
test("handles write errors gracefully", async () => {
const invalidDefinition = new TestConfigDefinition(
"/invalid/readonly/path"
);
const invalidHandler = new ConfigFileHandler(invalidDefinition);
const config = {
name: "error-test",
version: 1,
enabled: false,
timeout: 5000,
};
const success = await invalidHandler.writeConfigFile(config);
expect(success).toBe(false);
});
});
describe("End-to-End Workflow", () => {
test("complete workflow: load -> update -> reload", async () => {
// 1. Load (triggers migration since no file)
let config = await fileHandler.loadConfig();
expect(config.name).toBe("migrated");
// 2. Update
await fileHandler.updateConfig({ name: "workflow-test", timeout: 6000 });
// 3. Reload from disk
config = await fileHandler.readConfigFile();
expect(config.name).toBe("workflow-test");
expect(config.timeout).toBe(6000);
expect(config.version).toBe(2); // Preserved from migration
});
});
});

View File

@@ -0,0 +1,100 @@
import { Logger } from "@nestjs/common";
/**
* Abstract base class for configuration behavior without NestJS dependencies.
* Provides core configuration logic including file path resolution, defaults,
* validation, and migration support.
*
* @template T The configuration object type that extends object
*
* @example
* ```typescript
* interface MyConfig {
* enabled: boolean;
* timeout: number;
* }
*
* class MyConfigDefinition extends ConfigDefinition<MyConfig> {
* constructor(private configDir: string) {
* super('MyConfig');
* }
*
* fileName() { return "my-config.json"; }
* configPath() { return path.join(this.configDir, this.fileName()); }
* defaultConfig(): MyConfig { return { enabled: false, timeout: 5000 }; }
*
* async validate(config: object): Promise<MyConfig> {
* const myConfig = config as MyConfig;
* if (myConfig.timeout < 1000) throw new Error("Timeout too low");
* return myConfig;
* }
* }
* ```
*/
export abstract class ConfigDefinition<T extends object> {
protected logger: Logger;
/**
* @param loggerName Optional custom logger name (defaults to generic name)
*/
constructor(loggerName?: string) {
this.logger = new Logger(loggerName ?? `ConfigDefinition:${this.fileName()}`);
}
/**
* Returns the filename for the configuration file.
*
* @returns The name of the config file (e.g., "my-config.json")
* @example "user-preferences.json"
*/
abstract fileName(): string;
/**
* Returns the absolute path to the configuration file.
*/
abstract configPath(): string;
/**
* Returns the default configuration object.
* Used as fallback when migration fails or as base for merging.
*/
abstract defaultConfig(): T;
/**
* Validates and transforms a configuration object.
*
* Override to implement custom validation logic such as:
* - Schema validation
* - Range checking for numeric values
* - Data transformation/normalization
*
* @param config - The raw config object to validate
* @returns The validated and potentially transformed config
* @throws Error if the config is invalid
*/
async validate(config: object): Promise<T> {
return config as T;
}
/**
* Migrates legacy or corrupted configuration to the current format.
*
* Called when:
* - Config file doesn't exist (first-time setup)
* - Config file contains invalid JSON
* - Config validation fails
*
* Override to provide custom migration logic for legacy formats,
* version upgrades, or first-time installations.
*
* Note:
* - Backwards-compatible updates such as field additions are better handled via `defaultConfig()`
* because `defaultConfig()` is merged with the loaded config.
*
* @returns Migrated configuration object
* @throws Error if migration is not possible (falls back to defaults)
*/
async migrateConfig(): Promise<T> {
throw new Error("Not implemented");
}
}

View File

@@ -0,0 +1,157 @@
import { Logger } from "@nestjs/common";
import { readFile, writeFile } from "node:fs/promises";
import { isEqual } from "lodash-es";
import { ConfigDefinition } from "./config-definition.js";
import { fileExists } from "./file.js";
/**
* Standalone configuration file handler that works with any ConfigDefinition.
* Can be used independently of NestJS DI container.
*
* This class provides robust file operations with the following features:
* - **Migration Priority**: When files don't exist, migration is attempted before falling back to defaults
* - **Change Detection**: Uses deep equality checks to avoid unnecessary disk writes (flash drive optimization)
* - **Error Resilience**: Graceful handling of file system errors, JSON parsing failures, and validation errors
* - **Atomic Operations**: Individual methods for specific file operations (read, write, update)
*
* @template T The configuration object type that extends object
*
* @example
* ```typescript
* const configDef = new MyConfigDefinition('/etc/myapp');
* const fileHandler = new ConfigFileHandler(configDef);
*
* // Load config with migration fallback
* const config = await fileHandler.loadConfig();
*
* // Update specific properties
* await fileHandler.updateConfig({ enabled: true });
* ```
*/
export class ConfigFileHandler<T extends object> {
private readonly logger: Logger;
/**
* @param definition The configuration definition that provides behavior
*/
constructor(private readonly definition: ConfigDefinition<T>) {
this.logger = new Logger(`ConfigFileHandler:${definition.fileName()}`);
}
/**
* Loads configuration from file, with migration fallback.
*
* Strategy:
* 1. Load and validate existing config
* 2. If loading fails, attempt migration
* 3. If migration fails, use defaults
* 4. Merge result with defaults and persist if migrated
*
* @returns Complete configuration object
*/
async loadConfig(): Promise<T> {
const defaultConfig = this.definition.defaultConfig();
try {
const fileConfig = await this.readConfigFile();
return await this.definition.validate({
...defaultConfig,
...fileConfig,
});
} catch (error) {
this.logger.warn(error, "Error loading config. Attempting to migrate...");
try {
const migratedConfig = await this.definition.migrateConfig();
const mergedConfig = await this.definition.validate({
...defaultConfig,
...migratedConfig,
});
// Persist migrated config for future loads
await this.writeConfigFile(mergedConfig);
return mergedConfig;
} catch (migrationError) {
this.logger.warn("Migration failed. Using defaults.", migrationError);
return defaultConfig;
}
}
}
/**
* Reads and validates configuration from file.
*
* @param configPath - Path to config file (defaults to `configPath()`)
* @returns Validated configuration object from disk
* @throws Error if file doesn't exist, contains invalid JSON, or fails validation
*/
async readConfigFile(configPath = this.definition.configPath()): Promise<T> {
if (!(await fileExists(configPath))) {
throw new Error(`Config file does not exist at '${configPath}'`);
}
const content = await readFile(configPath, "utf8");
const parsed = JSON.parse(content);
return await this.definition.validate(parsed);
}
/**
* Writes configuration to file with change detection optimization.
* Uses deep equality checks to avoid unnecessary writes.
*
* @param config - The config object to write to disk
* @returns `true` if written to disk, `false` if skipped or failed
*/
async writeConfigFile(config: T): Promise<boolean> {
try {
config = await this.definition.validate(config);
} catch (error) {
this.logger.error(error, `Cannot write invalid config`);
return false;
}
// Skip write if config is unchanged (flash drive optimization)
try {
const existingConfig = await this.readConfigFile();
if (isEqual(config, existingConfig)) {
this.logger.verbose(`Config is unchanged, skipping write`);
return false;
}
} catch (error) {
// File doesn't exist or is invalid, proceed with write
this.logger.verbose(`Existing config unreadable, proceeding with write`);
}
try {
const data = JSON.stringify(config, null, 2);
this.logger.verbose("Writing config");
await writeFile(this.definition.configPath(), data);
return true;
} catch (error) {
this.logger.error(
error,
`Error writing config to '${this.definition.configPath()}'`
);
return false;
}
}
/**
* Updates configuration by merging with existing config.
* Loads current config, shallow merges updates, and writes back to disk.
*
* @param updates - Partial configuration object with properties to update
* @returns `true` if updated on disk, `false` if failed or no changes
*/
async updateConfig(updates: Partial<T>): Promise<boolean> {
try {
const currentConfig = await this.loadConfig();
const newConfig = await this.definition.validate({
...currentConfig,
...updates,
});
return await this.writeConfigFile(newConfig);
} catch (error) {
this.logger.error("Failed to update config", error);
return false;
}
}
}

View File

@@ -1,11 +1,24 @@
import { accessSync } from 'fs';
import { access } from 'fs/promises';
import { access, mkdir, writeFile } from 'fs/promises';
import { mkdirSync, writeFileSync } from 'fs';
import { F_OK } from 'node:constants';
import { dirname } from 'path';
/**
* Checks if a file exists asynchronously.
* @param path - The file path to check
* @returns Promise that resolves to true if file exists, false otherwise
*/
export const fileExists = async (path: string) =>
access(path, F_OK)
.then(() => true)
.catch(() => false);
/**
* Checks if a file exists synchronously.
* @param path - The file path to check
* @returns true if file exists, false otherwise
*/
export const fileExistsSync = (path: string) => {
try {
accessSync(path, F_OK);
@@ -14,3 +27,44 @@ export const fileExistsSync = (path: string) => {
return false;
}
};
/**
* Writes data to a file, creating parent directories if they don't exist.
*
* This function ensures the directory structure exists before writing the file,
* equivalent to `mkdir -p` followed by file writing.
*
* @param path - The file path to write to
* @param data - The data to write (string or Buffer)
* @throws {Error} If path is invalid (null, empty, or not a string)
* @throws {Error} For any file system errors (EACCES, EPERM, ENOSPC, EISDIR, etc.)
*/
export const ensureWrite = async (path: string, data: string | Buffer) => {
if (!path || typeof path !== 'string') {
throw new Error(`Invalid path provided: ${path}`);
}
await mkdir(dirname(path), { recursive: true });
return await writeFile(path, data);
};
/**
* Writes data to a file synchronously, creating parent directories if they don't exist.
*
* This function ensures the directory structure exists before writing the file,
* equivalent to `mkdir -p` followed by file writing.
*
* @param path - The file path to write to
* @param data - The data to write (string or Buffer)
* @throws {Error} If path is invalid (null, empty, or not a string)
* @throws {Error} For any file system errors (EACCES, EPERM, ENOSPC, EISDIR, etc.)
*/
export const ensureWriteSync = (path: string, data: string | Buffer) => {
if (!path || typeof path !== 'string') {
throw new Error(`Invalid path provided: ${path}`);
}
mkdirSync(dirname(path), { recursive: true });
return writeFileSync(path, data);
};

View File

@@ -1,17 +1,17 @@
{
"name": "@unraid/connect-plugin",
"version": "4.9.3",
"version": "4.10.0",
"private": true,
"dependencies": {
"commander": "14.0.0",
"conventional-changelog": "6.0.0",
"date-fns": "4.1.0",
"glob": "11.0.1",
"glob": "11.0.3",
"html-sloppy-escaper": "0.1.0",
"semver": "7.7.1",
"tsx": "4.19.3",
"zod": "3.24.2",
"zx": "8.3.2"
"semver": "7.7.2",
"tsx": "4.20.3",
"zod": "3.25.76",
"zx": "8.7.1"
},
"type": "module",
"license": "GPL-2.0-or-later",
@@ -37,7 +37,7 @@
"devDependencies": {
"http-server": "14.1.1",
"nodemon": "3.1.10",
"vitest": "3.0.7"
"vitest": "3.2.4"
},
"packageManager": "pnpm@10.12.4"
"packageManager": "pnpm@10.13.1"
}

View File

@@ -155,6 +155,11 @@ exit 0
# Remove the old header logo from DefaultPageLayout.php if present
if [ -f "/usr/local/emhttp/plugins/dynamix/include/DefaultPageLayout.php" ]; then
sed -i 's|<a href="https://unraid.net" target="_blank"><?readfile("$docroot/webGui/images/UN-logotype-gradient.svg")?></a>||g' "/usr/local/emhttp/plugins/dynamix/include/DefaultPageLayout.php"
# Add unraid-modals element if not already present
if ! grep -q '<unraid-modals>' "/usr/local/emhttp/plugins/dynamix/include/DefaultPageLayout.php"; then
sed -i 's|<body>|<body>\n<unraid-modals></unraid-modals>|' "/usr/local/emhttp/plugins/dynamix/include/DefaultPageLayout.php"
fi
fi
]]>
@@ -326,8 +331,7 @@ exit 0
<![CDATA[
SCRIPTS_DIR="/usr/local/share/dynamix.unraid.net/install/scripts"
# Log file for debugging
LOGFILE="/var/log/unraid-api/dynamix-unraid-install.log"
mkdir -p "$(dirname "$LOGFILE")"
mkdir -p "/var/log/unraid-api"
echo "Starting Unraid Connect installation..."
@@ -339,26 +343,26 @@ CFG_NEW=/boot/config/plugins/dynamix.my.servers
# Setup the API (but don't start it yet)
if [ -x "$SCRIPTS_DIR/setup_api.sh" ]; then
echo "Setting up Unraid API..."
echo "Running setup_api.sh" >> "$LOGFILE"
# Capture output and add to log file
setup_output=$("$SCRIPTS_DIR/setup_api.sh")
echo "$setup_output" >> "$LOGFILE"
echo "Running setup_api.sh"
# Run and show output to user
"$SCRIPTS_DIR/setup_api.sh"
else
echo "ERROR: setup_api.sh not found or not executable" >> "$LOGFILE"
echo "ERROR: setup_api.sh not found or not executable"
echo "ERROR: setup_api.sh not found or not executable"
fi
# Run post-installation verification
if [ -x "$SCRIPTS_DIR/verify_install.sh" ]; then
echo "Running post-installation verification..."
echo "Running verify_install.sh" >> "$LOGFILE"
# Capture output and add to log file
verify_output=$("$SCRIPTS_DIR/verify_install.sh")
echo "$verify_output" >> "$LOGFILE"
echo "Running verify_install.sh"
# Run and show output to user
"$SCRIPTS_DIR/verify_install.sh"
else
echo "ERROR: verify_install.sh not found or not executable" >> "$LOGFILE"
echo "ERROR: verify_install.sh not found or not executable"
echo "ERROR: verify_install.sh not found or not executable"
fi
echo "Installation completed at $(date)" >> "$LOGFILE"
echo "Installation completed at $(date)"
]]>
</INLINE>
</FILE>
@@ -374,6 +378,18 @@ echo "Installation completed at $(date)" >> "$LOGFILE"
/etc/rc.d/rc.unraid-api cleanup-dependencies
echo "Starting Unraid API service"
echo "DEBUG: Checking PATH: $PATH"
echo "DEBUG: Checking if unraid-api files exist:"
ls -la /usr/local/unraid-api/dist/
echo "DEBUG: Checking symlink:"
ls -la /usr/local/bin/unraid-api
echo "DEBUG: Checking Node.js version:"
node --version
echo "DEBUG: Checking if cli.js is executable:"
ls -la /usr/local/unraid-api/dist/cli.js
echo "DEBUG: Attempting to run unraid-api directly:"
/usr/local/unraid-api/dist/cli.js version || echo "Direct execution failed"
echo "If no additional messages appear within 30 seconds, it is safe to refresh the page."
/etc/rc.d/rc.unraid-api plugins add unraid-api-plugin-connect -b --no-restart
/etc/rc.d/rc.unraid-api start

View File

@@ -166,22 +166,23 @@ _enabled() {
return 1
}
_connected() {
CFG=$API_CONFIG_HOME/connect.json
[[ ! -f "${CFG}" ]] && return 1
local connect_config username status_cfg connection_status
connect_config=$API_CONFIG_HOME/connect.json
[[ ! -f "${connect_config}" ]] && return 1
username=$(jq -r '.username // empty' "${CFG}" 2>/dev/null)
# is the user signed in?
username=$(jq -r '.username // empty' "${connect_config}" 2>/dev/null)
if [ -z "${username}" ]; then
return 1
fi
# the minigraph status is no longer synced to the connect config file
# to avoid a false negative, we'll omit this check for now.
#
# shellcheck disable=SC1090
# source <(sed -nr '/\[connectionStatus\]/,/\[/{/minigraph/p}' "${CFG}" 2>/dev/null)
# # ensure connected
# if [[ -z "${minigraph}" || "${minigraph}" != "CONNECTED" ]]; then
# return 1
# fi
# are we connected to mothership?
status_cfg="/var/local/emhttp/connectStatus.json"
[[ ! -f "${status_cfg}" ]] && return 1
connection_status=$(jq -r '.connectionStatus // empty' "${status_cfg}" 2>/dev/null)
if [[ "${connection_status}" != "CONNECTED" ]]; then
return 1
fi
return 0
}
_haserror() {

View File

@@ -4,9 +4,6 @@
# shellcheck source=/dev/null
source /etc/profile
flash="/boot/config/plugins/dynamix.my.servers"
[[ ! -d "${flash}" ]] && echo "Please reinstall the Unraid Connect plugin" && exit 1
[[ ! -f "${flash}/env" ]] && echo 'env=production' >"${flash}/env"
unraid_binary_path="/usr/local/bin/unraid-api"
api_base_dir="/usr/local/unraid-api"
scripts_dir="/usr/local/share/dynamix.unraid.net/scripts"

View File

@@ -18,10 +18,9 @@ $cli = php_sapi_name()=='cli';
$docroot ??= ($_SERVER['DOCUMENT_ROOT'] ?: '/usr/local/emhttp');
require_once "$docroot/webGui/include/Wrappers.php";
require_once "$docroot/plugins/dynamix.my.servers/include/connect-config.php";
$myservers_flash_cfg_path='/boot/config/plugins/dynamix.my.servers/myservers.cfg';
$myservers = file_exists($myservers_flash_cfg_path) ? @parse_ini_file($myservers_flash_cfg_path,true) : [];
$isRegistered = !empty($myservers['remote']['username']);
$isRegistered = ConnectConfig::isUserSignedIn();
// Read connection status from the new API status file
$statusFilePath = '/var/local/emhttp/connectStatus.json';
@@ -595,9 +594,31 @@ set_git_config('user.email', 'gitbot@unraid.net');
set_git_config('user.name', 'gitbot');
// ensure dns can resolve backup.unraid.net
if (! checkdnsrr("backup.unraid.net","A") ) {
$dnsResolved = false;
// Try multiple DNS resolution methods
if (function_exists('dns_get_record')) {
$dnsRecords = dns_get_record("backup.unraid.net", DNS_A);
$dnsResolved = !empty($dnsRecords);
}
// Fallback to gethostbyname if dns_get_record fails
if (!$dnsResolved) {
$ip = gethostbyname("backup.unraid.net");
$dnsResolved = ($ip !== "backup.unraid.net");
}
// Final fallback to system nslookup
if (!$dnsResolved) {
$output = [];
$return_var = 0;
exec('nslookup backup.unraid.net 2>/dev/null', $output, $return_var);
$dnsResolved = ($return_var === 0 && !empty($output));
}
if (!$dnsResolved) {
$arrState['loading'] = '';
$arrState['error'] = 'DNS is unable to resolve backup.unraid.net';
$arrState['error'] = 'DNS resolution failed for backup.unraid.net - PHP DNS functions (checkdnsrr, dns_get_record, gethostbyname) and system nslookup all failed to resolve the hostname. This indicates a DNS configuration issue on your Unraid server. Check your DNS settings in Settings > Network Settings.';
response_complete(406, array('error' => $arrState['error']));
}

View File

@@ -0,0 +1,26 @@
<?php
$docroot = $docroot ?? $_SERVER['DOCUMENT_ROOT'] ?: '/usr/local/emhttp';
require_once "$docroot/plugins/dynamix.my.servers/include/api-config.php";
/**
* Wrapper around the API's connect.json configuration file.
*/
class ConnectConfig
{
public const CONFIG_PATH = ApiConfig::CONFIG_DIR . '/connect.json';
public static function getConfig()
{
try {
return json_decode(file_get_contents(self::CONFIG_PATH), true) ?? [];
} catch (Throwable $e) {
return [];
}
}
public static function isUserSignedIn()
{
$config = self::getConfig();
return ApiConfig::isConnectPluginEnabled() && !empty($config['username'] ?? '');
}
}

View File

@@ -39,6 +39,7 @@ class WebComponentsExtractor
return $contents ? json_decode($contents, true) : [];
}
private function getRichComponentsFile(): string
{
$manifestFiles = $this->findManifestFiles('manifest.json');

View File

@@ -1,10 +1,7 @@
#!/bin/sh
#!/bin/bash
# Unraid API Installation Verification Script
# Checks that critical files are installed correctly
# Exit on errors
set -e
echo "Performing comprehensive installation verification..."
# Define critical files to check (POSIX-compliant, no arrays)
@@ -171,7 +168,7 @@ if [ $TOTAL_ERRORS -eq 0 ]; then
else
printf 'Found %d total errors.\n' "$TOTAL_ERRORS"
echo "Installation verification completed with issues."
echo "See log file for details: /var/log/unraid-api/dynamix-unraid-install.log"
echo "Please review the errors above and contact support if needed."
# We don't exit with error as this is just a verification script
exit 0
fi

5955
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,5 +4,5 @@
"tabWidth": 2,
"printWidth": 105,
"singleQuote": true,
"plugins": ["prettier-plugin-tailwindcss", "@ianvs/prettier-plugin-sort-imports"]
"plugins": ["@ianvs/prettier-plugin-sort-imports"]
}

View File

@@ -19,29 +19,21 @@ const config: StorybookConfig = {
staticDirs: ['./static'],
async viteFinal(config) {
const storybookDir = dirname(new URL(import.meta.url).pathname);
return {
...config,
root: dirname(require.resolve('@storybook/builder-vite')),
plugins: [...(config.plugins ?? [])],
resolve: {
alias: {
'@': join(dirname(new URL(import.meta.url).pathname), '../src'),
'@/components': join(dirname(new URL(import.meta.url).pathname), '../src/components'),
'@/lib': join(dirname(new URL(import.meta.url).pathname), '../src/lib'),
'@': join(storybookDir, '../src'),
'@/components': join(storybookDir, '../src/components'),
'@/lib': join(storybookDir, '../src/lib'),
},
},
optimizeDeps: {
include: [...(config.optimizeDeps?.include ?? []), '@unraid/tailwind-rem-to-rem'],
},
css: {
postcss: {
plugins: [
(await import('tailwindcss')).default({
config: './tailwind.config.ts',
}),
(await import('autoprefixer')).default,
],
},
},
};
},
};

View File

@@ -1,6 +1,7 @@
import type { Preview } from '@storybook/vue3-vite';
import { registerAllComponents } from '../src/register';
import '@/styles/index.css';
import '@/../.storybook/static/index.css';
registerAllComponents({
pathToSharedCss: '/index.css',

View File

@@ -27,36 +27,6 @@ Import the component library styles in your main entry file:
import '@unraid/ui/style.css';
```
### 2. Configure TailwindCSS
Create a `tailwind.config.ts` file with the following configuration:
```typescript
import tailwindConfig from '@unraid/ui/tailwind.config.ts';
import type { Config } from 'tailwindcss';
export default {
presets: [tailwindConfig],
content: [
// ... your content paths
'./components/**/*.{js,vue,ts}',
'./layouts/**/*.vue',
'./pages/**/*.vue',
],
theme: {
extend: {
// your theme extensions
},
},
} satisfies Partial<Config>;
```
This configuration:
- Uses the Unraid UI library's Tailwind config as a preset
- Properly types your configuration with TypeScript
- Allows you to extend the base theme while maintaining all Unraid UI defaults
## Usage
```vue
@@ -249,7 +219,7 @@ const meta = {
argTypes: {
variant: {
control: 'select',
options: ['primary', 'secondary', 'outline'],
options: ['primary', 'secondary', 'outline-solid'],
},
size: {
control: 'select',

View File

@@ -3,7 +3,6 @@
"style": "default",
"typescript": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/styles/globals.css",
"baseColor": "neutral",
"cssVariables": true,

View File

@@ -1,6 +1,6 @@
{
"name": "@unraid/ui",
"version": "4.9.3",
"version": "4.10.0",
"private": true,
"license": "GPL-2.0-or-later",
"type": "module",
@@ -9,11 +9,9 @@
"types": "./dist/index.d.ts",
"sideEffects": false,
"files": [
"dist",
"tailwind.config.ts"
"dist"
],
"scripts": {
"prepare": "pnpm build",
"// Development": "",
"dev": "vite",
"preview": "vite preview",
@@ -34,11 +32,11 @@
"preunraid:deploy": "pnpm build:wc",
"unraid:deploy": "just deploy",
"// Storybook": "",
"prestorybook": "pnpm storybook:css",
"storybook": "storybook dev -p 6006",
"storybook:css": "node scripts/build-style.mjs",
"prebuild-storybook": "pnpm storybook:css",
"build-storybook": "storybook build",
"storybook": "pnpm tailwind:watch & pnpm storybook:dev",
"storybook:dev": "storybook dev -p 6006",
"build-storybook": "pnpm tailwind:build && storybook build",
"tailwind:build": "tailwindcss -i ./src/styles/globals.css -o ./.storybook/static/index.css",
"tailwind:watch": "pnpm tailwind:build --watch",
"// Cloudflare Workers Deployment": "",
"deploy:storybook": "pnpm build-storybook && wrangler deploy",
"deploy:storybook:staging": "pnpm build-storybook && wrangler deploy --env staging"
@@ -54,68 +52,65 @@
"@jsonforms/core": "3.6.0",
"@jsonforms/vue": "3.6.0",
"@jsonforms/vue-vanilla": "3.6.0",
"@vueuse/core": "13.4.0",
"@tailwindcss/cli": "4.1.11",
"@vueuse/core": "13.5.0",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"dompurify": "3.2.6",
"kebab-case": "2.0.2",
"lucide-vue-next": "0.519.0",
"lucide-vue-next": "0.525.0",
"marked": "16.0.0",
"reka-ui": "2.3.1",
"reka-ui": "2.3.2",
"shadcn-vue": "2.2.0",
"tailwind-merge": "2.6.0",
"vue-sonner": "1.3.0"
"tw-animate-css": "1.3.5",
"vue-sonner": "1.3.2"
},
"devDependencies": {
"@ianvs/prettier-plugin-sort-imports": "4.4.2",
"@storybook/addon-docs": "9.0.16",
"@storybook/addon-links": "9.0.16",
"@storybook/builder-vite": "9.0.16",
"@storybook/vue3-vite": "9.0.16",
"@tailwindcss/typography": "0.5.16",
"@ianvs/prettier-plugin-sort-imports": "4.5.1",
"@storybook/addon-docs": "9.0.17",
"@storybook/addon-links": "9.0.17",
"@storybook/builder-vite": "9.0.17",
"@storybook/vue3-vite": "9.0.17",
"@tailwindcss/vite": "4.1.11",
"@testing-library/vue": "8.1.0",
"@types/jsdom": "21.1.7",
"@types/node": "22.15.32",
"@types/node": "22.16.4",
"@types/testing-library__vue": "5.3.0",
"@typescript-eslint/eslint-plugin": "8.34.1",
"@unraid/tailwind-rem-to-rem": "1.1.0",
"@vitejs/plugin-vue": "5.2.4",
"@typescript-eslint/eslint-plugin": "8.37.0",
"@unraid/tailwind-rem-to-rem": "2.0.0",
"@vitejs/plugin-vue": "6.0.0",
"@vitest/coverage-v8": "3.2.4",
"@vitest/ui": "3.2.4",
"@vue/test-utils": "2.4.6",
"@vue/tsconfig": "0.7.0",
"autoprefixer": "10.4.21",
"concurrently": "9.1.2",
"eslint": "9.29.0",
"concurrently": "9.2.0",
"eslint": "9.31.0",
"eslint-config-prettier": "10.1.5",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-no-relative-import-paths": "1.6.1",
"eslint-plugin-prettier": "5.5.0",
"eslint-plugin-storybook": "9.0.16",
"eslint-plugin-vue": "10.2.0",
"happy-dom": "18.0.0",
"jiti": "^2.4.2",
"eslint-plugin-prettier": "5.5.1",
"eslint-plugin-storybook": "9.0.17",
"eslint-plugin-vue": "10.3.0",
"happy-dom": "18.0.1",
"jiti": "2.4.2",
"postcss": "8.5.6",
"postcss-import": "16.1.1",
"prettier": "3.5.3",
"prettier-plugin-tailwindcss": "0.6.13",
"prettier": "3.6.2",
"rimraf": "6.0.1",
"storybook": "9.0.16",
"tailwind-rem-to-rem": "github:unraid/tailwind-rem-to-rem",
"tailwindcss": "3.4.17",
"tailwindcss-animate": "1.0.7",
"storybook": "9.0.17",
"tailwindcss": "4.1.11",
"typescript": "5.8.3",
"typescript-eslint": "8.34.1",
"vite": "7.0.3",
"typescript-eslint": "8.37.0",
"vite": "7.0.4",
"vite-plugin-dts": "3.9.1",
"vite-plugin-vue-devtools": "7.7.7",
"vitest": "3.2.4",
"vue": "3.5.17",
"vue-tsc": "3.0.1",
"wrangler": "^3.87.0"
"wrangler": "4.24.3"
},
"optionalDependencies": {
"@rollup/rollup-linux-x64-gnu": "4.44.0"
"@rollup/rollup-linux-x64-gnu": "4.45.1"
},
"exports": {
".": {
@@ -124,20 +119,7 @@
"require": "./dist/index.cjs"
},
"./styles": "./dist/style.css",
"./styles/*": "./src/styles/*",
"./tailwind.config": {
"types": "./dist/tailwind.config.d.ts",
"import": "./dist/tailwind.config.js",
"default": "./dist/tailwind.config.js"
},
"./tailwind.config.ts": {
"import": "./tailwind.config.ts",
"default": "./tailwind.config.ts"
},
"./theme/preset": {
"types": "./dist/theme/preset.d.ts",
"import": "./dist/theme/preset.js"
}
"./styles/*": "./src/styles/*"
},
"packageManager": "pnpm@10.12.4"
"packageManager": "pnpm@10.13.1"
}

View File

@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -1,30 +0,0 @@
import fs from 'fs/promises';
import autoprefixer from 'autoprefixer';
import postcss from 'postcss';
import postcssImport from 'postcss-import';
import tailwindcss from 'tailwindcss';
/**
* Helper script for storybook to build the CSS file for the components. This is used to ensure that modals render using the shadow styles.
*/
process.env.VITE_TAILWIND_BASE_FONT_SIZE = 16;
const inputPath = './src/styles/index.css';
const outputPath = './.storybook/static/index.css'; // served from root: /index.css
const css = await fs.readFile(inputPath, 'utf8');
const result = await postcss([
postcssImport(),
tailwindcss({ config: './tailwind.config.ts' }),
autoprefixer(),
]).process(css, {
from: inputPath,
to: outputPath,
});
await fs.mkdir('./.storybook/static', { recursive: true });
await fs.writeFile(outputPath, result.css);
console.log('✅ CSS built for Storybook:', outputPath);

View File

@@ -42,18 +42,17 @@ const props = withDefaults(defineProps<BrandButtonProps>(), {
defineEmits(['click']);
const classes = computed(() => {
const iconSize = `w-${props.size}`;
return {
button: cn(
brandButtonVariants({ variant: props.variant, size: props.size, padding: props.padding }),
props.class
),
icon: `${iconSize} fill-current flex-shrink-0`,
icon: 'w-[var(--icon-size)] fill-current shrink-0',
iconSize: props.size ?? '16px',
};
});
const needsBrandGradientBackground = computed(() => {
return ['outline', 'outline-primary'].includes(props.variant ?? '');
return ['outline-solid', 'outline-primary'].includes(props.variant ?? '');
});
</script>
@@ -71,15 +70,20 @@ const needsBrandGradientBackground = computed(() => {
>
<div
v-if="variant === 'fill'"
class="absolute -top-[2px] -right-[2px] -bottom-[2px] -left-[2px] -z-10 bg-gradient-to-r from-unraid-red to-orange opacity-100 transition-all rounded-md group-hover:opacity-60 group-focus:opacity-60"
class="absolute -top-[2px] -right-[2px] -bottom-[2px] -left-[2px] -z-10 bg-linear-to-r from-unraid-red to-orange opacity-100 transition-all rounded-md group-hover:opacity-60 group-focus:opacity-60"
/>
<!-- gives outline buttons the brand gradient background -->
<div
v-if="needsBrandGradientBackground"
class="absolute -top-[2px] -right-[2px] -bottom-[2px] -left-[2px] -z-10 bg-gradient-to-r from-unraid-red to-orange opacity-0 transition-all rounded-md group-hover:opacity-100 group-focus:opacity-100"
class="absolute -top-[2px] -right-[2px] -bottom-[2px] -left-[2px] -z-10 bg-linear-to-r from-unraid-red to-orange opacity-0 transition-all rounded-md group-hover:opacity-100 group-focus:opacity-100"
/>
<component :is="icon" v-if="icon" :class="classes.icon" />
<component
:is="icon"
v-if="icon"
:class="classes.icon"
:style="{ '--icon-size': classes.iconSize }"
/>
{{ text }}
<slot />
@@ -92,6 +96,7 @@ const needsBrandGradientBackground = computed(() => {
iconRightHoverDisplay &&
'opacity-0 group-hover:opacity-100 group-focus:opacity-100 transition-all',
]"
:style="{ '--icon-size': classes.iconSize }"
/>
</component>
</template>

View File

@@ -21,15 +21,15 @@ export const brandButtonVariants = cva(
'underline-hover-red':
'opacity-75 underline border-transparent transition hover:text-white hover:bg-unraid-red hover:border-unraid-red focus:text-white focus:bg-unraid-red focus:border-unraid-red hover:opacity-100 focus:opacity-100',
white: 'text-black bg-white transition hover:bg-grey focus:bg-grey',
none: '',
none: 'border-transparent hover:shadow-none focus:shadow-none',
},
size: {
'12px': 'text-12px gap-4px',
'14px': 'text-14px gap-8px',
'16px': 'text-16px gap-8px',
'18px': 'text-18px gap-8px',
'20px': 'text-20px gap-8px',
'24px': 'text-24px gap-8px',
'12px': 'text-xs gap-1',
'14px': 'text-sm gap-2',
'16px': 'text-base gap-2',
'18px': 'text-lg gap-2',
'20px': 'text-xl gap-2',
'24px': 'text-2xl gap-2',
},
padding: {
default: '',
@@ -41,32 +41,32 @@ export const brandButtonVariants = cva(
{
size: '12px',
padding: 'default',
class: 'p-8px',
class: 'p-2',
},
{
size: '14px',
padding: 'default',
class: 'p-8px',
class: 'p-2',
},
{
size: '16px',
padding: 'default',
class: 'p-12px',
class: 'p-3',
},
{
size: '18px',
padding: 'default',
class: 'p-12px',
class: 'p-3',
},
{
size: '20px',
padding: 'default',
class: 'p-16px',
class: 'p-4',
},
{
size: '24px',
padding: 'default',
class: 'p-16px',
class: 'p-4',
},
],
defaultVariants: {

View File

@@ -23,12 +23,12 @@ const props = withDefaults(defineProps<BadgeProps>(), {
const badgeClasses = computed(() => {
const iconSizes = {
xs: 'w-12px',
sm: 'w-14px',
md: 'w-16px',
lg: 'w-18px',
xl: 'w-20px',
'2xl': 'w-24px',
xs: 'w-3',
sm: 'w-3.5',
md: 'w-4',
lg: 'w-4.5',
xl: 'w-5',
'2xl': 'w-6',
} as const;
return {
@@ -40,8 +40,8 @@ const badgeClasses = computed(() => {
<template>
<span :class="[badgeClasses.badge, props.class]">
<component :is="icon" v-if="icon" class="flex-shrink-0" :class="badgeClasses.icon" />
<component :is="icon" v-if="icon" class="shrink-0" :class="badgeClasses.icon" />
<slot />
<component :is="iconRight" v-if="iconRight" class="flex-shrink-0" :class="badgeClasses.icon" />
<component :is="iconRight" v-if="iconRight" class="shrink-0" :class="badgeClasses.icon" />
</span>
</template>

View File

@@ -22,12 +22,12 @@ export const badgeVariants = cva(
custom: '',
},
size: {
xs: 'text-12px px-8px py-4px gap-4px',
sm: 'text-14px px-8px py-4px gap-8px',
md: 'text-16px px-12px py-8px gap-8px',
lg: 'text-18px px-12px py-8px gap-8px',
xl: 'text-20px px-16px py-12px gap-8px',
'2xl': 'text-24px px-16px py-12px gap-8px',
xs: 'text-xs px-2 py-1 gap-1',
sm: 'text-sm px-2 py-1 gap-2',
md: 'text-base px-3 py-2 gap-2',
lg: 'text-lg px-3 py-2 gap-2',
xl: 'text-xl px-4 py-3 gap-2',
'2xl': 'text-2xl px-4 py-3 gap-2',
},
},
defaultVariants: {

View File

@@ -20,7 +20,7 @@ describe('Button', () => {
});
rerender({
props: { variant: 'outline' },
props: { variant: 'outline-solid' },
slots: { default: 'Delete' },
});
});

View File

@@ -2,7 +2,7 @@ import type { VariantProps } from 'class-variance-authority';
import { cva } from 'class-variance-authority';
export const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md text-base font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
'inline-flex items-center justify-center rounded-md text-base font-medium ring-offset-background transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {

Some files were not shown because too many files have changed in this diff Show More