From 2bc961733f49ad47b43272c1061e5cc0c96a4911 Mon Sep 17 00:00:00 2001 From: axi Date: Tue, 7 Jul 2026 20:11:20 +0300 Subject: [PATCH 01/12] Add Listmonk integration with PostgreSQL support - Implemented `CommunityToolkit.Aspire.Hosting.Listmonk` for Listmonk container integration. - Introduced extension methods for configuring Listmonk containers, environment variables, volumes, and annotations. - Added test projects for validating Listmonk configurations and functionalities. - Created example projects under `examples/listmonk` to demonstrate integration and usage. - Updated solution and centralized package references to include the new Listmonk project. --- .../apphost.mts | 38 + .../aspire.config.json | 21 + .../eslint.config.mjs | 5 + .../package-lock.json | 2044 +++++++++++++++++ .../package.json | 28 + .../tsconfig.json | 20 + ...kit.Aspire.Hosting.Listmonk.AppHost.csproj | 15 + .../Program.cs | 21 + .../Properties/launchSettings.json | 10 + .../appsettings.json | 3 + ...nityToolkit.Aspire.Hosting.Listmonk.csproj | 14 + .../ListmonkBuilderExtensions.cs | 341 +++ .../ListmonkContainerImageTags.cs | 11 + .../ListmonkResource.cs | 58 + .../README.md | 72 + .../AddListmonkTests.cs | 190 ++ .../AppHostTests.cs | 17 + ...olkit.Aspire.Hosting.Listmonk.Tests.csproj | 22 + .../ListmonkPublicApiTests.cs | 216 ++ .../TypeScriptAppHostTests.cs | 18 + 20 files changed, 3164 insertions(+) create mode 100644 examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost.TypeScript/apphost.mts create mode 100644 examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost.TypeScript/aspire.config.json create mode 100644 examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost.TypeScript/eslint.config.mjs create mode 100644 examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost.TypeScript/package-lock.json create mode 100644 examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost.TypeScript/package.json create mode 100644 examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost.TypeScript/tsconfig.json create mode 100644 examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost.csproj create mode 100644 examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost/Program.cs create mode 100644 examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost/Properties/launchSettings.json create mode 100644 examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost/appsettings.json create mode 100644 src/CommunityToolkit.Aspire.Hosting.Listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.csproj create mode 100644 src/CommunityToolkit.Aspire.Hosting.Listmonk/ListmonkBuilderExtensions.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Listmonk/ListmonkContainerImageTags.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Listmonk/ListmonkResource.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Listmonk/README.md create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Listmonk.Tests/AddListmonkTests.cs create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Listmonk.Tests/AppHostTests.cs create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Listmonk.Tests/CommunityToolkit.Aspire.Hosting.Listmonk.Tests.csproj create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Listmonk.Tests/ListmonkPublicApiTests.cs create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Listmonk.Tests/TypeScriptAppHostTests.cs diff --git a/examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost.TypeScript/apphost.mts b/examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost.TypeScript/apphost.mts new file mode 100644 index 000000000..606560fb5 --- /dev/null +++ b/examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost.TypeScript/apphost.mts @@ -0,0 +1,38 @@ +import { createBuilder } from "./.aspire/modules/aspire.mjs"; + +const builder = await createBuilder(); + +const password = await builder.addParameter("db-password", { + value: "12345678", + secret: true, +}); +const adminPassword = await builder.addParameter("admin-password", { + value: "SuperSecret123!", + secret: true, +}); +const postgres = await builder.addPostgres("postgres", { password }); +const listmonkDb = await postgres.addDatabase("listmonkdb"); + +const listmonk = await builder.addListmonk("listmonk", { + port: 31900, +}); +const listmonkDefault = await builder.addListmonk("listmonk-default"); + +await listmonk.withPostgreSQL(listmonkDb); +await listmonk.withAdminCredentials("admin", adminPassword); +await listmonk.withDatabaseMaxOpenConnections(25); +await listmonk.withDatabaseMaxIdleConnections(25); +await listmonk.withDatabaseMaxLifetime("300s"); +await listmonk.withTimeZone("Etc/UTC"); +await listmonk.withUserAndGroupId(0, 0); +await listmonk.withUploadsVolume(); +await listmonkDefault.withPostgreSQL(listmonkDb); +await listmonkDefault.withDatabaseSslMode("disable"); + +const _primaryEndpoint = await listmonk.primaryEndpoint(); +const _host = await listmonk.host(); +const _port = await listmonk.port(); +const _uriExpression = await listmonk.uriExpression(); +const _connectionStringExpression = await listmonk.connectionStringExpression(); + +await builder.build().run(); diff --git a/examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost.TypeScript/aspire.config.json b/examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost.TypeScript/aspire.config.json new file mode 100644 index 000000000..b6bbd5290 --- /dev/null +++ b/examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost.TypeScript/aspire.config.json @@ -0,0 +1,21 @@ +{ + "appHost": { + "path": "apphost.mts", + "language": "typescript/nodejs" + }, + "sdk": { + "version": "13.4.3" + }, + "profiles": { + "https": { + "applicationUrl": "https://localhost:29750;http://localhost:28931", + "environmentVariables": { + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:10975", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:13319" + } + } + }, + "packages": { + "CommunityToolkit.Aspire.Hosting.Listmonk": "../../../src/CommunityToolkit.Aspire.Hosting.Listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.csproj" + } +} diff --git a/examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost.TypeScript/eslint.config.mjs b/examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost.TypeScript/eslint.config.mjs new file mode 100644 index 000000000..74a2679ab --- /dev/null +++ b/examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost.TypeScript/eslint.config.mjs @@ -0,0 +1,5 @@ +import tseslint from "typescript-eslint"; + +export default tseslint.config( + ...tseslint.configs.recommended, +); diff --git a/examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost.TypeScript/package-lock.json b/examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost.TypeScript/package-lock.json new file mode 100644 index 000000000..e70781aef --- /dev/null +++ b/examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost.TypeScript/package-lock.json @@ -0,0 +1,2044 @@ +{ + "name": "validationapphost", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "validationapphost", + "version": "1.0.0", + "dependencies": { + "vscode-jsonrpc": "^8.2.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "eslint": "^10.0.3", + "nodemon": "^3.1.14", + "tsx": "^4.21.0", + "typescript": "^5.9.3", + "typescript-eslint": "^8.57.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.3", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz", + "integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.3", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.3.tgz", + "integrity": "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.1.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz", + "integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz", + "integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz", + "integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.1.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", + "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz", + "integrity": "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/type-utils": "8.57.2", + "@typescript-eslint/utils": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.57.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.2.tgz", + "integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.2.tgz", + "integrity": "sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.57.2", + "@typescript-eslint/types": "^8.57.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.2.tgz", + "integrity": "sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.2.tgz", + "integrity": "sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.2.tgz", + "integrity": "sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/utils": "8.57.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz", + "integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.2.tgz", + "integrity": "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.57.2", + "@typescript-eslint/tsconfig-utils": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.2.tgz", + "integrity": "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.2.tgz", + "integrity": "sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.1.0.tgz", + "integrity": "sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.3", + "@eslint/config-helpers": "^0.5.3", + "@eslint/core": "^1.1.1", + "@eslint/plugin-kit": "^0.6.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nodemon": { + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^10.2.1", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.2.tgz", + "integrity": "sha512-VEPQ0iPgWO/sBaZOU1xo4nuNdODVOajPnTIbog2GKYr31nIlZ0fWPoCQgGfF3ETyBl1vn63F/p50Um9Z4J8O8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.57.2", + "@typescript-eslint/parser": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/utils": "8.57.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz", + "integrity": "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost.TypeScript/package.json b/examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost.TypeScript/package.json new file mode 100644 index 000000000..ae384cfc8 --- /dev/null +++ b/examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost.TypeScript/package.json @@ -0,0 +1,28 @@ +{ + "name": "validationapphost", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "lint": "eslint apphost.mts", + "predev": "npm run lint", + "dev": "aspire run", + "prebuild": "npm run lint", + "build": "tsc", + "watch": "tsc --watch" + }, + "dependencies": { + "vscode-jsonrpc": "^8.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "eslint": "^10.0.3", + "nodemon": "^3.1.14", + "tsx": "^4.21.0", + "typescript": "^5.9.3", + "typescript-eslint": "^8.57.1" + } +} diff --git a/examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost.TypeScript/tsconfig.json b/examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost.TypeScript/tsconfig.json new file mode 100644 index 000000000..e70fa1fa2 --- /dev/null +++ b/examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost.TypeScript/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "outDir": "./dist", + "rootDir": "." + }, + "include": [ + "apphost.mts", + ".aspire/modules/**/*.ts" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost.csproj b/examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost.csproj new file mode 100644 index 000000000..fbd35058d --- /dev/null +++ b/examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost.csproj @@ -0,0 +1,15 @@ + + + + Exe + + + + + + + + + + + diff --git a/examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost/Program.cs b/examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost/Program.cs new file mode 100644 index 000000000..8ade49281 --- /dev/null +++ b/examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost/Program.cs @@ -0,0 +1,21 @@ +var builder = DistributedApplication.CreateBuilder(args); + +var password = builder.AddParameter("db-password", "12345678"); +var adminPassword = builder.AddParameter("admin-password", "SuperSecret123!", secret: true); + +var postgres = builder + .AddPostgres("postgres", password: password) + .WithLifetime(ContainerLifetime.Persistent); + +var listmonkDb = postgres.AddDatabase("listmonkdb"); + +builder.AddListmonk("listmonk") + .WithPostgreSQL(listmonkDb) + .WithAdminCredentials("admin", adminPassword) + .WithDatabaseMaxOpenConnections(25) + .WithDatabaseMaxIdleConnections(25) + .WithDatabaseMaxLifetime("300s") + .WithTimeZone("Etc/UTC") + .WithUploadsVolume(); + +builder.Build().Run(); diff --git a/examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost/Properties/launchSettings.json b/examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost/Properties/launchSettings.json new file mode 100644 index 000000000..574d6f4e9 --- /dev/null +++ b/examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost/Properties/launchSettings.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "launchBrowser": true, + "dotnetRunMessages": true + } + } +} diff --git a/examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost/appsettings.json b/examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost/appsettings.json new file mode 100644 index 000000000..4130b2429 --- /dev/null +++ b/examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost/appsettings.json @@ -0,0 +1,3 @@ +{ + "$schema": "https://json.schemastore.org/appsettings.json" +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.csproj b/src/CommunityToolkit.Aspire.Hosting.Listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.csproj new file mode 100644 index 000000000..8987eefa0 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.csproj @@ -0,0 +1,14 @@ + + + + hosting listmonk newsletter email marketing + listmonk support for Aspire. + + + + + + + + + diff --git a/src/CommunityToolkit.Aspire.Hosting.Listmonk/ListmonkBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Listmonk/ListmonkBuilderExtensions.cs new file mode 100644 index 000000000..acf92e947 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Listmonk/ListmonkBuilderExtensions.cs @@ -0,0 +1,341 @@ +using Aspire.Hosting.ApplicationModel; +using CommunityToolkit.Aspire.Hosting.Listmonk; + +#pragma warning disable ASPIREATS001 // AspireExport is experimental + +namespace Aspire.Hosting; + +/// +/// Provides extension methods for adding listmonk resources to the application model. +/// +public static class ListmonkBuilderExtensions +{ + private const int ListmonkPort = 9000; + private const string UploadsPath = "/listmonk/uploads"; + private const string AppAddressEnvVarName = "LISTMONK_app__address"; + private const string DatabaseHostEnvVarName = "LISTMONK_db__host"; + private const string DatabasePortEnvVarName = "LISTMONK_db__port"; + private const string DatabaseUserEnvVarName = "LISTMONK_db__user"; + private const string DatabasePasswordEnvVarName = "LISTMONK_db__password"; + private const string DatabaseNameEnvVarName = "LISTMONK_db__database"; + private const string DatabaseSslModeEnvVarName = "LISTMONK_db__ssl_mode"; + private const string DatabaseMaxOpenEnvVarName = "LISTMONK_db__max_open"; + private const string DatabaseMaxIdleEnvVarName = "LISTMONK_db__max_idle"; + private const string DatabaseMaxLifetimeEnvVarName = "LISTMONK_db__max_lifetime"; + private const string DatabaseParamsEnvVarName = "LISTMONK_db__params"; + private const string TimeZoneEnvVarName = "TZ"; + private const string AdminUserEnvVarName = "LISTMONK_ADMIN_USER"; + private const string AdminPasswordEnvVarName = "LISTMONK_ADMIN_PASSWORD"; + private const string UserIdEnvVarName = "PUID"; + private const string GroupIdEnvVarName = "PGID"; + + /// + /// Adds a listmonk container resource to the application model. + /// The default image is and the tag is . + /// + /// The . + /// The name of the resource. This name will be used as the connection string name when referenced in a dependency. + /// The host port for the listmonk HTTP endpoint. If , Aspire will assign a random host port. + /// A reference to the . + /// + /// + /// Add a listmonk container to the application model and reference it in a .NET project. + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// + /// var db = builder.AddPostgres("postgres") + /// .AddDatabase("listmonk"); + /// var listmonk = builder.AddListmonk("listmonk") + /// .WithPostgreSQL(db); + /// var api = builder.AddProject<Projects.Api>("api") + /// .WithReference(listmonk); + /// + /// builder.Build().Run(); + /// + /// + /// + [AspireExport] + public static IResourceBuilder AddListmonk( + this IDistributedApplicationBuilder builder, + [ResourceName] string name, + int? port = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + + var resource = new ListmonkResource(name); + + return builder.AddResource(resource) + .WithImage(ListmonkContainerImageTags.Image, ListmonkContainerImageTags.Tag) + .WithImageRegistry(ListmonkContainerImageTags.Registry) + .WithHttpEndpoint(port: port, targetPort: ListmonkPort, name: ListmonkResource.PrimaryEndpointName) + .WithEntrypoint("sh") + .WithArgs("-c", "./listmonk --install --idempotent --yes --config '' && ./listmonk --upgrade --yes --config '' && ./listmonk --config ''") + .WithEnvironment(AppAddressEnvVarName, "0.0.0.0:9000") + .WithHttpHealthCheck("/"); + } + + /// + /// Configures the listmonk web server address. + /// + /// The listmonk resource builder. + /// The address value for LISTMONK_app__address, for example 0.0.0.0:9000. + /// The . + [AspireExport] + public static IResourceBuilder WithAppAddress(this IResourceBuilder builder, string address) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(address); + + return builder.WithEnvironment(AppAddressEnvVarName, address); + } + + /// + /// References a as the storage backend for the . + /// + /// Configures PostgreSQL as the storage backend for listmonk. + /// The listmonk resource builder. + /// The PostgreSQL database resource builder. + /// A reference to the . + [AspireExport] + public static IResourceBuilder WithPostgreSQL( + this IResourceBuilder builder, + IResourceBuilder database) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(database); + + var postgres = database.Resource.Parent; + + return builder + .WithEnvironment(DatabaseHostEnvVarName, postgres.Name) + .WithEnvironment(DatabasePortEnvVarName, ReferenceExpression.Create($"{postgres.PrimaryEndpoint.Property(EndpointProperty.TargetPort)}")) + .WithEnvironment(DatabaseUserEnvVarName, postgres.UserNameReference) + .WithEnvironment(DatabasePasswordEnvVarName, postgres.PasswordParameter) + .WithEnvironment(DatabaseNameEnvVarName, database.Resource.DatabaseName) + .WithDatabaseSslMode("disable") + .WaitFor(database); + } + + /// + /// Configures the PostgreSQL SSL mode used by listmonk. + /// + /// The listmonk resource builder. + /// The PostgreSQL SSL mode value for LISTMONK_db__ssl_mode. + /// The . + [AspireExport] + public static IResourceBuilder WithDatabaseSslMode(this IResourceBuilder builder, string sslMode) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(sslMode); + + return builder.WithEnvironment(DatabaseSslModeEnvVarName, sslMode); + } + + /// + /// Configures the maximum number of open PostgreSQL connections. + /// + /// The listmonk resource builder. + /// The value for LISTMONK_db__max_open. + /// The . + [AspireExport] + public static IResourceBuilder WithDatabaseMaxOpenConnections(this IResourceBuilder builder, int maxOpen) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentOutOfRangeException.ThrowIfNegative(maxOpen); + + return builder.WithEnvironment(DatabaseMaxOpenEnvVarName, maxOpen.ToString()); + } + + /// + /// Configures the maximum number of idle PostgreSQL connections. + /// + /// The listmonk resource builder. + /// The value for LISTMONK_db__max_idle. + /// The . + [AspireExport] + public static IResourceBuilder WithDatabaseMaxIdleConnections(this IResourceBuilder builder, int maxIdle) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentOutOfRangeException.ThrowIfNegative(maxIdle); + + return builder.WithEnvironment(DatabaseMaxIdleEnvVarName, maxIdle.ToString()); + } + + /// + /// Configures the maximum lifetime for PostgreSQL connections. + /// + /// The listmonk resource builder. + /// The duration value for LISTMONK_db__max_lifetime, for example 300s. + /// The . + [AspireExport] + public static IResourceBuilder WithDatabaseMaxLifetime(this IResourceBuilder builder, string maxLifetime) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(maxLifetime); + + return builder.WithEnvironment(DatabaseMaxLifetimeEnvVarName, maxLifetime); + } + + /// + /// Configures additional PostgreSQL DSN parameters. + /// + /// The listmonk resource builder. + /// The space-separated parameter string for LISTMONK_db__params. + /// The . + [AspireExport] + public static IResourceBuilder WithDatabaseParameters(this IResourceBuilder builder, string parameters) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(parameters); + + return builder.WithEnvironment(DatabaseParamsEnvVarName, parameters); + } + + /// + /// Configures the time zone used by the listmonk container. + /// + /// The listmonk resource builder. + /// The time zone value for TZ, for example Etc/UTC. + /// The . + [AspireExport] + public static IResourceBuilder WithTimeZone(this IResourceBuilder builder, string timeZone) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(timeZone); + + return builder.WithEnvironment(TimeZoneEnvVarName, timeZone); + } + + /// + /// Configures the first-run listmonk Super Admin username. + /// + /// The listmonk resource builder. + /// The value for LISTMONK_ADMIN_USER. + /// The . + [AspireExport] + public static IResourceBuilder WithAdminUser(this IResourceBuilder builder, string username) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(username); + + return builder.WithEnvironment(AdminUserEnvVarName, username); + } + + /// + /// Configures the first-run listmonk Super Admin password. + /// + /// The listmonk resource builder. + /// The parameter resource used for LISTMONK_ADMIN_PASSWORD. + /// The . + [AspireExport] + public static IResourceBuilder WithAdminPassword(this IResourceBuilder builder, IResourceBuilder password) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(password); + + return builder.WithEnvironment(AdminPasswordEnvVarName, password.Resource); + } + + /// + /// Configures the first-run listmonk Super Admin credentials. + /// + /// The listmonk resource builder. + /// The value for LISTMONK_ADMIN_USER. + /// The parameter resource used for LISTMONK_ADMIN_PASSWORD. + /// The . + [AspireExport] + public static IResourceBuilder WithAdminCredentials( + this IResourceBuilder builder, + string username, + IResourceBuilder password) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(username); + ArgumentNullException.ThrowIfNull(password); + + return builder + .WithAdminUser(username) + .WithAdminPassword(password); + } + + /// + /// Configures the user ID used by the listmonk container entrypoint. + /// + /// The listmonk resource builder. + /// The value for PUID. + /// The . + [AspireExport] + public static IResourceBuilder WithUserId(this IResourceBuilder builder, int userId) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentOutOfRangeException.ThrowIfNegative(userId); + + return builder.WithEnvironment(UserIdEnvVarName, userId.ToString()); + } + + /// + /// Configures the group ID used by the listmonk container entrypoint. + /// + /// The listmonk resource builder. + /// The value for PGID. + /// The . + [AspireExport] + public static IResourceBuilder WithGroupId(this IResourceBuilder builder, int groupId) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentOutOfRangeException.ThrowIfNegative(groupId); + + return builder.WithEnvironment(GroupIdEnvVarName, groupId.ToString()); + } + + /// + /// Configures the user and group IDs used by the listmonk container entrypoint. + /// + /// The listmonk resource builder. + /// The value for PUID. + /// The value for PGID. + /// The . + [AspireExport] + public static IResourceBuilder WithUserAndGroupId(this IResourceBuilder builder, int userId, int groupId) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentOutOfRangeException.ThrowIfNegative(userId); + ArgumentOutOfRangeException.ThrowIfNegative(groupId); + + return builder + .WithUserId(userId) + .WithGroupId(groupId); + } + + /// + /// Adds a named volume for listmonk media uploads. + /// + /// The listmonk resource builder. + /// The name of the volume. Defaults to an auto-generated name based on the application and resource names. + /// The . + [AspireExport] + public static IResourceBuilder WithUploadsVolume(this IResourceBuilder builder, string? name = null) + { + ArgumentNullException.ThrowIfNull(builder); + + return builder.WithVolume(name ?? VolumeNameGenerator.Generate(builder, "uploads"), UploadsPath); + } + + /// + /// Adds a bind mount for listmonk media uploads. + /// + /// The listmonk resource builder. + /// The source directory on the host to mount into the container. + /// The . + [AspireExport] + public static IResourceBuilder WithUploadsBindMount(this IResourceBuilder builder, string source) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(source); + + return builder.WithBindMount(source, UploadsPath); + } +} + +#pragma warning restore ASPIREATS001 // AspireExport is experimental diff --git a/src/CommunityToolkit.Aspire.Hosting.Listmonk/ListmonkContainerImageTags.cs b/src/CommunityToolkit.Aspire.Hosting.Listmonk/ListmonkContainerImageTags.cs new file mode 100644 index 000000000..2cd9084c0 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Listmonk/ListmonkContainerImageTags.cs @@ -0,0 +1,11 @@ +namespace CommunityToolkit.Aspire.Hosting.Listmonk; + +internal static class ListmonkContainerImageTags +{ + /// docker.io + public const string Registry = "docker.io"; + /// listmonk/listmonk + public const string Image = "listmonk/listmonk"; + /// latest + public const string Tag = "latest"; +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Listmonk/ListmonkResource.cs b/src/CommunityToolkit.Aspire.Hosting.Listmonk/ListmonkResource.cs new file mode 100644 index 000000000..45d2661aa --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Listmonk/ListmonkResource.cs @@ -0,0 +1,58 @@ +#pragma warning disable ASPIREATS001 // AspireExport is experimental + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// A resource that represents a listmonk instance. +/// +[AspireExport(ExposeProperties = true)] +public class ListmonkResource : ContainerResource, IResourceWithServiceDiscovery, IResourceWithConnectionString +{ + internal const string PrimaryEndpointName = "http"; + + /// + /// Initializes a new instance of the class. + /// + /// The name of the resource. + public ListmonkResource([ResourceName] string name) : base(name) + { + PrimaryEndpoint = new(this, PrimaryEndpointName); + } + + /// + /// Gets the primary HTTP endpoint for the listmonk instance. + /// + public EndpointReference PrimaryEndpoint { get; } + + /// + /// Gets the host endpoint reference for this resource. + /// + public EndpointReferenceExpression Host => PrimaryEndpoint.Property(EndpointProperty.Host); + + /// + /// Gets the port endpoint reference for this resource. + /// + public EndpointReferenceExpression Port => PrimaryEndpoint.Property(EndpointProperty.Port); + + /// + /// Gets the connection string expression for the listmonk instance. + /// + public ReferenceExpression ConnectionStringExpression => UriExpression; + + /// + /// Gets the connection URI expression for the listmonk instance. + /// + /// + /// Format: http://{host}:{port}. + /// + public ReferenceExpression UriExpression => ReferenceExpression.Create($"http://{Host}:{Port}"); + + IEnumerable> IResourceWithConnectionString.GetConnectionProperties() + { + yield return new("Host", ReferenceExpression.Create($"{Host}")); + yield return new("Port", ReferenceExpression.Create($"{Port}")); + yield return new("Uri", UriExpression); + } +} + +#pragma warning restore ASPIREATS001 // AspireExport is experimental diff --git a/src/CommunityToolkit.Aspire.Hosting.Listmonk/README.md b/src/CommunityToolkit.Aspire.Hosting.Listmonk/README.md new file mode 100644 index 000000000..b90bb7f1d --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Listmonk/README.md @@ -0,0 +1,72 @@ +# listmonk hosting integration + +Use this integration to model, configure, and orchestrate a listmonk newsletter and mailing list manager in an Aspire AppHost. The integration runs listmonk in a container and configures it to use a PostgreSQL database resource. + +## Getting started + +Install the package in your AppHost project: + +```shell +aspire add CommunityToolkit.Aspire.Hosting.Listmonk +``` + +## Usage example + +```csharp +var builder = DistributedApplication.CreateBuilder(args); + +var postgres = builder.AddPostgres("postgres"); +var listmonkDb = postgres.AddDatabase("listmonk"); +var adminPassword = builder.AddParameter("listmonk-admin-password", secret: true); + +var listmonk = builder.AddListmonk("listmonk") + .WithPostgreSQL(listmonkDb) + .WithAdminCredentials("admin", adminPassword) + .WithTimeZone("Etc/UTC") + .WithUploadsVolume(); + +builder.AddProject("api") + .WithReference(listmonk); + +builder.Build().Run(); +``` + +## Connection Properties + +The `ListmonkResource` exposes the following connection properties: + +| Name | Format | +| --- | --- | +| `Host` | The listmonk host name | +| `Port` | The listmonk HTTP port | +| `Uri` | `http://{host}:{port}` | + +Connection properties become environment variables when referenced, for example `LISTMONK_URI`. + +## Configuration + +The integration exposes listmonk's container configuration environment variables as fluent methods: + +| Method | Environment variable | +| --- | --- | +| `WithAppAddress` | `LISTMONK_app__address` | +| `WithPostgreSQL` | `LISTMONK_db__host`, `LISTMONK_db__port`, `LISTMONK_db__user`, `LISTMONK_db__password`, `LISTMONK_db__database`, `LISTMONK_db__ssl_mode` | +| `WithDatabaseSslMode` | `LISTMONK_db__ssl_mode` | +| `WithDatabaseMaxOpenConnections` | `LISTMONK_db__max_open` | +| `WithDatabaseMaxIdleConnections` | `LISTMONK_db__max_idle` | +| `WithDatabaseMaxLifetime` | `LISTMONK_db__max_lifetime` | +| `WithDatabaseParameters` | `LISTMONK_db__params` | +| `WithTimeZone` | `TZ` | +| `WithAdminUser`, `WithAdminPassword`, `WithAdminCredentials` | `LISTMONK_ADMIN_USER`, `LISTMONK_ADMIN_PASSWORD` | +| `WithUserId`, `WithGroupId`, `WithUserAndGroupId` | `PUID`, `PGID` | + +The admin credentials are only used by listmonk during first database setup. +listmonk's container entrypoint also supports `LISTMONK_*_FILE` variables for Docker or Podman secret files. Prefer Aspire parameters for secrets in AppHost code; if a deployment requires file-based secret paths, configure those specific variables with Aspire's generic `WithEnvironment` API. + +## Additional documentation + +See the [listmonk documentation](https://listmonk.app/docs/) for listmonk configuration, administration, and operation. + +## Feedback & contributing + +https://github.com/CommunityToolkit/Aspire diff --git a/tests/CommunityToolkit.Aspire.Hosting.Listmonk.Tests/AddListmonkTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Listmonk.Tests/AddListmonkTests.cs new file mode 100644 index 000000000..070d4e0ce --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Listmonk.Tests/AddListmonkTests.cs @@ -0,0 +1,190 @@ +using Aspire.Hosting; +using Aspire.Hosting.Utils; +using CommunityToolkit.Aspire.Hosting.Listmonk; +using CommunityToolkit.Aspire.Testing; +using System.Net.Sockets; + +namespace CommunityToolkit.Aspire.Hosting.Listmonk.Tests; + +public class AddListmonkTests +{ + [Fact] + public async Task AddListmonkContainerWithDefaultsAddsAnnotationMetadata() + { + var appBuilder = DistributedApplication.CreateBuilder(); + + var listmonk = appBuilder.AddListmonk("listmonk"); + + using var app = appBuilder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var containerResource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal("listmonk", containerResource.Name); + + var endpoint = Assert.Single(containerResource.Annotations.OfType()); + Assert.Equal(9000, endpoint.TargetPort); + Assert.False(endpoint.IsExternal); + Assert.Equal("http", endpoint.Name); + Assert.Null(endpoint.Port); + Assert.Equal(ProtocolType.Tcp, endpoint.Protocol); + Assert.Equal("http", endpoint.Transport); + Assert.Equal("http", endpoint.UriScheme); + + var containerAnnotation = Assert.Single(containerResource.Annotations.OfType()); + Assert.Equal(ListmonkContainerImageTags.Tag, containerAnnotation.Tag); + Assert.Equal(ListmonkContainerImageTags.Image, containerAnnotation.Image); + Assert.Equal(ListmonkContainerImageTags.Registry, containerAnnotation.Registry); + + var config = await listmonk.Resource.GetEnvironmentVariablesAsync(); + + Assert.Collection(config, + env => + { + Assert.Equal("LISTMONK_app__address", env.Key); + Assert.Equal("0.0.0.0:9000", env.Value); + }); + } + + [Fact] + public async Task WithPostgreSQLAddsDatabaseConfiguration() + { + using var appBuilder = TestDistributedApplicationBuilder.Create(); + + var password = appBuilder.AddParameter("password", "password"); + var postgres = appBuilder.AddPostgres("postgres", password: password); + var database = postgres.AddDatabase("listmonkdb"); + + var listmonk = appBuilder.AddListmonk("listmonk") + .WithPostgreSQL(database); + + var config = await listmonk.Resource.GetEnvironmentVariablesAsync(); + + Assert.Equal("postgres", config["LISTMONK_db__host"]); + Assert.Equal("5432", config["LISTMONK_db__port"]); + Assert.Equal("postgres", config["LISTMONK_db__user"]); + Assert.Equal("password", config["LISTMONK_db__password"]); + Assert.Equal("listmonkdb", config["LISTMONK_db__database"]); + Assert.Equal("disable", config["LISTMONK_db__ssl_mode"]); + } + + [Fact] + public async Task WithAppAddressAddsAppAddressConfiguration() + { + using var appBuilder = TestDistributedApplicationBuilder.Create(); + + var listmonk = appBuilder.AddListmonk("listmonk") + .WithAppAddress("0.0.0.0:9100"); + + var config = await listmonk.Resource.GetEnvironmentVariablesAsync(); + + Assert.Equal("0.0.0.0:9100", config["LISTMONK_app__address"]); + } + + [Fact] + public async Task WithDatabaseConfigurationMethodsAddEnvironmentVariables() + { + using var appBuilder = TestDistributedApplicationBuilder.Create(); + + var listmonk = appBuilder.AddListmonk("listmonk") + .WithDatabaseSslMode("require") + .WithDatabaseMaxOpenConnections(50) + .WithDatabaseMaxIdleConnections(10) + .WithDatabaseMaxLifetime("600s") + .WithDatabaseParameters("application_name=listmonk gssencmode=disable"); + + var config = await listmonk.Resource.GetEnvironmentVariablesAsync(); + + Assert.Equal("require", config["LISTMONK_db__ssl_mode"]); + Assert.Equal("50", config["LISTMONK_db__max_open"]); + Assert.Equal("10", config["LISTMONK_db__max_idle"]); + Assert.Equal("600s", config["LISTMONK_db__max_lifetime"]); + Assert.Equal("application_name=listmonk gssencmode=disable", config["LISTMONK_db__params"]); + } + + [Fact] + public async Task WithTimeZoneAddsTimeZoneEnvironmentVariable() + { + using var appBuilder = TestDistributedApplicationBuilder.Create(); + + var listmonk = appBuilder.AddListmonk("listmonk") + .WithTimeZone("Europe/Kiev"); + + var config = await listmonk.Resource.GetEnvironmentVariablesAsync(); + + Assert.Equal("Europe/Kiev", config["TZ"]); + } + + [Fact] + public async Task WithUserAndGroupIdAddsContainerUserEnvironmentVariables() + { + using var appBuilder = TestDistributedApplicationBuilder.Create(); + + var listmonk = appBuilder.AddListmonk("listmonk") + .WithUserAndGroupId(1000, 1001); + + var config = await listmonk.Resource.GetEnvironmentVariablesAsync(); + + Assert.Equal("1000", config["PUID"]); + Assert.Equal("1001", config["PGID"]); + } + + [Fact] + public async Task WithAdminCredentialsAddsFirstRunAdminEnvironmentVariables() + { + using var appBuilder = TestDistributedApplicationBuilder.Create(); + + var adminPassword = appBuilder.AddParameter("admin-password", "SuperSecret123!", secret: true); + var listmonk = appBuilder.AddListmonk("listmonk") + .WithAdminCredentials("admin", adminPassword); + + var config = await listmonk.Resource.GetEnvironmentVariablesAsync(); + + Assert.Equal("admin", config["LISTMONK_ADMIN_USER"]); + Assert.Equal("SuperSecret123!", config["LISTMONK_ADMIN_PASSWORD"]); + } + + [Fact] + public async Task WithAdminUserAndPasswordAddFirstRunAdminEnvironmentVariables() + { + using var appBuilder = TestDistributedApplicationBuilder.Create(); + + var adminPassword = appBuilder.AddParameter("admin-password", "SuperSecret123!", secret: true); + var listmonk = appBuilder.AddListmonk("listmonk") + .WithAdminUser("admin") + .WithAdminPassword(adminPassword); + + var config = await listmonk.Resource.GetEnvironmentVariablesAsync(); + + Assert.Equal("admin", config["LISTMONK_ADMIN_USER"]); + Assert.Equal("SuperSecret123!", config["LISTMONK_ADMIN_PASSWORD"]); + } + + [Fact] + public void WithUploadsVolumeAddsVolumeAnnotation() + { + using var appBuilder = TestDistributedApplicationBuilder.Create(); + + var listmonk = appBuilder.AddListmonk("listmonk") + .WithUploadsVolume("uploads"); + + var volume = Assert.Single(listmonk.Resource.Annotations.OfType()); + Assert.Equal("uploads", volume.Source); + Assert.Equal("/listmonk/uploads", volume.Target); + Assert.Equal(ContainerMountType.Volume, volume.Type); + } + + [Fact] + public void WithUploadsBindMountAddsBindMountAnnotation() + { + using var appBuilder = TestDistributedApplicationBuilder.Create(); + + var listmonk = appBuilder.AddListmonk("listmonk") + .WithUploadsBindMount("./uploads"); + + var volume = Assert.Single(listmonk.Resource.Annotations.OfType()); + Assert.Equal(Path.Combine(appBuilder.AppHostDirectory, "uploads"), volume.Source); + Assert.Equal("/listmonk/uploads", volume.Target); + Assert.Equal(ContainerMountType.BindMount, volume.Type); + } +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.Listmonk.Tests/AppHostTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Listmonk.Tests/AppHostTests.cs new file mode 100644 index 000000000..8ee426096 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Listmonk.Tests/AppHostTests.cs @@ -0,0 +1,17 @@ +using CommunityToolkit.Aspire.Testing; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace CommunityToolkit.Aspire.Hosting.Listmonk.Tests; + +[RequiresDocker] +public class AppHostTests(AspireIntegrationTestFixture fixture) : IClassFixture> +{ + [Fact] + public async Task ListmonkResourceStartsAndRespondsOk() + { + const string resourceName = "listmonk"; + var @event = await fixture.ResourceNotificationService.WaitForResourceHealthyAsync(resourceName).WaitAsync(TimeSpan.FromMinutes(2)); + + Assert.Equal(HealthStatus.Healthy, @event.Snapshot.HealthStatus); + } +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.Listmonk.Tests/CommunityToolkit.Aspire.Hosting.Listmonk.Tests.csproj b/tests/CommunityToolkit.Aspire.Hosting.Listmonk.Tests/CommunityToolkit.Aspire.Hosting.Listmonk.Tests.csproj new file mode 100644 index 000000000..29fbe216d --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Listmonk.Tests/CommunityToolkit.Aspire.Hosting.Listmonk.Tests.csproj @@ -0,0 +1,22 @@ + + + + CommunityToolkit.Aspire.Hosting.Listmonk.Tests + + + + + + + + + + + + + + + + + + diff --git a/tests/CommunityToolkit.Aspire.Hosting.Listmonk.Tests/ListmonkPublicApiTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Listmonk.Tests/ListmonkPublicApiTests.cs new file mode 100644 index 000000000..388e494df --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Listmonk.Tests/ListmonkPublicApiTests.cs @@ -0,0 +1,216 @@ +using Aspire.Hosting; +using Aspire.Hosting.Utils; + +namespace CommunityToolkit.Aspire.Hosting.Listmonk.Tests; + +public class ListmonkPublicApiTests +{ + [Fact] + public void AddListmonkShouldThrowWhenBuilderIsNull() + { + IDistributedApplicationBuilder builder = null!; + const string name = "listmonk"; + + var action = () => builder.AddListmonk(name); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(builder), exception.ParamName); + } + + [Fact] + public void AddListmonkShouldThrowWhenNameIsNull() + { + var builder = TestDistributedApplicationBuilder.Create(); + string name = null!; + + var action = () => builder.AddListmonk(name); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(name), exception.ParamName); + } + + [Fact] + public void WithPostgreSQLShouldThrowWhenBuilderIsNull() + { + IResourceBuilder builder = null!; + + var action = () => builder.WithPostgreSQL(null!); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(builder), exception.ParamName); + } + + [Fact] + public void WithPostgreSQLShouldThrowWhenDatabaseIsNull() + { + var builder = TestDistributedApplicationBuilder.Create(); + var listmonk = builder.AddListmonk("listmonk"); + + var action = () => listmonk.WithPostgreSQL(null!); + + var exception = Assert.Throws(action); + Assert.Equal("database", exception.ParamName); + } + + [Fact] + public void WithUploadsBindMountShouldThrowWhenSourceIsNull() + { + var builder = TestDistributedApplicationBuilder.Create(); + var listmonk = builder.AddListmonk("listmonk"); + string source = null!; + + var action = () => listmonk.WithUploadsBindMount(source); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(source), exception.ParamName); + } + + [Fact] + public void WithAppAddressShouldThrowWhenAddressIsNull() + { + var builder = TestDistributedApplicationBuilder.Create(); + var listmonk = builder.AddListmonk("listmonk"); + string address = null!; + + var action = () => listmonk.WithAppAddress(address); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(address), exception.ParamName); + } + + [Fact] + public void WithDatabaseSslModeShouldThrowWhenSslModeIsNull() + { + var builder = TestDistributedApplicationBuilder.Create(); + var listmonk = builder.AddListmonk("listmonk"); + string sslMode = null!; + + var action = () => listmonk.WithDatabaseSslMode(sslMode); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(sslMode), exception.ParamName); + } + + [Fact] + public void WithDatabaseMaxOpenConnectionsShouldThrowWhenMaxOpenIsNegative() + { + var builder = TestDistributedApplicationBuilder.Create(); + var listmonk = builder.AddListmonk("listmonk"); + + var action = () => listmonk.WithDatabaseMaxOpenConnections(-1); + + var exception = Assert.Throws(action); + Assert.Equal("maxOpen", exception.ParamName); + } + + [Fact] + public void WithDatabaseMaxIdleConnectionsShouldThrowWhenMaxIdleIsNegative() + { + var builder = TestDistributedApplicationBuilder.Create(); + var listmonk = builder.AddListmonk("listmonk"); + + var action = () => listmonk.WithDatabaseMaxIdleConnections(-1); + + var exception = Assert.Throws(action); + Assert.Equal("maxIdle", exception.ParamName); + } + + [Fact] + public void WithDatabaseMaxLifetimeShouldThrowWhenMaxLifetimeIsNull() + { + var builder = TestDistributedApplicationBuilder.Create(); + var listmonk = builder.AddListmonk("listmonk"); + string maxLifetime = null!; + + var action = () => listmonk.WithDatabaseMaxLifetime(maxLifetime); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(maxLifetime), exception.ParamName); + } + + [Fact] + public void WithDatabaseParametersShouldThrowWhenParametersIsNull() + { + var builder = TestDistributedApplicationBuilder.Create(); + var listmonk = builder.AddListmonk("listmonk"); + string parameters = null!; + + var action = () => listmonk.WithDatabaseParameters(parameters); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(parameters), exception.ParamName); + } + + [Fact] + public void WithTimeZoneShouldThrowWhenTimeZoneIsNull() + { + var builder = TestDistributedApplicationBuilder.Create(); + var listmonk = builder.AddListmonk("listmonk"); + string timeZone = null!; + + var action = () => listmonk.WithTimeZone(timeZone); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(timeZone), exception.ParamName); + } + + [Fact] + public void WithAdminUserShouldThrowWhenUsernameIsNull() + { + var builder = TestDistributedApplicationBuilder.Create(); + var listmonk = builder.AddListmonk("listmonk"); + string username = null!; + + var action = () => listmonk.WithAdminUser(username); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(username), exception.ParamName); + } + + [Fact] + public void WithAdminPasswordShouldThrowWhenPasswordIsNull() + { + var builder = TestDistributedApplicationBuilder.Create(); + var listmonk = builder.AddListmonk("listmonk"); + + var action = () => listmonk.WithAdminPassword(null!); + + var exception = Assert.Throws(action); + Assert.Equal("password", exception.ParamName); + } + + [Fact] + public void WithUserIdShouldThrowWhenUserIdIsNegative() + { + var builder = TestDistributedApplicationBuilder.Create(); + var listmonk = builder.AddListmonk("listmonk"); + + var action = () => listmonk.WithUserId(-1); + + var exception = Assert.Throws(action); + Assert.Equal("userId", exception.ParamName); + } + + [Fact] + public void WithGroupIdShouldThrowWhenGroupIdIsNegative() + { + var builder = TestDistributedApplicationBuilder.Create(); + var listmonk = builder.AddListmonk("listmonk"); + + var action = () => listmonk.WithGroupId(-1); + + var exception = Assert.Throws(action); + Assert.Equal("groupId", exception.ParamName); + } + + [Fact] + public void CtorListmonkResourceShouldThrowWhenNameIsNull() + { + string name = null!; + + var action = () => new ListmonkResource(name); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(name), exception.ParamName); + } +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.Listmonk.Tests/TypeScriptAppHostTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Listmonk.Tests/TypeScriptAppHostTests.cs new file mode 100644 index 000000000..8778a688d --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Listmonk.Tests/TypeScriptAppHostTests.cs @@ -0,0 +1,18 @@ +using CommunityToolkit.Aspire.Testing; + +namespace CommunityToolkit.Aspire.Hosting.Listmonk.Tests; + +[RequiresDocker] +public class TypeScriptAppHostTests +{ + [Fact] + public async Task TypeScriptAppHostCompilesAndStarts() + { + await TypeScriptAppHostTest.Run( + appHostProject: "CommunityToolkit.Aspire.Hosting.Listmonk.AppHost.TypeScript", + packageName: "CommunityToolkit.Aspire.Hosting.Listmonk", + exampleName: "listmonk", + waitForResources: ["postgres", "listmonk", "listmonk-default"], + cancellationToken: TestContext.Current.CancellationToken); + } +} From 8d6b5bff11a90c30e10911d0c46da21a9977dd2a Mon Sep 17 00:00:00 2001 From: axi Date: Tue, 7 Jul 2026 20:11:36 +0300 Subject: [PATCH 02/12] Update solution file to include Listmonk project and test examples under `/src` and `/examples/listmonk`. --- CommunityToolkit.Aspire.slnx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CommunityToolkit.Aspire.slnx b/CommunityToolkit.Aspire.slnx index f05008d80..1e0438234 100644 --- a/CommunityToolkit.Aspire.slnx +++ b/CommunityToolkit.Aspire.slnx @@ -97,6 +97,9 @@ + + + @@ -253,6 +256,7 @@ + @@ -327,6 +331,7 @@ + @@ -379,4 +384,4 @@ - \ No newline at end of file + From ba7faab3d4f8084d9f6378ab7e94c7d57c766bd9 Mon Sep 17 00:00:00 2001 From: axi Date: Tue, 7 Jul 2026 22:45:12 +0300 Subject: [PATCH 03/12] Refactor Listmonk integration to simplify database and PostgreSQL configuration. --- .../apphost.mts | 10 ++-------- .../Program.cs | 10 +--------- src/CommunityToolkit.Aspire.Hosting.Listmonk/README.md | 7 ++----- 3 files changed, 5 insertions(+), 22 deletions(-) diff --git a/examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost.TypeScript/apphost.mts b/examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost.TypeScript/apphost.mts index 606560fb5..0218a1e28 100644 --- a/examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost.TypeScript/apphost.mts +++ b/examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost.TypeScript/apphost.mts @@ -2,23 +2,18 @@ import { createBuilder } from "./.aspire/modules/aspire.mjs"; const builder = await createBuilder(); -const password = await builder.addParameter("db-password", { - value: "12345678", - secret: true, -}); const adminPassword = await builder.addParameter("admin-password", { value: "SuperSecret123!", secret: true, }); -const postgres = await builder.addPostgres("postgres", { password }); -const listmonkDb = await postgres.addDatabase("listmonkdb"); const listmonk = await builder.addListmonk("listmonk", { port: 31900, + postgresName: "postgres", + databaseName: "listmonkdb", }); const listmonkDefault = await builder.addListmonk("listmonk-default"); -await listmonk.withPostgreSQL(listmonkDb); await listmonk.withAdminCredentials("admin", adminPassword); await listmonk.withDatabaseMaxOpenConnections(25); await listmonk.withDatabaseMaxIdleConnections(25); @@ -26,7 +21,6 @@ await listmonk.withDatabaseMaxLifetime("300s"); await listmonk.withTimeZone("Etc/UTC"); await listmonk.withUserAndGroupId(0, 0); await listmonk.withUploadsVolume(); -await listmonkDefault.withPostgreSQL(listmonkDb); await listmonkDefault.withDatabaseSslMode("disable"); const _primaryEndpoint = await listmonk.primaryEndpoint(); diff --git a/examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost/Program.cs b/examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost/Program.cs index 8ade49281..13cf1b025 100644 --- a/examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost/Program.cs +++ b/examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost/Program.cs @@ -1,16 +1,8 @@ var builder = DistributedApplication.CreateBuilder(args); -var password = builder.AddParameter("db-password", "12345678"); var adminPassword = builder.AddParameter("admin-password", "SuperSecret123!", secret: true); -var postgres = builder - .AddPostgres("postgres", password: password) - .WithLifetime(ContainerLifetime.Persistent); - -var listmonkDb = postgres.AddDatabase("listmonkdb"); - -builder.AddListmonk("listmonk") - .WithPostgreSQL(listmonkDb) +builder.AddListmonk("listmonk", postgresName: "postgres", databaseName: "listmonkdb") .WithAdminCredentials("admin", adminPassword) .WithDatabaseMaxOpenConnections(25) .WithDatabaseMaxIdleConnections(25) diff --git a/src/CommunityToolkit.Aspire.Hosting.Listmonk/README.md b/src/CommunityToolkit.Aspire.Hosting.Listmonk/README.md index b90bb7f1d..4006e6c5c 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Listmonk/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Listmonk/README.md @@ -15,12 +15,9 @@ aspire add CommunityToolkit.Aspire.Hosting.Listmonk ```csharp var builder = DistributedApplication.CreateBuilder(args); -var postgres = builder.AddPostgres("postgres"); -var listmonkDb = postgres.AddDatabase("listmonk"); var adminPassword = builder.AddParameter("listmonk-admin-password", secret: true); -var listmonk = builder.AddListmonk("listmonk") - .WithPostgreSQL(listmonkDb) +var listmonk = builder.AddListmonk("listmonk", postgresName: "postgres", databaseName: "listmonkdb") .WithAdminCredentials("admin", adminPassword) .WithTimeZone("Etc/UTC") .WithUploadsVolume(); @@ -50,7 +47,7 @@ The integration exposes listmonk's container configuration environment variables | Method | Environment variable | | --- | --- | | `WithAppAddress` | `LISTMONK_app__address` | -| `WithPostgreSQL` | `LISTMONK_db__host`, `LISTMONK_db__port`, `LISTMONK_db__user`, `LISTMONK_db__password`, `LISTMONK_db__database`, `LISTMONK_db__ssl_mode` | +| `AddListmonk` | `LISTMONK_db__host`, `LISTMONK_db__port`, `LISTMONK_db__user`, `LISTMONK_db__password`, `LISTMONK_db__database`, `LISTMONK_db__ssl_mode` | | `WithDatabaseSslMode` | `LISTMONK_db__ssl_mode` | | `WithDatabaseMaxOpenConnections` | `LISTMONK_db__max_open` | | `WithDatabaseMaxIdleConnections` | `LISTMONK_db__max_idle` | From c8d1cbbaf23c90e111628f0336fd7fc2917fd120 Mon Sep 17 00:00:00 2001 From: axi Date: Tue, 7 Jul 2026 22:45:18 +0300 Subject: [PATCH 04/12] Enhance Listmonk integration tests to validate PostgreSQL configurations and resource relationships. --- .../AddListmonkTests.cs | 42 ++++++++++++------- .../ListmonkPublicApiTests.cs | 19 ++++----- .../TypeScriptAppHostTests.cs | 2 +- 3 files changed, 38 insertions(+), 25 deletions(-) diff --git a/tests/CommunityToolkit.Aspire.Hosting.Listmonk.Tests/AddListmonkTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Listmonk.Tests/AddListmonkTests.cs index 070d4e0ce..0c9710db5 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Listmonk.Tests/AddListmonkTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Listmonk.Tests/AddListmonkTests.cs @@ -21,6 +21,14 @@ public async Task AddListmonkContainerWithDefaultsAddsAnnotationMetadata() var containerResource = Assert.Single(appModel.Resources.OfType()); Assert.Equal("listmonk", containerResource.Name); + var postgres = Assert.Single(appModel.Resources.OfType()); + Assert.Equal("listmonk-postgres", postgres.Name); + var parentAnnotation = postgres.Annotations.OfType() + .SingleOrDefault(annotation => annotation.Type == "Parent"); + Assert.NotNull(parentAnnotation); + Assert.Same(containerResource, parentAnnotation.Resource); + + Assert.Contains(appModel.Resources.OfType(), resource => resource.Name == "listmonk-db"); var endpoint = Assert.Single(containerResource.Annotations.OfType()); Assert.Equal(9000, endpoint.TargetPort); @@ -38,32 +46,38 @@ public async Task AddListmonkContainerWithDefaultsAddsAnnotationMetadata() var config = await listmonk.Resource.GetEnvironmentVariablesAsync(); - Assert.Collection(config, - env => - { - Assert.Equal("LISTMONK_app__address", env.Key); - Assert.Equal("0.0.0.0:9000", env.Value); - }); + Assert.Equal("0.0.0.0:9000", config["LISTMONK_app__address"]); + Assert.Equal("listmonk-postgres", config["LISTMONK_db__host"]); + Assert.Equal("5432", config["LISTMONK_db__port"]); + Assert.Equal("postgres", config["LISTMONK_db__user"]); + Assert.Equal("listmonk-db", config["LISTMONK_db__database"]); + Assert.Equal("disable", config["LISTMONK_db__ssl_mode"]); } [Fact] - public async Task WithPostgreSQLAddsDatabaseConfiguration() + public async Task AddListmonkUsesConfiguredPostgresName() { using var appBuilder = TestDistributedApplicationBuilder.Create(); - var password = appBuilder.AddParameter("password", "password"); - var postgres = appBuilder.AddPostgres("postgres", password: password); - var database = postgres.AddDatabase("listmonkdb"); + var listmonk = appBuilder.AddListmonk("listmonk", postgresName: "mailing-postgres"); - var listmonk = appBuilder.AddListmonk("listmonk") - .WithPostgreSQL(database); + var config = await listmonk.Resource.GetEnvironmentVariablesAsync(); + + Assert.Equal("mailing-postgres", config["LISTMONK_db__host"]); + } + + [Fact] + public async Task AddListmonkUsesConfiguredDatabaseName() + { + using var appBuilder = TestDistributedApplicationBuilder.Create(); + + var listmonk = appBuilder.AddListmonk("listmonk", databaseName: "listmonkdb"); var config = await listmonk.Resource.GetEnvironmentVariablesAsync(); - Assert.Equal("postgres", config["LISTMONK_db__host"]); + Assert.Equal("listmonk-postgres", config["LISTMONK_db__host"]); Assert.Equal("5432", config["LISTMONK_db__port"]); Assert.Equal("postgres", config["LISTMONK_db__user"]); - Assert.Equal("password", config["LISTMONK_db__password"]); Assert.Equal("listmonkdb", config["LISTMONK_db__database"]); Assert.Equal("disable", config["LISTMONK_db__ssl_mode"]); } diff --git a/tests/CommunityToolkit.Aspire.Hosting.Listmonk.Tests/ListmonkPublicApiTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Listmonk.Tests/ListmonkPublicApiTests.cs index 388e494df..0fd9b5580 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Listmonk.Tests/ListmonkPublicApiTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Listmonk.Tests/ListmonkPublicApiTests.cs @@ -30,26 +30,25 @@ public void AddListmonkShouldThrowWhenNameIsNull() } [Fact] - public void WithPostgreSQLShouldThrowWhenBuilderIsNull() + public void AddListmonkShouldThrowWhenDatabaseNameIsEmpty() { - IResourceBuilder builder = null!; + var builder = TestDistributedApplicationBuilder.Create(); - var action = () => builder.WithPostgreSQL(null!); + var action = () => builder.AddListmonk("listmonk", databaseName: string.Empty); - var exception = Assert.Throws(action); - Assert.Equal(nameof(builder), exception.ParamName); + var exception = Assert.Throws(action); + Assert.Equal("databaseName", exception.ParamName); } [Fact] - public void WithPostgreSQLShouldThrowWhenDatabaseIsNull() + public void AddListmonkShouldThrowWhenPostgresNameIsEmpty() { var builder = TestDistributedApplicationBuilder.Create(); - var listmonk = builder.AddListmonk("listmonk"); - var action = () => listmonk.WithPostgreSQL(null!); + var action = () => builder.AddListmonk("listmonk", postgresName: string.Empty); - var exception = Assert.Throws(action); - Assert.Equal("database", exception.ParamName); + var exception = Assert.Throws(action); + Assert.Equal("postgresName", exception.ParamName); } [Fact] diff --git a/tests/CommunityToolkit.Aspire.Hosting.Listmonk.Tests/TypeScriptAppHostTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Listmonk.Tests/TypeScriptAppHostTests.cs index 8778a688d..296284e1e 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Listmonk.Tests/TypeScriptAppHostTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Listmonk.Tests/TypeScriptAppHostTests.cs @@ -12,7 +12,7 @@ await TypeScriptAppHostTest.Run( appHostProject: "CommunityToolkit.Aspire.Hosting.Listmonk.AppHost.TypeScript", packageName: "CommunityToolkit.Aspire.Hosting.Listmonk", exampleName: "listmonk", - waitForResources: ["postgres", "listmonk", "listmonk-default"], + waitForResources: ["postgres", "listmonk", "listmonk-default-postgres", "listmonk-default"], cancellationToken: TestContext.Current.CancellationToken); } } From 87b07ccfefc7dac5fc29da70986a6567d1e23bc0 Mon Sep 17 00:00:00 2001 From: axi Date: Tue, 7 Jul 2026 22:45:41 +0300 Subject: [PATCH 05/12] Add support for PostgreSQL configuration in `AddListmonk` method - Introduced new parameters `postgresName` and `databaseName` to simplify PostgreSQL integration. - Removed `WithPostgreSQL` method and replaced it with an internal `ConfigurePostgreSQL` implementation. - Updated XML documentation and examples to reflect the new configuration approach. --- .../ListmonkBuilderExtensions.cs | 42 ++++++++++--------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Listmonk/ListmonkBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Listmonk/ListmonkBuilderExtensions.cs index acf92e947..a06251ee1 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Listmonk/ListmonkBuilderExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Listmonk/ListmonkBuilderExtensions.cs @@ -36,6 +36,8 @@ public static class ListmonkBuilderExtensions /// The . /// The name of the resource. This name will be used as the connection string name when referenced in a dependency. /// The host port for the listmonk HTTP endpoint. If , Aspire will assign a random host port. + /// The name of the PostgreSQL server resource. Defaults to {name}-postgres. + /// The name of the PostgreSQL database resource. Defaults to {name}-db. /// A reference to the . /// /// @@ -43,10 +45,7 @@ public static class ListmonkBuilderExtensions /// /// var builder = DistributedApplication.CreateBuilder(args); /// - /// var db = builder.AddPostgres("postgres") - /// .AddDatabase("listmonk"); - /// var listmonk = builder.AddListmonk("listmonk") - /// .WithPostgreSQL(db); + /// var listmonk = builder.AddListmonk("listmonk"); /// var api = builder.AddProject<Projects.Api>("api") /// .WithReference(listmonk); /// @@ -58,14 +57,24 @@ public static class ListmonkBuilderExtensions public static IResourceBuilder AddListmonk( this IDistributedApplicationBuilder builder, [ResourceName] string name, - int? port = null) + int? port = null, + [ResourceName] string? postgresName = null, + [ResourceName] string? databaseName = null) { ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrEmpty(name); + if (postgresName is not null) + { + ArgumentException.ThrowIfNullOrEmpty(postgresName); + } - var resource = new ListmonkResource(name); + if (databaseName is not null) + { + ArgumentException.ThrowIfNullOrEmpty(databaseName); + } - return builder.AddResource(resource) + var resource = new ListmonkResource(name); + var listmonk = builder.AddResource(resource) .WithImage(ListmonkContainerImageTags.Image, ListmonkContainerImageTags.Tag) .WithImageRegistry(ListmonkContainerImageTags.Registry) .WithHttpEndpoint(port: port, targetPort: ListmonkPort, name: ListmonkResource.PrimaryEndpointName) @@ -73,6 +82,12 @@ public static IResourceBuilder AddListmonk( .WithArgs("-c", "./listmonk --install --idempotent --yes --config '' && ./listmonk --upgrade --yes --config '' && ./listmonk --config ''") .WithEnvironment(AppAddressEnvVarName, "0.0.0.0:9000") .WithHttpHealthCheck("/"); + + var postgres = builder.AddPostgres(postgresName ?? $"{name}-postgres") + .WithParentRelationship(listmonk.Resource); + var database = postgres.AddDatabase(databaseName ?? $"{name}-db"); + + return listmonk.ConfigurePostgreSQL(database); } /// @@ -90,21 +105,10 @@ public static IResourceBuilder WithAppAddress(this IResourceBu return builder.WithEnvironment(AppAddressEnvVarName, address); } - /// - /// References a as the storage backend for the . - /// - /// Configures PostgreSQL as the storage backend for listmonk. - /// The listmonk resource builder. - /// The PostgreSQL database resource builder. - /// A reference to the . - [AspireExport] - public static IResourceBuilder WithPostgreSQL( + private static IResourceBuilder ConfigurePostgreSQL( this IResourceBuilder builder, IResourceBuilder database) { - ArgumentNullException.ThrowIfNull(builder); - ArgumentNullException.ThrowIfNull(database); - var postgres = database.Resource.Parent; return builder From 7ecb9c8befb2ea317987951c98fb5e7b82a8a7c1 Mon Sep 17 00:00:00 2001 From: axi Date: Tue, 7 Jul 2026 22:54:01 +0300 Subject: [PATCH 06/12] Update Listmonk health check endpoint to `/health` for improved endpoint specificity --- .../ListmonkBuilderExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Listmonk/ListmonkBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Listmonk/ListmonkBuilderExtensions.cs index a06251ee1..17cca7808 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Listmonk/ListmonkBuilderExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Listmonk/ListmonkBuilderExtensions.cs @@ -81,7 +81,7 @@ public static IResourceBuilder AddListmonk( .WithEntrypoint("sh") .WithArgs("-c", "./listmonk --install --idempotent --yes --config '' && ./listmonk --upgrade --yes --config '' && ./listmonk --config ''") .WithEnvironment(AppAddressEnvVarName, "0.0.0.0:9000") - .WithHttpHealthCheck("/"); + .WithHttpHealthCheck("/health"); var postgres = builder.AddPostgres(postgresName ?? $"{name}-postgres") .WithParentRelationship(listmonk.Resource); From 5627a336d24c742168319034d6830c931976228d Mon Sep 17 00:00:00 2001 From: axi Date: Wed, 8 Jul 2026 09:42:08 +0300 Subject: [PATCH 07/12] Refactor `ListmonkContainerImageTags` and test namespaces to align with `Aspire.Hosting` conventions --- .../ListmonkContainerImageTags.cs | 2 +- .../AddListmonkTests.cs | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Listmonk/ListmonkContainerImageTags.cs b/src/CommunityToolkit.Aspire.Hosting.Listmonk/ListmonkContainerImageTags.cs index 2cd9084c0..6f45e5658 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Listmonk/ListmonkContainerImageTags.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Listmonk/ListmonkContainerImageTags.cs @@ -1,4 +1,4 @@ -namespace CommunityToolkit.Aspire.Hosting.Listmonk; +namespace Aspire.Hosting; internal static class ListmonkContainerImageTags { diff --git a/tests/CommunityToolkit.Aspire.Hosting.Listmonk.Tests/AddListmonkTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Listmonk.Tests/AddListmonkTests.cs index 0c9710db5..2b604f9ca 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Listmonk.Tests/AddListmonkTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Listmonk.Tests/AddListmonkTests.cs @@ -1,6 +1,5 @@ using Aspire.Hosting; using Aspire.Hosting.Utils; -using CommunityToolkit.Aspire.Hosting.Listmonk; using CommunityToolkit.Aspire.Testing; using System.Net.Sockets; From 16236c71045ce9dbac9134f2e466eb5bdf6431f1 Mon Sep 17 00:00:00 2001 From: axi Date: Wed, 8 Jul 2026 09:42:28 +0300 Subject: [PATCH 08/12] Remove `WithUserAndGroupId` method and update related Listmonk configuration and tests --- .../apphost.mts | 3 ++- .../ListmonkBuilderExtensions.cs | 19 ------------------- .../ListmonkResource.cs | 13 ++----------- .../README.md | 2 +- .../AddListmonkTests.cs | 5 +++-- 5 files changed, 8 insertions(+), 34 deletions(-) diff --git a/examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost.TypeScript/apphost.mts b/examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost.TypeScript/apphost.mts index 0218a1e28..b4adc662d 100644 --- a/examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost.TypeScript/apphost.mts +++ b/examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost.TypeScript/apphost.mts @@ -19,7 +19,8 @@ await listmonk.withDatabaseMaxOpenConnections(25); await listmonk.withDatabaseMaxIdleConnections(25); await listmonk.withDatabaseMaxLifetime("300s"); await listmonk.withTimeZone("Etc/UTC"); -await listmonk.withUserAndGroupId(0, 0); +await listmonk.withUserId(0); +await listmonk.withGroupId(0); await listmonk.withUploadsVolume(); await listmonkDefault.withDatabaseSslMode("disable"); diff --git a/src/CommunityToolkit.Aspire.Hosting.Listmonk/ListmonkBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Listmonk/ListmonkBuilderExtensions.cs index 17cca7808..5ea871c01 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Listmonk/ListmonkBuilderExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Listmonk/ListmonkBuilderExtensions.cs @@ -293,25 +293,6 @@ public static IResourceBuilder WithGroupId(this IResourceBuild return builder.WithEnvironment(GroupIdEnvVarName, groupId.ToString()); } - /// - /// Configures the user and group IDs used by the listmonk container entrypoint. - /// - /// The listmonk resource builder. - /// The value for PUID. - /// The value for PGID. - /// The . - [AspireExport] - public static IResourceBuilder WithUserAndGroupId(this IResourceBuilder builder, int userId, int groupId) - { - ArgumentNullException.ThrowIfNull(builder); - ArgumentOutOfRangeException.ThrowIfNegative(userId); - ArgumentOutOfRangeException.ThrowIfNegative(groupId); - - return builder - .WithUserId(userId) - .WithGroupId(groupId); - } - /// /// Adds a named volume for listmonk media uploads. /// diff --git a/src/CommunityToolkit.Aspire.Hosting.Listmonk/ListmonkResource.cs b/src/CommunityToolkit.Aspire.Hosting.Listmonk/ListmonkResource.cs index 45d2661aa..983bf4874 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Listmonk/ListmonkResource.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Listmonk/ListmonkResource.cs @@ -6,23 +6,14 @@ namespace Aspire.Hosting.ApplicationModel; /// A resource that represents a listmonk instance. /// [AspireExport(ExposeProperties = true)] -public class ListmonkResource : ContainerResource, IResourceWithServiceDiscovery, IResourceWithConnectionString +public class ListmonkResource([ResourceName] string name) : ContainerResource(name), IResourceWithServiceDiscovery, IResourceWithConnectionString { internal const string PrimaryEndpointName = "http"; - /// - /// Initializes a new instance of the class. - /// - /// The name of the resource. - public ListmonkResource([ResourceName] string name) : base(name) - { - PrimaryEndpoint = new(this, PrimaryEndpointName); - } - /// /// Gets the primary HTTP endpoint for the listmonk instance. /// - public EndpointReference PrimaryEndpoint { get; } + public EndpointReference PrimaryEndpoint => new(this, PrimaryEndpointName); /// /// Gets the host endpoint reference for this resource. diff --git a/src/CommunityToolkit.Aspire.Hosting.Listmonk/README.md b/src/CommunityToolkit.Aspire.Hosting.Listmonk/README.md index 4006e6c5c..ae3df680e 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Listmonk/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Listmonk/README.md @@ -55,7 +55,7 @@ The integration exposes listmonk's container configuration environment variables | `WithDatabaseParameters` | `LISTMONK_db__params` | | `WithTimeZone` | `TZ` | | `WithAdminUser`, `WithAdminPassword`, `WithAdminCredentials` | `LISTMONK_ADMIN_USER`, `LISTMONK_ADMIN_PASSWORD` | -| `WithUserId`, `WithGroupId`, `WithUserAndGroupId` | `PUID`, `PGID` | +| `WithUserId`, `WithGroupId` | `PUID`, `PGID` | The admin credentials are only used by listmonk during first database setup. listmonk's container entrypoint also supports `LISTMONK_*_FILE` variables for Docker or Podman secret files. Prefer Aspire parameters for secrets in AppHost code; if a deployment requires file-based secret paths, configure those specific variables with Aspire's generic `WithEnvironment` API. diff --git a/tests/CommunityToolkit.Aspire.Hosting.Listmonk.Tests/AddListmonkTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Listmonk.Tests/AddListmonkTests.cs index 2b604f9ca..c1b80dda9 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Listmonk.Tests/AddListmonkTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Listmonk.Tests/AddListmonkTests.cs @@ -129,12 +129,13 @@ public async Task WithTimeZoneAddsTimeZoneEnvironmentVariable() } [Fact] - public async Task WithUserAndGroupIdAddsContainerUserEnvironmentVariables() + public async Task WithUserIdAndWithGroupIdAddContainerUserEnvironmentVariables() { using var appBuilder = TestDistributedApplicationBuilder.Create(); var listmonk = appBuilder.AddListmonk("listmonk") - .WithUserAndGroupId(1000, 1001); + .WithUserId(1000) + .WithGroupId(1001); var config = await listmonk.Resource.GetEnvironmentVariablesAsync(); From 61e6779cf5042d3354600725463cfdc81867cc29 Mon Sep 17 00:00:00 2001 From: axi Date: Wed, 8 Jul 2026 09:42:38 +0300 Subject: [PATCH 09/12] Update `ListmonkContainerImageTags.Tag` to `v6.2.0` --- .../ListmonkContainerImageTags.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Listmonk/ListmonkContainerImageTags.cs b/src/CommunityToolkit.Aspire.Hosting.Listmonk/ListmonkContainerImageTags.cs index 6f45e5658..30771f34e 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Listmonk/ListmonkContainerImageTags.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Listmonk/ListmonkContainerImageTags.cs @@ -6,6 +6,6 @@ internal static class ListmonkContainerImageTags public const string Registry = "docker.io"; /// listmonk/listmonk public const string Image = "listmonk/listmonk"; - /// latest - public const string Tag = "latest"; + /// v6.2.0 + public const string Tag = "v6.2.0"; } From 15957fd51b71c79d9072fd83d98a91070340f45d Mon Sep 17 00:00:00 2001 From: axi Date: Wed, 8 Jul 2026 09:42:43 +0300 Subject: [PATCH 10/12] Refactor `AddListmonk` and tests to use `WithReference` for PostgreSQL database configuration. --- .../apphost.mts | 8 +++- .../Program.cs | 5 ++- .../AddListmonkTests.cs | 38 +++++------------- .../ListmonkPublicApiTests.cs | 40 ++++++++++--------- 4 files changed, 43 insertions(+), 48 deletions(-) diff --git a/examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost.TypeScript/apphost.mts b/examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost.TypeScript/apphost.mts index b4adc662d..cd0729a09 100644 --- a/examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost.TypeScript/apphost.mts +++ b/examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost.TypeScript/apphost.mts @@ -6,14 +6,17 @@ const adminPassword = await builder.addParameter("admin-password", { value: "SuperSecret123!", secret: true, }); +const postgres = await builder.addPostgres("postgres"); +const database = await postgres.addDatabase("listmonkdb"); +const defaultPostgres = await builder.addPostgres("listmonk-default-postgres"); +const defaultDatabase = await defaultPostgres.addDatabase("listmonk-default-db"); const listmonk = await builder.addListmonk("listmonk", { port: 31900, - postgresName: "postgres", - databaseName: "listmonkdb", }); const listmonkDefault = await builder.addListmonk("listmonk-default"); +await listmonk.withReference(database); await listmonk.withAdminCredentials("admin", adminPassword); await listmonk.withDatabaseMaxOpenConnections(25); await listmonk.withDatabaseMaxIdleConnections(25); @@ -22,6 +25,7 @@ await listmonk.withTimeZone("Etc/UTC"); await listmonk.withUserId(0); await listmonk.withGroupId(0); await listmonk.withUploadsVolume(); +await listmonkDefault.withReference(defaultDatabase); await listmonkDefault.withDatabaseSslMode("disable"); const _primaryEndpoint = await listmonk.primaryEndpoint(); diff --git a/examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost/Program.cs b/examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost/Program.cs index 13cf1b025..7e00ca255 100644 --- a/examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost/Program.cs +++ b/examples/listmonk/CommunityToolkit.Aspire.Hosting.Listmonk.AppHost/Program.cs @@ -1,8 +1,11 @@ var builder = DistributedApplication.CreateBuilder(args); var adminPassword = builder.AddParameter("admin-password", "SuperSecret123!", secret: true); +var database = builder.AddPostgres("postgres") + .AddDatabase("listmonkdb"); -builder.AddListmonk("listmonk", postgresName: "postgres", databaseName: "listmonkdb") +builder.AddListmonk("listmonk") + .WithReference(database) .WithAdminCredentials("admin", adminPassword) .WithDatabaseMaxOpenConnections(25) .WithDatabaseMaxIdleConnections(25) diff --git a/tests/CommunityToolkit.Aspire.Hosting.Listmonk.Tests/AddListmonkTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Listmonk.Tests/AddListmonkTests.cs index c1b80dda9..860a04195 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Listmonk.Tests/AddListmonkTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Listmonk.Tests/AddListmonkTests.cs @@ -20,14 +20,7 @@ public async Task AddListmonkContainerWithDefaultsAddsAnnotationMetadata() var containerResource = Assert.Single(appModel.Resources.OfType()); Assert.Equal("listmonk", containerResource.Name); - var postgres = Assert.Single(appModel.Resources.OfType()); - Assert.Equal("listmonk-postgres", postgres.Name); - var parentAnnotation = postgres.Annotations.OfType() - .SingleOrDefault(annotation => annotation.Type == "Parent"); - Assert.NotNull(parentAnnotation); - Assert.Same(containerResource, parentAnnotation.Resource); - - Assert.Contains(appModel.Resources.OfType(), resource => resource.Name == "listmonk-db"); + Assert.DoesNotContain(appModel.Resources, resource => resource is PostgresServerResource or PostgresDatabaseResource); var endpoint = Assert.Single(containerResource.Annotations.OfType()); Assert.Equal(9000, endpoint.TargetPort); @@ -46,39 +39,27 @@ public async Task AddListmonkContainerWithDefaultsAddsAnnotationMetadata() var config = await listmonk.Resource.GetEnvironmentVariablesAsync(); Assert.Equal("0.0.0.0:9000", config["LISTMONK_app__address"]); - Assert.Equal("listmonk-postgres", config["LISTMONK_db__host"]); - Assert.Equal("5432", config["LISTMONK_db__port"]); - Assert.Equal("postgres", config["LISTMONK_db__user"]); - Assert.Equal("listmonk-db", config["LISTMONK_db__database"]); - Assert.Equal("disable", config["LISTMONK_db__ssl_mode"]); } [Fact] - public async Task AddListmonkUsesConfiguredPostgresName() + public async Task WithReferenceConfiguresPostgresDatabase() { using var appBuilder = TestDistributedApplicationBuilder.Create(); - var listmonk = appBuilder.AddListmonk("listmonk", postgresName: "mailing-postgres"); + var database = appBuilder.AddPostgres("mailing-postgres") + .AddDatabase("listmonkdb"); + var listmonk = appBuilder.AddListmonk("listmonk") + .WithReference(database); var config = await listmonk.Resource.GetEnvironmentVariablesAsync(); Assert.Equal("mailing-postgres", config["LISTMONK_db__host"]); - } - - [Fact] - public async Task AddListmonkUsesConfiguredDatabaseName() - { - using var appBuilder = TestDistributedApplicationBuilder.Create(); - - var listmonk = appBuilder.AddListmonk("listmonk", databaseName: "listmonkdb"); - - var config = await listmonk.Resource.GetEnvironmentVariablesAsync(); - - Assert.Equal("listmonk-postgres", config["LISTMONK_db__host"]); Assert.Equal("5432", config["LISTMONK_db__port"]); Assert.Equal("postgres", config["LISTMONK_db__user"]); Assert.Equal("listmonkdb", config["LISTMONK_db__database"]); Assert.Equal("disable", config["LISTMONK_db__ssl_mode"]); + + Assert.Contains(listmonk.Resource.Annotations.OfType(), annotation => annotation.Resource == database.Resource); } [Fact] @@ -99,7 +80,10 @@ public async Task WithDatabaseConfigurationMethodsAddEnvironmentVariables() { using var appBuilder = TestDistributedApplicationBuilder.Create(); + var database = appBuilder.AddPostgres("postgres") + .AddDatabase("listmonkdb"); var listmonk = appBuilder.AddListmonk("listmonk") + .WithReference(database) .WithDatabaseSslMode("require") .WithDatabaseMaxOpenConnections(50) .WithDatabaseMaxIdleConnections(10) diff --git a/tests/CommunityToolkit.Aspire.Hosting.Listmonk.Tests/ListmonkPublicApiTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Listmonk.Tests/ListmonkPublicApiTests.cs index 0fd9b5580..6d5df5bab 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Listmonk.Tests/ListmonkPublicApiTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Listmonk.Tests/ListmonkPublicApiTests.cs @@ -30,51 +30,55 @@ public void AddListmonkShouldThrowWhenNameIsNull() } [Fact] - public void AddListmonkShouldThrowWhenDatabaseNameIsEmpty() + public void WithUploadsBindMountShouldThrowWhenSourceIsNull() { var builder = TestDistributedApplicationBuilder.Create(); + var listmonk = builder.AddListmonk("listmonk"); + string source = null!; - var action = () => builder.AddListmonk("listmonk", databaseName: string.Empty); + var action = () => listmonk.WithUploadsBindMount(source); - var exception = Assert.Throws(action); - Assert.Equal("databaseName", exception.ParamName); + var exception = Assert.Throws(action); + Assert.Equal(nameof(source), exception.ParamName); } [Fact] - public void AddListmonkShouldThrowWhenPostgresNameIsEmpty() + public void WithAppAddressShouldThrowWhenAddressIsNull() { var builder = TestDistributedApplicationBuilder.Create(); + var listmonk = builder.AddListmonk("listmonk"); + string address = null!; - var action = () => builder.AddListmonk("listmonk", postgresName: string.Empty); + var action = () => listmonk.WithAppAddress(address); - var exception = Assert.Throws(action); - Assert.Equal("postgresName", exception.ParamName); + var exception = Assert.Throws(action); + Assert.Equal(nameof(address), exception.ParamName); } [Fact] - public void WithUploadsBindMountShouldThrowWhenSourceIsNull() + public void WithReferenceShouldThrowWhenBuilderIsNull() { - var builder = TestDistributedApplicationBuilder.Create(); - var listmonk = builder.AddListmonk("listmonk"); - string source = null!; + var appBuilder = TestDistributedApplicationBuilder.Create(); + IResourceBuilder listmonk = null!; + var database = appBuilder.AddPostgres("postgres").AddDatabase("listmonkdb"); - var action = () => listmonk.WithUploadsBindMount(source); + var action = () => listmonk.WithReference(database); var exception = Assert.Throws(action); - Assert.Equal(nameof(source), exception.ParamName); + Assert.Equal("builder", exception.ParamName); } [Fact] - public void WithAppAddressShouldThrowWhenAddressIsNull() + public void WithReferenceShouldThrowWhenDatabaseIsNull() { var builder = TestDistributedApplicationBuilder.Create(); var listmonk = builder.AddListmonk("listmonk"); - string address = null!; + IResourceBuilder database = null!; - var action = () => listmonk.WithAppAddress(address); + var action = () => listmonk.WithReference(database); var exception = Assert.Throws(action); - Assert.Equal(nameof(address), exception.ParamName); + Assert.Equal("database", exception.ParamName); } [Fact] From 0d4ca42d3cbf4dbe0750094e13cf4b2015db1698 Mon Sep 17 00:00:00 2001 From: axi Date: Wed, 8 Jul 2026 09:42:53 +0300 Subject: [PATCH 11/12] Refactor `AddListmonk` to simplify PostgreSQL configuration with `WithReference`. Update XML docs and examples accordingly. --- .../ListmonkBuilderExtensions.cs | 43 ++++++++----------- .../README.md | 7 ++- 2 files changed, 23 insertions(+), 27 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Listmonk/ListmonkBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Listmonk/ListmonkBuilderExtensions.cs index 5ea871c01..ffedfdd58 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Listmonk/ListmonkBuilderExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Listmonk/ListmonkBuilderExtensions.cs @@ -1,5 +1,4 @@ using Aspire.Hosting.ApplicationModel; -using CommunityToolkit.Aspire.Hosting.Listmonk; #pragma warning disable ASPIREATS001 // AspireExport is experimental @@ -36,8 +35,6 @@ public static class ListmonkBuilderExtensions /// The . /// The name of the resource. This name will be used as the connection string name when referenced in a dependency. /// The host port for the listmonk HTTP endpoint. If , Aspire will assign a random host port. - /// The name of the PostgreSQL server resource. Defaults to {name}-postgres. - /// The name of the PostgreSQL database resource. Defaults to {name}-db. /// A reference to the . /// /// @@ -45,7 +42,10 @@ public static class ListmonkBuilderExtensions /// /// var builder = DistributedApplication.CreateBuilder(args); /// - /// var listmonk = builder.AddListmonk("listmonk"); + /// var db = builder.AddPostgres("postgres") + /// .AddDatabase("db"); + /// var listmonk = builder.AddListmonk("listmonk") + /// .WithReference(db); /// var api = builder.AddProject<Projects.Api>("api") /// .WithReference(listmonk); /// @@ -57,24 +57,13 @@ public static class ListmonkBuilderExtensions public static IResourceBuilder AddListmonk( this IDistributedApplicationBuilder builder, [ResourceName] string name, - int? port = null, - [ResourceName] string? postgresName = null, - [ResourceName] string? databaseName = null) + int? port = null) { ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrEmpty(name); - if (postgresName is not null) - { - ArgumentException.ThrowIfNullOrEmpty(postgresName); - } - - if (databaseName is not null) - { - ArgumentException.ThrowIfNullOrEmpty(databaseName); - } var resource = new ListmonkResource(name); - var listmonk = builder.AddResource(resource) + return builder.AddResource(resource) .WithImage(ListmonkContainerImageTags.Image, ListmonkContainerImageTags.Tag) .WithImageRegistry(ListmonkContainerImageTags.Registry) .WithHttpEndpoint(port: port, targetPort: ListmonkPort, name: ListmonkResource.PrimaryEndpointName) @@ -82,12 +71,6 @@ public static IResourceBuilder AddListmonk( .WithArgs("-c", "./listmonk --install --idempotent --yes --config '' && ./listmonk --upgrade --yes --config '' && ./listmonk --config ''") .WithEnvironment(AppAddressEnvVarName, "0.0.0.0:9000") .WithHttpHealthCheck("/health"); - - var postgres = builder.AddPostgres(postgresName ?? $"{name}-postgres") - .WithParentRelationship(listmonk.Resource); - var database = postgres.AddDatabase(databaseName ?? $"{name}-db"); - - return listmonk.ConfigurePostgreSQL(database); } /// @@ -105,10 +88,20 @@ public static IResourceBuilder WithAppAddress(this IResourceBu return builder.WithEnvironment(AppAddressEnvVarName, address); } - private static IResourceBuilder ConfigurePostgreSQL( + /// + /// References a as the PostgreSQL database for the listmonk resource. + /// + /// The listmonk resource builder. + /// The PostgreSQL database resource builder. + /// The . + [AspireExport] + public static IResourceBuilder WithReference( this IResourceBuilder builder, IResourceBuilder database) { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(database); + var postgres = database.Resource.Parent; return builder @@ -116,7 +109,7 @@ private static IResourceBuilder ConfigurePostgreSQL( .WithEnvironment(DatabasePortEnvVarName, ReferenceExpression.Create($"{postgres.PrimaryEndpoint.Property(EndpointProperty.TargetPort)}")) .WithEnvironment(DatabaseUserEnvVarName, postgres.UserNameReference) .WithEnvironment(DatabasePasswordEnvVarName, postgres.PasswordParameter) - .WithEnvironment(DatabaseNameEnvVarName, database.Resource.DatabaseName) + .WithEnvironment(DatabaseNameEnvVarName, ReferenceExpression.Create($"{database.Resource.DatabaseName}")) .WithDatabaseSslMode("disable") .WaitFor(database); } diff --git a/src/CommunityToolkit.Aspire.Hosting.Listmonk/README.md b/src/CommunityToolkit.Aspire.Hosting.Listmonk/README.md index ae3df680e..c460ebc9a 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Listmonk/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Listmonk/README.md @@ -16,8 +16,11 @@ aspire add CommunityToolkit.Aspire.Hosting.Listmonk var builder = DistributedApplication.CreateBuilder(args); var adminPassword = builder.AddParameter("listmonk-admin-password", secret: true); +var database = builder.AddPostgres("postgres") + .AddDatabase("listmonkdb"); -var listmonk = builder.AddListmonk("listmonk", postgresName: "postgres", databaseName: "listmonkdb") +var listmonk = builder.AddListmonk("listmonk") + .WithReference(database) .WithAdminCredentials("admin", adminPassword) .WithTimeZone("Etc/UTC") .WithUploadsVolume(); @@ -47,7 +50,7 @@ The integration exposes listmonk's container configuration environment variables | Method | Environment variable | | --- | --- | | `WithAppAddress` | `LISTMONK_app__address` | -| `AddListmonk` | `LISTMONK_db__host`, `LISTMONK_db__port`, `LISTMONK_db__user`, `LISTMONK_db__password`, `LISTMONK_db__database`, `LISTMONK_db__ssl_mode` | +| `WithReference` | `LISTMONK_db__host`, `LISTMONK_db__port`, `LISTMONK_db__user`, `LISTMONK_db__password`, `LISTMONK_db__database`, `LISTMONK_db__ssl_mode` | | `WithDatabaseSslMode` | `LISTMONK_db__ssl_mode` | | `WithDatabaseMaxOpenConnections` | `LISTMONK_db__max_open` | | `WithDatabaseMaxIdleConnections` | `LISTMONK_db__max_idle` | From fe2e274c5aa97cdd2c524c14edb8eac40f9aef66 Mon Sep 17 00:00:00 2001 From: axi Date: Wed, 8 Jul 2026 10:10:54 +0300 Subject: [PATCH 12/12] Remove obsolete `#pragma warning disable/restore ASPIREATS001` directives from `ListmonkBuilderExtensions` and `ListmonkResource`. --- .../ListmonkBuilderExtensions.cs | 4 ---- .../ListmonkResource.cs | 4 ---- 2 files changed, 8 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Listmonk/ListmonkBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Listmonk/ListmonkBuilderExtensions.cs index ffedfdd58..69ae09b2b 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Listmonk/ListmonkBuilderExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Listmonk/ListmonkBuilderExtensions.cs @@ -1,7 +1,5 @@ using Aspire.Hosting.ApplicationModel; -#pragma warning disable ASPIREATS001 // AspireExport is experimental - namespace Aspire.Hosting; /// @@ -315,5 +313,3 @@ public static IResourceBuilder WithUploadsBindMount(this IReso return builder.WithBindMount(source, UploadsPath); } } - -#pragma warning restore ASPIREATS001 // AspireExport is experimental diff --git a/src/CommunityToolkit.Aspire.Hosting.Listmonk/ListmonkResource.cs b/src/CommunityToolkit.Aspire.Hosting.Listmonk/ListmonkResource.cs index 983bf4874..b9d578d8b 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Listmonk/ListmonkResource.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Listmonk/ListmonkResource.cs @@ -1,5 +1,3 @@ -#pragma warning disable ASPIREATS001 // AspireExport is experimental - namespace Aspire.Hosting.ApplicationModel; /// @@ -45,5 +43,3 @@ IEnumerable> IResourceWithConnectionSt yield return new("Uri", UriExpression); } } - -#pragma warning restore ASPIREATS001 // AspireExport is experimental