Compare commits

...

14 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
204 changed files with 6093 additions and 5280 deletions

View File

@@ -14,7 +14,31 @@
"Bash(mv:*)",
"Bash(ls:*)",
"mcp__ide__getDiagnostics",
"Bash(pnpm --filter \"*connect*\" test connect-status-writer.service.spec)"
"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

@@ -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,5 @@
{
"version": "4.9.5",
"version": "4.10.0",
"extraOrigins": [
"https://google.com",
"https://test.com"

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

@@ -247,6 +247,347 @@ A field whose value conforms to the standard URL format as specified in RFC3986:
"""
scalar URL
type DiskPartition {
"""The name of the partition"""
name: String!
"""The filesystem type of the partition"""
fsType: DiskFsType!
"""The size of the partition in bytes"""
size: Float!
}
"""The type of filesystem on the disk partition"""
enum DiskFsType {
XFS
BTRFS
VFAT
ZFS
EXT4
NTFS
}
type Disk implements Node {
id: PrefixedID!
"""The device path of the disk (e.g. /dev/sdb)"""
device: String!
"""The type of disk (e.g. SSD, HDD)"""
type: String!
"""The model name of the disk"""
name: String!
"""The manufacturer of the disk"""
vendor: String!
"""The total size of the disk in bytes"""
size: Float!
"""The number of bytes per sector"""
bytesPerSector: Float!
"""The total number of cylinders on the disk"""
totalCylinders: Float!
"""The total number of heads on the disk"""
totalHeads: Float!
"""The total number of sectors on the disk"""
totalSectors: Float!
"""The total number of tracks on the disk"""
totalTracks: Float!
"""The number of tracks per cylinder"""
tracksPerCylinder: Float!
"""The number of sectors per track"""
sectorsPerTrack: Float!
"""The firmware revision of the disk"""
firmwareRevision: String!
"""The serial number of the disk"""
serialNum: String!
"""The interface type of the disk"""
interfaceType: DiskInterfaceType!
"""The SMART status of the disk"""
smartStatus: DiskSmartStatus!
"""The current temperature of the disk in Celsius"""
temperature: Float
"""The partitions on the disk"""
partitions: [DiskPartition!]!
}
"""The type of interface the disk uses to connect to the system"""
enum DiskInterfaceType {
SAS
SATA
USB
PCIE
UNKNOWN
}
"""
The SMART (Self-Monitoring, Analysis and Reporting Technology) status of the disk
"""
enum DiskSmartStatus {
OK
UNKNOWN
}
type KeyFile {
location: String
contents: String
}
type Registration implements Node {
id: PrefixedID!
type: registrationType
keyFile: KeyFile
state: RegistrationState
expiration: String
updateExpiration: String
}
enum registrationType {
BASIC
PLUS
PRO
STARTER
UNLEASHED
LIFETIME
INVALID
TRIAL
}
enum RegistrationState {
TRIAL
BASIC
PLUS
PRO
STARTER
UNLEASHED
LIFETIME
EEXPIRED
EGUID
EGUID1
ETRIAL
ENOKEYFILE
ENOKEYFILE1
ENOKEYFILE2
ENOFLASH
ENOFLASH1
ENOFLASH2
ENOFLASH3
ENOFLASH4
ENOFLASH5
ENOFLASH6
ENOFLASH7
EBLACKLISTED
EBLACKLISTED1
EBLACKLISTED2
ENOCONN
}
type Vars implements Node {
id: PrefixedID!
"""Unraid version"""
version: String
maxArraysz: Int
maxCachesz: Int
"""Machine hostname"""
name: String
timeZone: String
comment: String
security: String
workgroup: String
domain: String
domainShort: String
hideDotFiles: Boolean
localMaster: Boolean
enableFruit: String
"""Should a NTP server be used for time sync?"""
useNtp: Boolean
"""NTP Server 1"""
ntpServer1: String
"""NTP Server 2"""
ntpServer2: String
"""NTP Server 3"""
ntpServer3: String
"""NTP Server 4"""
ntpServer4: String
domainLogin: String
sysModel: String
sysArraySlots: Int
sysCacheSlots: Int
sysFlashSlots: Int
useSsl: Boolean
"""Port for the webui via HTTP"""
port: Int
"""Port for the webui via HTTPS"""
portssl: Int
localTld: String
bindMgt: Boolean
"""Should telnet be enabled?"""
useTelnet: Boolean
porttelnet: Int
useSsh: Boolean
portssh: Int
startPage: String
startArray: Boolean
spindownDelay: String
queueDepth: String
spinupGroups: Boolean
defaultFormat: String
defaultFsType: String
shutdownTimeout: Int
luksKeyfile: String
pollAttributes: String
pollAttributesDefault: String
pollAttributesStatus: String
nrRequests: Int
nrRequestsDefault: Int
nrRequestsStatus: String
mdNumStripes: Int
mdNumStripesDefault: Int
mdNumStripesStatus: String
mdSyncWindow: Int
mdSyncWindowDefault: Int
mdSyncWindowStatus: String
mdSyncThresh: Int
mdSyncThreshDefault: Int
mdSyncThreshStatus: String
mdWriteMethod: Int
mdWriteMethodDefault: String
mdWriteMethodStatus: String
shareDisk: String
shareUser: String
shareUserInclude: String
shareUserExclude: String
shareSmbEnabled: Boolean
shareNfsEnabled: Boolean
shareAfpEnabled: Boolean
shareInitialOwner: String
shareInitialGroup: String
shareCacheEnabled: Boolean
shareCacheFloor: String
shareMoverSchedule: String
shareMoverLogging: Boolean
fuseRemember: String
fuseRememberDefault: String
fuseRememberStatus: String
fuseDirectio: String
fuseDirectioDefault: String
fuseDirectioStatus: String
shareAvahiEnabled: Boolean
shareAvahiSmbName: String
shareAvahiSmbModel: String
shareAvahiAfpName: String
shareAvahiAfpModel: String
safeMode: Boolean
startMode: String
configValid: Boolean
configError: ConfigErrorState
joinStatus: String
deviceCount: Int
flashGuid: String
flashProduct: String
flashVendor: String
regCheck: String
regFile: String
regGuid: String
regTy: registrationType
regState: RegistrationState
"""Registration owner"""
regTo: String
regTm: String
regTm2: String
regGen: String
sbName: String
sbVersion: String
sbUpdated: String
sbEvents: Int
sbState: String
sbClean: Boolean
sbSynced: Int
sbSyncErrs: Int
sbSynced2: Int
sbSyncExit: String
sbNumDisks: Int
mdColor: String
mdNumDisks: Int
mdNumDisabled: Int
mdNumInvalid: Int
mdNumMissing: Int
mdNumNew: Int
mdNumErased: Int
mdResync: Int
mdResyncCorr: String
mdResyncPos: String
mdResyncDb: String
mdResyncDt: String
mdResyncAction: String
mdResyncSize: Int
mdState: String
mdVersion: String
cacheNumDevices: Int
cacheSbNumDisks: Int
fsState: String
"""Human friendly string of array events happening"""
fsProgress: String
"""
Percentage from 0 - 100 while upgrading a disk or swapping parity drives
"""
fsCopyPrcnt: Int
fsNumMounted: Int
fsNumUnmountable: Int
fsUnmountableMask: String
"""Total amount of user shares"""
shareCount: Int
"""Total amount shares with SMB enabled"""
shareSmbCount: Int
"""Total amount shares with NFS enabled"""
shareNfsCount: Int
"""Total amount shares with AFP enabled"""
shareAfpCount: Int
shareMoverActive: Boolean
csrfToken: String
}
"""Possible error states for configuration"""
enum ConfigErrorState {
UNKNOWN_ERROR
INELIGIBLE
INVALID
NO_KEY_SERVER
WITHDRAWN
}
type Permission {
resource: Resource!
actions: [String!]!
@@ -620,102 +961,6 @@ enum ThemeName {
white
}
type DiskPartition {
"""The name of the partition"""
name: String!
"""The filesystem type of the partition"""
fsType: DiskFsType!
"""The size of the partition in bytes"""
size: Float!
}
"""The type of filesystem on the disk partition"""
enum DiskFsType {
XFS
BTRFS
VFAT
ZFS
EXT4
NTFS
}
type Disk implements Node {
id: PrefixedID!
"""The device path of the disk (e.g. /dev/sdb)"""
device: String!
"""The type of disk (e.g. SSD, HDD)"""
type: String!
"""The model name of the disk"""
name: String!
"""The manufacturer of the disk"""
vendor: String!
"""The total size of the disk in bytes"""
size: Float!
"""The number of bytes per sector"""
bytesPerSector: Float!
"""The total number of cylinders on the disk"""
totalCylinders: Float!
"""The total number of heads on the disk"""
totalHeads: Float!
"""The total number of sectors on the disk"""
totalSectors: Float!
"""The total number of tracks on the disk"""
totalTracks: Float!
"""The number of tracks per cylinder"""
tracksPerCylinder: Float!
"""The number of sectors per track"""
sectorsPerTrack: Float!
"""The firmware revision of the disk"""
firmwareRevision: String!
"""The serial number of the disk"""
serialNum: String!
"""The interface type of the disk"""
interfaceType: DiskInterfaceType!
"""The SMART status of the disk"""
smartStatus: DiskSmartStatus!
"""The current temperature of the disk in Celsius"""
temperature: Float
"""The partitions on the disk"""
partitions: [DiskPartition!]!
}
"""The type of interface the disk uses to connect to the system"""
enum DiskInterfaceType {
SAS
SATA
USB
PCIE
UNKNOWN
}
"""
The SMART (Self-Monitoring, Analysis and Reporting Technology) status of the disk
"""
enum DiskSmartStatus {
OK
UNKNOWN
}
type InfoApps implements Node {
id: PrefixedID!
@@ -1106,60 +1351,6 @@ type Owner {
avatar: String!
}
type KeyFile {
location: String
contents: String
}
type Registration implements Node {
id: PrefixedID!
type: registrationType
keyFile: KeyFile
state: RegistrationState
expiration: String
updateExpiration: String
}
enum registrationType {
BASIC
PLUS
PRO
STARTER
UNLEASHED
LIFETIME
INVALID
TRIAL
}
enum RegistrationState {
TRIAL
BASIC
PLUS
PRO
STARTER
UNLEASHED
LIFETIME
EEXPIRED
EGUID
EGUID1
ETRIAL
ENOKEYFILE
ENOKEYFILE1
ENOKEYFILE2
ENOFLASH
ENOFLASH1
ENOFLASH2
ENOFLASH3
ENOFLASH4
ENOFLASH5
ENOFLASH6
ENOFLASH7
EBLACKLISTED
EBLACKLISTED1
EBLACKLISTED2
ENOCONN
}
type ProfileModel implements Node {
id: PrefixedID!
username: String!
@@ -1225,197 +1416,6 @@ type Settings implements Node {
api: ApiConfig!
}
type Vars implements Node {
id: PrefixedID!
"""Unraid version"""
version: String
maxArraysz: Int
maxCachesz: Int
"""Machine hostname"""
name: String
timeZone: String
comment: String
security: String
workgroup: String
domain: String
domainShort: String
hideDotFiles: Boolean
localMaster: Boolean
enableFruit: String
"""Should a NTP server be used for time sync?"""
useNtp: Boolean
"""NTP Server 1"""
ntpServer1: String
"""NTP Server 2"""
ntpServer2: String
"""NTP Server 3"""
ntpServer3: String
"""NTP Server 4"""
ntpServer4: String
domainLogin: String
sysModel: String
sysArraySlots: Int
sysCacheSlots: Int
sysFlashSlots: Int
useSsl: Boolean
"""Port for the webui via HTTP"""
port: Int
"""Port for the webui via HTTPS"""
portssl: Int
localTld: String
bindMgt: Boolean
"""Should telnet be enabled?"""
useTelnet: Boolean
porttelnet: Int
useSsh: Boolean
portssh: Int
startPage: String
startArray: Boolean
spindownDelay: String
queueDepth: String
spinupGroups: Boolean
defaultFormat: String
defaultFsType: String
shutdownTimeout: Int
luksKeyfile: String
pollAttributes: String
pollAttributesDefault: String
pollAttributesStatus: String
nrRequests: Int
nrRequestsDefault: Int
nrRequestsStatus: String
mdNumStripes: Int
mdNumStripesDefault: Int
mdNumStripesStatus: String
mdSyncWindow: Int
mdSyncWindowDefault: Int
mdSyncWindowStatus: String
mdSyncThresh: Int
mdSyncThreshDefault: Int
mdSyncThreshStatus: String
mdWriteMethod: Int
mdWriteMethodDefault: String
mdWriteMethodStatus: String
shareDisk: String
shareUser: String
shareUserInclude: String
shareUserExclude: String
shareSmbEnabled: Boolean
shareNfsEnabled: Boolean
shareAfpEnabled: Boolean
shareInitialOwner: String
shareInitialGroup: String
shareCacheEnabled: Boolean
shareCacheFloor: String
shareMoverSchedule: String
shareMoverLogging: Boolean
fuseRemember: String
fuseRememberDefault: String
fuseRememberStatus: String
fuseDirectio: String
fuseDirectioDefault: String
fuseDirectioStatus: String
shareAvahiEnabled: Boolean
shareAvahiSmbName: String
shareAvahiSmbModel: String
shareAvahiAfpName: String
shareAvahiAfpModel: String
safeMode: Boolean
startMode: String
configValid: Boolean
configError: ConfigErrorState
joinStatus: String
deviceCount: Int
flashGuid: String
flashProduct: String
flashVendor: String
regCheck: String
regFile: String
regGuid: String
regTy: registrationType
regState: RegistrationState
"""Registration owner"""
regTo: String
regTm: String
regTm2: String
regGen: String
sbName: String
sbVersion: String
sbUpdated: String
sbEvents: Int
sbState: String
sbClean: Boolean
sbSynced: Int
sbSyncErrs: Int
sbSynced2: Int
sbSyncExit: String
sbNumDisks: Int
mdColor: String
mdNumDisks: Int
mdNumDisabled: Int
mdNumInvalid: Int
mdNumMissing: Int
mdNumNew: Int
mdNumErased: Int
mdResync: Int
mdResyncCorr: String
mdResyncPos: String
mdResyncDb: String
mdResyncDt: String
mdResyncAction: String
mdResyncSize: Int
mdState: String
mdVersion: String
cacheNumDevices: Int
cacheSbNumDisks: Int
fsState: String
"""Human friendly string of array events happening"""
fsProgress: String
"""
Percentage from 0 - 100 while upgrading a disk or swapping parity drives
"""
fsCopyPrcnt: Int
fsNumMounted: Int
fsNumUnmountable: Int
fsUnmountableMask: String
"""Total amount of user shares"""
shareCount: Int
"""Total amount shares with SMB enabled"""
shareSmbCount: Int
"""Total amount shares with NFS enabled"""
shareNfsCount: Int
"""Total amount shares with AFP enabled"""
shareAfpCount: Int
shareMoverActive: Boolean
csrfToken: String
}
"""Possible error states for configuration"""
enum ConfigErrorState {
UNKNOWN_ERROR
INELIGIBLE
INVALID
NO_KEY_SERVER
WITHDRAWN
}
type VmDomain implements Node {
"""The unique identifier for the vm (uuid)"""
id: PrefixedID!

View File

@@ -94,7 +94,7 @@
"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",
@@ -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",
@@ -153,7 +153,7 @@
}
},
"devDependencies": {
"@eslint/js": "9.30.1",
"@eslint/js": "9.31.0",
"@graphql-codegen/add": "5.0.3",
"@graphql-codegen/cli": "5.0.7",
"@graphql-codegen/fragment-matcher": "5.1.0",
@@ -167,7 +167,7 @@
"@nestjs/testing": "11.1.3",
"@originjs/vite-plugin-commonjs": "1.0.3",
"@rollup/plugin-node-resolve": "16.0.1",
"@swc/core": "1.12.11",
"@swc/core": "1.12.14",
"@types/async-exit-hook": "2.0.2",
"@types/bytes": "3.1.5",
"@types/cli-table": "0.3.4",
@@ -181,7 +181,7 @@
"@types/lodash": "4.17.20",
"@types/lodash-es": "4.17.12",
"@types/mustache": "4.2.6",
"@types/node": "22.16.3",
"@types/node": "22.16.4",
"@types/pify": "6.1.0",
"@types/semver": "7.7.0",
"@types/sendmail": "1.4.7",
@@ -193,7 +193,7 @@
"@vitest/coverage-v8": "3.2.4",
"@vitest/ui": "3.2.4",
"cz-conventional-changelog": "3.3.0",
"eslint": "9.30.1",
"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",
@@ -207,13 +207,13 @@
"tsx": "4.20.3",
"type-fest": "4.41.0",
"typescript": "5.8.3",
"typescript-eslint": "8.36.0",
"typescript-eslint": "8.37.0",
"unplugin-swc": "1.5.5",
"vite": "7.0.4",
"vite-plugin-node": "7.0.0",
"vite-tsconfig-paths": "5.1.4",
"vitest": "3.2.4",
"zx": "8.6.2"
"zx": "8.7.1"
},
"overrides": {
"eslint": {

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

@@ -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

@@ -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,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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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": {

View File

@@ -41,7 +41,7 @@
"@types/ini": "4.1.1",
"@types/ip": "1.1.3",
"@types/lodash-es": "4.17.12",
"@types/node": "22.16.3",
"@types/node": "22.16.4",
"@types/ws": "8.18.1",
"camelcase-keys": "9.1.3",
"class-transformer": "0.5.1",
@@ -52,7 +52,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",
@@ -91,7 +91,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",

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

@@ -25,7 +25,7 @@
"@nestjs/graphql": "13.1.0",
"@types/fs-extra": "11.0.4",
"@types/inquirer": "9.0.8",
"@types/node": "22.16.3",
"@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

@@ -33,7 +33,7 @@
"@nestjs/graphql": "13.1.0",
"@types/bun": "1.2.18",
"@types/lodash-es": "4.17.12",
"@types/node": "22.16.3",
"@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

@@ -11,7 +11,7 @@
"semver": "7.7.2",
"tsx": "4.20.3",
"zod": "3.25.76",
"zx": "8.6.2"
"zx": "8.7.1"
},
"type": "module",
"license": "GPL-2.0-or-later",

View File

@@ -39,6 +39,7 @@ class WebComponentsExtractor
return $contents ? json_decode($contents, true) : [];
}
private function getRichComponentsFile(): string
{
$manifestFiles = $this->findManifestFiles('manifest.json');

3670
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

@@ -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,6 +52,7 @@
"@jsonforms/core": "3.6.0",
"@jsonforms/vue": "3.6.0",
"@jsonforms/vue-vanilla": "3.6.0",
"@tailwindcss/cli": "4.1.11",
"@vueuse/core": "13.5.0",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
@@ -64,48 +63,44 @@
"reka-ui": "2.3.2",
"shadcn-vue": "2.2.0",
"tailwind-merge": "2.6.0",
"tw-animate-css": "1.3.5",
"vue-sonner": "1.3.2"
},
"devDependencies": {
"@ianvs/prettier-plugin-sort-imports": "4.5.1",
"@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",
"@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.16.3",
"@types/node": "22.16.4",
"@types/testing-library__vue": "5.3.0",
"@typescript-eslint/eslint-plugin": "8.36.0",
"@unraid/tailwind-rem-to-rem": "1.1.0",
"@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.2.0",
"eslint": "9.30.1",
"eslint": "9.31.0",
"eslint-config-prettier": "10.1.5",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-no-relative-import-paths": "1.6.1",
"eslint-plugin-prettier": "5.5.1",
"eslint-plugin-storybook": "9.0.16",
"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.6.2",
"prettier-plugin-tailwindcss": "0.6.14",
"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.36.0",
"typescript-eslint": "8.37.0",
"vite": "7.0.4",
"vite-plugin-dts": "3.9.1",
"vite-plugin-vue-devtools": "7.7.7",
@@ -115,7 +110,7 @@
"wrangler": "4.24.3"
},
"optionalDependencies": {
"@rollup/rollup-linux-x64-gnu": "4.44.2"
"@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.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: {

View File

@@ -35,7 +35,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
v-bind="{ ...forwarded, ...$attrs }"
:class="
cn(
'z-50 w-72 rounded-md bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
'z-50 w-72 rounded-md bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
props.class
)
"

View File

@@ -46,12 +46,12 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
<template>
<DialogPortal :disabled="disabled" :force-mount="forceMount" :to="teleportTarget">
<DialogOverlay
class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
class="fixed inset-0 z-50 bg-black/60 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
/>
<DialogContent :class="sheetClass" v-bind="forwarded">
<slot />
<DialogClose
class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary"
class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary"
>
<X class="w-4 h-4 text-muted-foreground" />
</DialogClose>

View File

@@ -22,7 +22,7 @@ const forwarded = useForwardProps(delegatedProps);
cn(
'inline-flex items-center justify-center rounded-full text-muted-foreground/50 w-10 h-10',
// Disabled
'group-data-[disabled]:text-muted-foreground group-data-[disabled]:opacity-50',
'group-data-disabled:text-muted-foreground group-data-disabled:opacity-50',
// Active
'group-data-[state=active]:bg-primary group-data-[state=active]:text-primary-foreground',
// Completed

View File

@@ -24,7 +24,7 @@ const forwarded = useForwardProps(delegatedProps);
'flex flex-col items-start gap-1',
'md:flex-row md:items-center md:gap-2',
'group transition-all duration-200',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-80',
'data-disabled:pointer-events-none data-disabled:opacity-80',
props.class
)
"

View File

@@ -22,7 +22,7 @@ const forwarded = useForwardProps(delegatedProps);
cn(
'hidden md:block bg-muted md:w-24 md:h-px md:my-0',
// Disabled
'group-data-[disabled]:bg-muted group-data-[disabled]:opacity-75',
'group-data-disabled:bg-muted group-data-disabled:opacity-75',
// Completed
'group-data-[state=completed]:bg-accent-foreground',
props.class

View File

@@ -16,7 +16,7 @@ const delegatedProps = computed(() => {
v-bind="delegatedProps"
:class="
cn(
'flex mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'flex mt-2 ring-offset-background focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
props.class
)
"

View File

@@ -19,7 +19,7 @@ const forwardedProps = useForwardProps(delegatedProps);
v-bind="forwardedProps"
:class="
cn(
'inline-flex items-center justify-center whitespace-nowrap rounded px-4.5 py-2.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
'inline-flex items-center justify-center whitespace-nowrap rounded px-4.5 py-2.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-xs',
props.class
)
"

View File

@@ -30,7 +30,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
v-bind="forwarded"
:class="
cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
props.class
)
"

View File

@@ -21,7 +21,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
v-bind="forwarded"
:class="
cn(
'relative flex cursor-default gap-2 select-none justify-between items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0',
'relative flex cursor-default gap-2 select-none justify-between items-center rounded-sm px-2 py-1.5 text-sm outline-hidden data-highlighted:bg-accent data-highlighted:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0',
props.class
)
"

View File

@@ -33,7 +33,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
v-bind="{ ...forwarded, ...$attrs }"
:class="
cn(
'z-50 w-[200px] rounded-md border bg-popover text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
'z-50 w-[200px] rounded-md border bg-popover text-popover-foreground shadow-md outline-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
props.class
@@ -45,7 +45,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
cn(
'p-1',
position === 'popper' &&
'h-[--reka-combobox-trigger-height] w-full min-w-[--reka-combobox-trigger-width]'
'h-(--reka-combobox-trigger-height) w-full min-w-(--reka-combobox-trigger-width)'
)
"
>

View File

@@ -24,7 +24,7 @@ const modelValue = useVModel(props, 'modelValue', emits, {
v-model="modelValue"
:class="
cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
props.class
)
"

View File

@@ -13,18 +13,18 @@ const checked = ref(false);
</script>
<template>
<SwitchGroup as="div">
<div class="flex flex-shrink-0 items-center gap-16px">
<div class="flex shrink-0 items-center gap-4">
<Switch
v-model="checked"
:class="[
checked ? 'bg-green-500' : 'bg-gray-200',
'relative inline-flex h-24px w-[44px] flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2',
'relative inline-flex h-6 w-[44px] shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-hidden focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2',
]"
>
<span
:class="[
checked ? 'translate-x-20px' : 'translate-x-0',
'pointer-events-none relative inline-block h-20px w-20px transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
checked ? 'translate-x-5' : 'translate-x-0',
'pointer-events-none relative inline-block h-5 w-5 transform rounded-full bg-white shadow-sm ring-0 transition duration-200 ease-in-out',
]"
>
<span
@@ -34,7 +34,7 @@ const checked = ref(false);
]"
aria-hidden="true"
>
<svg class="h-12px w-12px text-gray-400" fill="none" viewBox="0 0 12 12">
<svg class="h-3 w-3 text-gray-400" fill="none" viewBox="0 0 12 12">
<path
d="M4 8l2-2m0 0l2-2M6 6L4 4m2 2l2 2"
stroke="currentColor"
@@ -51,7 +51,7 @@ const checked = ref(false);
]"
aria-hidden="true"
>
<svg class="h-12px w-12px text-green-500" fill="currentColor" viewBox="0 0 12 12">
<svg class="h-3 w-3 text-green-500" fill="currentColor" viewBox="0 0 12 12">
<path
d="M3.707 5.293a1 1 0 00-1.414 1.414l1.414-1.414zM5 8l-.707.707a1 1 0 001.414 0L5 8zm4.707-3.293a1 1 0 00-1.414-1.414l1.414 1.414zm-7.414 2l2 2 1.414-1.414-2-2-1.414 1.414zm3.414 2l4-4-1.414-1.414-4 4 1.414 1.414z"
/>
@@ -59,7 +59,7 @@ const checked = ref(false);
</span>
</span>
</Switch>
<SwitchLabel class="text-14px">
<SwitchLabel class="text-sm">
{{ label }}
</SwitchLabel>
</div>

View File

@@ -12,7 +12,7 @@ const props = defineProps<{
<div
:class="
cn(
'relative [&>[data-slot=input]]:has-[[data-slot=increment]]:pr-5 [&>[data-slot=input]]:has-[[data-slot=decrement]]:pl-5',
'relative has-data-[slot=increment]:*:data-[slot=input]:pr-5 has-data-[slot=decrement]:*:data-[slot=input]:pl-5',
props.class
)
"

View File

@@ -13,7 +13,7 @@ const props = defineProps<{
data-slot="input"
:class="
cn(
'flex h-10 w-full rounded-md border border-input bg-background py-2 text-sm text-center ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
'flex h-10 w-full rounded-md border border-input bg-background py-2 text-sm text-center ring-offset-background placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
props.class
)
"

View File

@@ -9,12 +9,11 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { AcceptableValue } from 'reka-ui';
import { computed } from 'vue';
type SelectValueType = string | number;
type AcceptableValue = SelectValueType | SelectValueType[] | Record<string, unknown> | bigint | null;
interface SelectItemInterface {
label: string;
value: SelectValueType;

View File

@@ -27,7 +27,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
v-bind="forwarded"
:class="
cn(
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
props.class
)
"

View File

@@ -4,13 +4,13 @@ withDefaults(
maxWidth?: string;
}>(),
{
maxWidth: 'max-w-1024px',
maxWidth: 'max-w-[1024px]',
}
);
</script>
<template>
<div class="grid gap-y-24px w-full mx-auto px-16px" :class="maxWidth">
<div class="grid gap-y-6 w-full mx-auto px-4" :class="maxWidth">
<slot />
</div>
</template>

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import { cn } from '@/lib/utils';
const props = defineProps<{
class?: string;
}>();
</script>
<template>
<div
:class="cn('settings-grid grid gap-2 items-baseline md:pl-3 md:gap-x-10 md:gap-y-6', props.class)"
>
<slot />
</div>
</template>
<style>
.settings-grid {
grid-template-columns: 1fr;
}
@media (min-width: 768px) {
.settings-grid {
grid-template-columns: 35% 1fr;
}
.settings-grid > *:nth-child(odd) {
text-align: end;
}
}
</style>

View File

@@ -1,4 +1,5 @@
import CardWrapper from '@/components/layout/CardWrapper.vue';
import PageContainer from '@/components/layout/PageContainer.vue';
import SettingsGrid from '@/components/layout/SettingsGrid.vue';
export { CardWrapper, PageContainer };
export { CardWrapper, PageContainer, SettingsGrid };

View File

@@ -44,7 +44,7 @@ const { teleportTarget } = useTeleport();
<DialogClose
v-if="showCloseButton !== false"
class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"
class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"
>
<X class="w-4 h-4" />
<span class="sr-only">Close</span>

View File

@@ -24,7 +24,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
v-bind="forwarded"
:class="
cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-hidden transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',
props.class
)
"

View File

@@ -18,7 +18,7 @@ const forwardedProps = useForwardProps(delegatedProps);
v-bind="forwardedProps"
:class="
cn(
'relative flex cursor-default select-none items-center rounded-sm gap-2 px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0',
'relative flex cursor-default select-none items-center rounded-sm gap-2 px-2 py-1.5 text-sm outline-hidden transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0',
inset && 'pl-8',
props.class
)

View File

@@ -25,7 +25,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
v-bind="forwarded"
:class="
cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-hidden transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',
props.class
)
"

View File

@@ -17,7 +17,7 @@ const forwardedProps = useForwardProps(delegatedProps);
v-bind="forwardedProps"
:class="
cn(
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent',
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent data-[state=open]:bg-accent',
props.class
)
"

View File

@@ -8,7 +8,7 @@ const forwardedProps = useForwardProps(props);
<template>
<DropdownMenuTrigger
class="outline-none cursor-pointer [&[data-state=open]]:cursor-pointer"
class="outline-hidden cursor-pointer data-[state=open]:cursor-pointer"
v-bind="forwardedProps"
>
<slot />

View File

@@ -48,7 +48,7 @@ const { teleportTarget } = useTeleport();
cn(
'p-1',
position === 'popper' &&
'h-[--reka-select-trigger-height] w-full min-w-[--reka-select-trigger-width]'
'h-(--reka-select-trigger-height) w-full min-w-(--reka-select-trigger-width)'
)
"
>

View File

@@ -23,7 +23,7 @@ const forwardedProps = useForwardProps(delegatedProps);
v-bind="forwardedProps"
:class="
cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',
props.class
)
"

View File

@@ -17,7 +17,7 @@ const forwardedProps = useForwardProps(delegatedProps);
v-bind="forwardedProps"
:class="
cn(
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:truncate text-start',
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background data-placeholder:text-muted-foreground focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:truncate text-start',
props.class
)
"

View File

@@ -99,7 +99,7 @@ if (control.value.data !== undefined && control.value.data !== null) {
<ComboboxItem
:value="suggestion.value"
@select="handleSelect"
class="relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground"
class="relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden hover:bg-accent hover:text-accent-foreground data-highlighted:bg-accent data-highlighted:text-accent-foreground"
>
<span>{{ suggestion.label || suggestion.value }}</span>
</ComboboxItem>
@@ -113,7 +113,7 @@ if (control.value.data !== undefined && control.value.data !== null) {
v-else
:value="suggestion.value"
@select="handleSelect"
class="relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground"
class="relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden hover:bg-accent hover:text-accent-foreground data-highlighted:bg-accent data-highlighted:text-accent-foreground"
>
<span>{{ suggestion.label || suggestion.value }}</span>
</ComboboxItem>

View File

@@ -13,7 +13,7 @@ const { control } = useJsonFormsControl(props);
<template>
<!-- Only render the wrapper if the control is visible -->
<div v-if="control.visible" class="flex-grow">
<div v-if="control.visible" class="grow">
<!-- Render the actual control passed via the default slot -->
<slot />
<!-- Automatically display errors below the control -->

View File

@@ -46,7 +46,7 @@ const togglePasswordVisibility = () => {
<Input
v-model="value"
:type="inputType"
:class="cn('flex-grow', classOverride, { 'pr-10': isPassword })"
:class="cn('grow', classOverride, { 'pr-10': isPassword })"
:disabled="!control.enabled"
:required="control.required"
:placeholder="control.schema.description"

View File

@@ -70,7 +70,7 @@ const descriptionClass = computed(() => {
<template>
<!-- Use the computed isVisible based on renderer.value.visible -->
<div class="flex flex-col gap-2 flex-shrink-0">
<div class="flex flex-col gap-2 shrink-0">
<!-- Replace native label with the Label component -->
<Label v-if="labelText" :class="labelClass">{{ labelText }}</Label>
<!-- Use v-html with the parsedDescription ref -->

View File

@@ -145,7 +145,10 @@ const getStepState = (stepIndex: number): StepState => {
<!-- Render elements for the current step -->
<!-- Added key to force re-render on step change, ensuring correct elements display -->
<div class="current-step-content rounded-md border p-4 shadow" :key="`step-content-${currentStep}`">
<div
class="current-step-content rounded-md border p-4 shadow-sm"
:key="`step-content-${currentStep}`"
>
<DispatchRenderer
v-for="(element, index) in currentStepElements"
:key="`${layout.path}-${index}-step-${currentStep}`"

Some files were not shown because too many files have changed in this diff Show More