diff --git a/CommunityToolkit.Aspire.slnx b/CommunityToolkit.Aspire.slnx index f05008d80..1410f8102 100644 --- a/CommunityToolkit.Aspire.slnx +++ b/CommunityToolkit.Aspire.slnx @@ -281,6 +281,7 @@ + @@ -354,6 +355,7 @@ + diff --git a/eng/testing/generate-test-list-for-workflow.sh b/eng/testing/generate-test-list-for-workflow.sh index 91f1afba4..1934e292e 100755 --- a/eng/testing/generate-test-list-for-workflow.sh +++ b/eng/testing/generate-test-list-for-workflow.sh @@ -22,13 +22,7 @@ case "${MODE}" in get_test_names ;; --json) - mapfile -t tests < <(get_test_names) - python - <<'PY' "${tests[@]}" -import json -import sys - -print(json.dumps(sys.argv[1:])) -PY + get_test_names | python3 -c 'import json, sys; print(json.dumps([line.rstrip("\n") for line in sys.stdin if line.rstrip("\n")]))' ;; --workflow) echo "${INDENT}# Hosting integration tests" diff --git a/examples/opentelemetry-collector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.AppHost.TypeScript/apphost.mts b/examples/opentelemetry-collector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.AppHost.TypeScript/apphost.mts index b9e0f569e..c68db3da6 100644 --- a/examples/opentelemetry-collector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.AppHost.TypeScript/apphost.mts +++ b/examples/opentelemetry-collector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.AppHost.TypeScript/apphost.mts @@ -2,8 +2,13 @@ import { createBuilder } from "./.aspire/modules/aspire.mjs"; const builder = await createBuilder(); -// addOpenTelemetryCollector — default overload -const collector = await builder.addOpenTelemetryCollector("collector"); +// addOpenTelemetryCollector — settings overload +const collector = await builder.addOpenTelemetryCollector("collector", { + configureSettings: async settings => { + // This TypeScript smoke validates settings export/startup; C# tests cover the default health endpoint. + await settings.disableHealthcheck.set(true); + }, +}); await collector.withConfig("./otel-config.yaml"); await collector.withAppForwarding(); @@ -14,4 +19,3 @@ const _grpcEndpointName = await _grpcEndpoint.endpointName(); const _httpEndpointName = await _httpEndpoint.endpointName(); await builder.build().run(); - diff --git a/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/apphost.mts b/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/apphost.mts new file mode 100644 index 000000000..187e4f361 --- /dev/null +++ b/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/apphost.mts @@ -0,0 +1,16 @@ +import { createBuilder } from "./.aspire/modules/aspire.mjs"; + +const builder = await createBuilder(); + +const vercel = await builder.addVercelEnvironment("vercel"); +await vercel.withVercelProductionDeployments(); +await builder.addDockerComposeEnvironment("docker"); + +const api = await builder + .addNodeApp("api", "./ct-aspire-vercel-typescript", "server.mjs"); + +await api.withVercelProjectName("typescript-api"); +await api.withComputeEnvironment(vercel); +await api.withEnvironment("GREETING", "hello-from-typescript-apphost"); + +await builder.build().run(); diff --git a/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/aspire.config.json b/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/aspire.config.json new file mode 100644 index 000000000..cbf58e69d --- /dev/null +++ b/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/aspire.config.json @@ -0,0 +1,23 @@ +{ + "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": { + "Aspire.Hosting.JavaScript": "13.4.3", + "Aspire.Hosting.Docker": "13.4.3", + "CommunityToolkit.Aspire.Hosting.Vercel": "../../../src/CommunityToolkit.Aspire.Hosting.Vercel/CommunityToolkit.Aspire.Hosting.Vercel.csproj" + } +} diff --git a/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/ct-aspire-vercel-typescript/.gitignore b/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/ct-aspire-vercel-typescript/.gitignore new file mode 100644 index 000000000..e985853ed --- /dev/null +++ b/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/ct-aspire-vercel-typescript/.gitignore @@ -0,0 +1 @@ +.vercel diff --git a/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/ct-aspire-vercel-typescript/server.mjs b/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/ct-aspire-vercel-typescript/server.mjs new file mode 100644 index 000000000..2d9848291 --- /dev/null +++ b/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/ct-aspire-vercel-typescript/server.mjs @@ -0,0 +1,15 @@ +import http from "node:http"; + +const server = http.createServer((request, response) => { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ + ok: true, + path: request.url, + greeting: process.env.GREETING ?? null + })); +}); + +const port = Number(process.env.PORT ?? 80); +server.listen(port, "0.0.0.0", () => { + console.log(`listening on ${port}`); +}); diff --git a/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/eslint.config.mjs b/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/eslint.config.mjs new file mode 100644 index 000000000..84d4644c1 --- /dev/null +++ b/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/eslint.config.mjs @@ -0,0 +1,18 @@ +// @ts-check + +import { defineConfig } from 'eslint/config'; +import tseslint from 'typescript-eslint'; + +export default defineConfig({ + files: ['apphost.mts'], + extends: [tseslint.configs.base], + languageOptions: { + parserOptions: { + projectService: true, + }, + }, + rules: { + '@typescript-eslint/no-floating-promises': ['error', { checkThenables: true }], + }, +}); + diff --git a/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/package-lock.json b/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/package-lock.json new file mode 100644 index 000000000..0618ca2db --- /dev/null +++ b/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/package-lock.json @@ -0,0 +1,2037 @@ +{ + "name": "communitytoolkit-aspire-hosting-vercel-apphost-typescript", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "communitytoolkit-aspire-hosting-vercel-apphost-typescript", + "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.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "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.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "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.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.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.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "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.20.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", + "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.1.tgz", + "integrity": "sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.62.1", + "@typescript-eslint/type-utils": "8.62.1", + "@typescript-eslint/utils": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.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.62.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.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.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.62.1.tgz", + "integrity": "sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.62.1", + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1", + "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.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.1.tgz", + "integrity": "sha512-yQ3RgY5RkSBpsNS1Bx/JQEcA24FOSdfGktoyprAr5u18390UQdtVcfnEv4nIrIshNnavlVyZBKxQwT1fIAE6cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.62.1", + "@typescript-eslint/types": "^8.62.1", + "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.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.1.tgz", + "integrity": "sha512-r4d249KbQ1SFdpeStvob8Ih6aPPIzfqllPVOtvhve6ZcpuVcYo5/7zUWckKpHE7StASX4kTKZTLf0WQm/wPkcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1" + }, + "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.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.1.tgz", + "integrity": "sha512-xadytJqX9vJVQ2fdQjkcIVigwaOJNWkpjdLt6cEQ+xPnrI1fkp+/jZE/I97k9KUjqtpd25i0HeyZf3T6dutv2g==", + "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.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.62.1.tgz", + "integrity": "sha512-aXM5xlqXiTxPibXB93cLAURfT3rlizf7uMXISCXy66Isr/9hISJx3yDsKl0L7lKa51b8JpFuNKby0/O0pEm9jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1", + "@typescript-eslint/utils": "8.62.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.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.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.1.tgz", + "integrity": "sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q==", + "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.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.1.tgz", + "integrity": "sha512-xMcW9oP9u7fAMXYs9A65CVmtLQe2r//oXINHfi8HV+oiqhih17sbLdhXr4540YWlgpDKQdY854OL5ZrdCiQsAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.62.1", + "@typescript-eslint/tsconfig-utils": "8.62.1", + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.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.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.1.tgz", + "integrity": "sha512-sHtbPfuKNZCG+ih8SyjjucqRntSVmp8XgL5u6o9mAhiSn8ds5o/M/XdM0abweme2Tln3szOstOrZ9OXitvPh0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.62.1", + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1" + }, + "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.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.1.tgz", + "integrity": "sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.62.1", + "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.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "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.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "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.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "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/chokidar/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/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.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "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.6.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.6.0.tgz", + "integrity": "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@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/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/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/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/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "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.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "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.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "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.62.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.62.1.tgz", + "integrity": "sha512-vymnnM5g0AKQDSAyfP12nMIBvgwgA42syg74kkuZ4x1VuTzwQKwc5h9rGxeShCjny5o+zWAb6OEoz7XLgrIkIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.62.1", + "@typescript-eslint/parser": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1", + "@typescript-eslint/utils": "8.62.1" + }, + "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.1.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/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/package.json b/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/package.json new file mode 100644 index 000000000..c7da7e81a --- /dev/null +++ b/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/package.json @@ -0,0 +1,28 @@ +{ + "name": "communitytoolkit-aspire-hosting-vercel-apphost-typescript", + "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/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/tsconfig.json b/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript/tsconfig.json new file mode 100644 index 000000000..e70fa1fa2 --- /dev/null +++ b/examples/vercel/CommunityToolkit.Aspire.Hosting.Vercel.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/src/CommunityToolkit.Aspire.Hosting.Vercel/CommunityToolkit.Aspire.Hosting.Vercel.csproj b/src/CommunityToolkit.Aspire.Hosting.Vercel/CommunityToolkit.Aspire.Hosting.Vercel.csproj new file mode 100644 index 000000000..e610bebb3 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/CommunityToolkit.Aspire.Hosting.Vercel.csproj @@ -0,0 +1,19 @@ + + + + hosting deployment vercel dockerfile containers + An Aspire hosting integration for deploying Aspire workloads to Vercel Dockerfile hosting. + true + $(NoWarn);ASPIREATS001 + + + + + + + + + + + + diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md b/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md new file mode 100644 index 000000000..3fd31095a --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/README.md @@ -0,0 +1,231 @@ +# Vercel hosting integration + +Use this integration to model, configure, and deploy Aspire workloads to Vercel's Dockerfile-based hosting and Vercel Services. + +## Getting started + +Install the package in your AppHost project: + +```bash +aspire add CommunityToolkit.Aspire.Hosting.Vercel +``` + +## Usage example + +Add a Vercel environment and a workload that participates in Aspire's image build/push model. Language integrations such as `AddNodeApp` can emit generated Dockerfile metadata for deploy. When Vercel is the only compute environment, image-build workloads target it by default: + +```csharp +var builder = DistributedApplication.CreateBuilder(args); + +builder.AddVercelEnvironment("vercel"); + +builder.AddNodeApp("api", "../api", "server.mjs"); + +builder.Build().Run(); +``` + +.NET projects use Aspire's existing Dockerfile publish support: + +```csharp +builder.AddProject("api") + .PublishAsDockerFile(); +``` + +Checked-in Dockerfiles also work for low-level container resources: + +```csharp +builder.AddContainer("api", "api") + .WithDockerfile("../api", "Dockerfile"); +``` + +By default, each public Aspire workload becomes a Vercel project root. Referenced non-public workloads become private Vercel Services inside that project and get their own VCR repository: + +```bash +vercel link --cwd --yes --project +vercel pull --cwd --yes --environment +docker login vcr.vercel.com --username --password-stdin +aspire build/push -> vcr.vercel.com///: +docker buildx imagetools inspect --format '{{json .Manifest}}' vcr.vercel.com///: +vercel deploy --cwd --project --prebuilt --yes +``` + +Use `WithVercelProductionDeployments` to add `--prod`, `WithVercelTarget` to add `--target`, and `WithVercelScope` to deploy to a team or account scope. + +When an AppHost contains multiple compute environments, use Aspire's standard `WithComputeEnvironment` API to choose Vercel for a workload: + +```csharp +var vercel = builder.AddVercelEnvironment("vercel"); + +builder.AddContainer("api", "api") + .WithDockerfile("../api", "Dockerfile") + .WithComputeEnvironment(vercel); +``` + +Use `WithVercelProjectName` when an Aspire-managed public project root should deploy to a specific Vercel project name instead of inferring one from the source directory: + +```csharp +builder.AddContainer("api", "api") + .WithDockerfile("../api", "Dockerfile") + .WithVercelProjectName("my-api"); +``` + +Linked projects with `.vercel/project.json` keep their existing provider project identity and take precedence over `WithVercelProjectName`. Project names apply only to public project roots; private services use the normalized Aspire resource name as their Vercel service and VCR repository name. + +## Project and service mapping + +The integration maps Aspire resources to Vercel resources using the public routing shape of your app: + +| Aspire model | Vercel model | +| --- | --- | +| One Vercel-targeted resource | One Vercel project with one public root service, preserving the original single-resource behavior. | +| Multiple Vercel-targeted resources with external HTTP endpoints | One Vercel project per external HTTP resource. Each project receives public traffic through its root service. | +| A non-public resource referenced by exactly one public root | A private Vercel Service inside that public root's project. The service gets its own image repository at `vcr.vercel.com///:`. | +| A non-public resource referenced by multiple public roots | Rejected as ambiguous until an explicit mapping API exists. Deploy the shared resource separately or reference it from exactly one public workload. | +| A non-public resource with no public owner | Rejected. Add an external HTTP endpoint, reference it from one public workload, or deploy it separately. | + +Vercel Services are private by default. Public traffic reaches only the project root through the generated project-level rewrite. Same-project server-to-server references are emitted as Vercel service bindings, so the caller receives a private service URL in the requested environment variable. Cross-project references are emitted as deterministic production URLs and therefore require `WithVercelProductionDeployments`. + +Example with one public app and one private backend: + +```csharp +var vercel = builder.AddVercelEnvironment("vercel") + .WithVercelProductionDeployments(); + +var backend = builder.AddNodeApp("backend", "../backend", "server.mjs") + .WithEndpoint(name: "http", scheme: "http", env: "PORT", isExternal: false) + .WithComputeEnvironment(vercel); + +builder.AddNodeApp("frontend", "../frontend", "server.mjs") + .WithEndpoint(name: "http", scheme: "http", env: "PORT", isExternal: true) + .WithEnvironment("BACKEND_URL", backend.GetEndpoint("http")) + .WithComputeEnvironment(vercel); +``` + +This produces one Vercel project rooted by `frontend`, two Vercel Services (`frontend` and `backend`), two VCR repositories under the same project, and a service binding that injects `BACKEND_URL` into `frontend`. + +For low-level container resources, Vercel requires Aspire image-build metadata. `WithDockerfile` is supported for checked-in Dockerfiles and Containerfiles, including custom names or paths outside the source root. Generated Dockerfiles (`WithDockerfileFactory` / `WithDockerfileBuilder`) and language integrations such as `AddNodeApp` use Aspire's built-in image build path without staging source or writing generated files into the user checkout. Resource-level `WithContainerRegistry` remains unsupported for Vercel-targeted resources because the integration must push to the Vercel project-owned VCR repository used by the generated Build Output API metadata. Add a `.dockerignore` file to keep local files such as `.env`, test artifacts, large assets, and other files out of the local Docker build context. + +## How it works end to end + +`aspire publish` and `aspire deploy` intentionally do different work: + +1. `aspire publish` validates the targeted resources and writes `vercel-deployments.json`, a deterministic review plan with command shape, Dockerfile/source information, and environment variable names. It does not call Vercel, resolve secrets, or push images. +2. The deploy prerequisite step validates Vercel CLI, Docker buildx, Vercel auth, configured scope, existing state compatibility, Vercel project names, endpoints, and unsupported Aspire concepts before provider mutation. +3. The deploy prerequisite step groups resources into Vercel project roots and private services before provider mutation. Grouping failures happen before any Vercel project is created. +4. For each project group, the integration creates or links one Vercel project in an Aspire-owned scratch directory, configures sensitive project environment variables with `vercel env add --sensitive` over standard input, and runs [`vercel pull`](https://vercel.com/docs/cli/pull) once in that scratch directory to obtain `.vercel/project.json`, `.vercel/.env..local`, and the short-lived `VERCEL_OIDC_TOKEN`. +5. The integration decodes only routing claims from the Vercel OIDC JWT payload, configures [Vercel Container Registry](https://vercel.com/docs/container-registry) (`vcr.vercel.com//`) as each service resource's Aspire deployment target registry, and lets Aspire's built-in build/push steps build each service image and push it to the service-specific VCR repository. +6. After the push, deploy logs in to VCR again for each service, runs `docker buildx imagetools inspect --format '{{json .Manifest}}'`, and selects the linux/amd64 manifest digest. Live Vercel validation rejected OCI index digests, so the generated artifact references the concrete platform manifest accepted by [Vercel Container Images](https://vercel.com/docs/functions/container-images). +7. Deploy writes [Vercel Build Output API](https://vercel.com/docs/build-output-api) v3 files under Aspire temp/output storage: `.vercel/project.json`, root `.vercel/output/config.json` with Vercel Services and bindings, and per-service `.vercel/output/services//functions/index.func/.vc-config.json` files with `runtime: "container"` and digest-pinned VCR `handler` values. +8. Deploy runs one [`vercel deploy --prebuilt`](https://vercel.com/docs/cli/deploy) per project group, parses the deployment URL from JSON or plain CLI output, verifies readiness with [`vercel inspect --wait`](https://vercel.com/docs/cli/inspect), records deployment URLs/image digests/state, and adds deployment URLs to the pipeline summary. +9. `aspire destroy` reads saved deployment state first, removes tracked project environment variables from linked projects, deletes only Aspire-managed Vercel projects, treats already-missing projects as converged, and saves partial state after each successful delete so retry remains safe. + +Non-secret Aspire environment variables configured on a resource are processed during publish/deploy and written to that service's generated Build Output container function config: + +```csharp +builder.AddContainer("api", "api") + .WithDockerfile("../api", "Dockerfile") + .WithEnvironment("GREETING", "hello"); +``` + +The value is not passed with `vercel deploy --env`; it is written into the generated `.vc-config.json` for that service. + +Secret parameters, connection strings, and composite values that contain secrets are configured as [sensitive Vercel project environment variables](https://vercel.com/docs/environment-variables/sensitive-environment-variables) before deploy. Because [`vercel env add`](https://vercel.com/docs/cli/env) is scoped through [`vercel link`](https://vercel.com/docs/cli/link) metadata and does not accept `--project`, the integration links a temporary scratch directory outside the source tree and sends the value through standard input instead of putting it on the command line: + +```csharp +var apiKey = builder.AddParameter("api-key", secret: true); + +builder.AddContainer("api", "api") + .WithDockerfile("../api", "Dockerfile") + .WithEnvironment("API_KEY", apiKey); +``` + +```bash +vercel link --cwd --yes --project +vercel env add API_KEY production --cwd --yes --force --sensitive +``` + +Secret project environment variables are written to `production` when `WithVercelProductionDeployments` is used, to the configured `WithVercelTarget` value when one is set, and to `preview` otherwise. Deploy treats secret environment variable names configured in the AppHost as Aspire-owned for that Vercel target and overwrites those values on each deploy. + +Endpoint references to other Vercel-targeted workloads are mapped based on project grouping. Same-project references become service bindings; cross-project references become deterministic production project URLs. + +```csharp +var vercel = builder.AddVercelEnvironment("vercel") + .WithVercelProductionDeployments(); + +var backend = builder.AddNodeApp("backend", "../backend", "server.mjs") + .WithEndpoint(name: "http", scheme: "http", env: "PORT", isExternal: true) + .WithComputeEnvironment(vercel); + +builder.AddNodeApp("api", "../api", "server.mjs") + .WithEndpoint(name: "http", scheme: "http", env: "PORT", isExternal: true) + .WithEnvironment("BACKEND_URL", backend.GetEndpoint("http")) + .WithComputeEnvironment(vercel); +``` + +Because both resources are public project roots, `BACKEND_URL` is deployed as `https://.vercel.app`. Preview and custom-target deployments reject cross-project endpoint references because those URLs are assigned by Vercel after deployment. Same-project service bindings can only inject direct URL-valued environment variables. + +## Deployment behavior + +- `aspire start` is unchanged. The Vercel environment is publish/deploy-only. +- `aspire publish` writes a deterministic `vercel-deployments.json` plan for the targeted resources, including environment variable and service binding names. +- `aspire deploy` validates the Vercel CLI, Docker authentication, and configured scope; groups public project roots and private services; creates Aspire-managed Vercel projects when needed; links one temporary scratch directory per project group for project-scoped Vercel CLI operations; configures secret project environment variables; pulls Vercel project settings into that scratch directory for VCR OIDC authentication; configures one VCR repository per service for Aspire's built-in build/push steps; resolves pushed image tags to digests; generates a Vercel Services Build Output API directory; invokes `vercel deploy --prebuilt` once per project group; and verifies each deployment with `vercel inspect --wait --format=json` before saving deployment state. The deployment summary records the deployment URL, deployment state records each service's VCR image digest and Build Output API version for diagnostics, and production deployments also record the deterministic `https://.vercel.app` production URL. +- `aspire destroy` reads Aspire deployment state first and removes only Vercel projects that were created by this integration for the same environment/scope. Missing state or unmanaged-only state does not require Vercel CLI/auth. State is preserved if project removal fails. + +## Validation coverage + +The implementation keeps Vercel-specific behavior unit-testable behind injectable services and pure helpers. Unit tests verify pipeline step registration and direct step actions, Aspire built-in build/push ordering, project/service grouping, VCR registry annotations, linux/amd64 build target selection, project/env/destroy state transitions, generated Build Output API files for Vercel Services and service bindings, secret redaction, exact CLI argument boundaries, Docker digest parsing, Vercel deploy/inspect output parsing, compact JWT claim decoding, Vercel dotenv parsing, TypeScript AppHost publish/projection, the TypeScript start smoke when `pwsh` is available, and failure messages for unsupported Aspire concepts. + +The integration was also validated against Vercel end to end during development: Dockerfile workloads were built and pushed to VCR through Aspire's built-in pipeline, deployed with `vercel deploy --prebuilt`, verified with `vercel inspect`, called through the deployed URL where deployment protection allowed it, and cleaned up with destroy. The Vercel Services Build Output shape was separately deployed against real disposable Vercel projects to confirm `vercel deploy --prebuilt` accepts generated services and bindings. + +## Prerequisites + +- An Aspire workload that participates in Aspire's image build/push model, such as a .NET project, a language app resource that emits Dockerfile metadata, or a resource configured with `WithDockerfile`. +- The deployed container should listen on `$PORT`; the default port is `80`. +- Vercel CLI 54.18.6 or later installed and available on `PATH`. This preview depends on `vercel link`, `vercel pull`, `deploy --prebuilt`, `inspect --wait --timeout --format=json`, `env add --sensitive`, and `project remove`; older CLI versions are rejected during deploy preflight. +- Docker CLI with buildx and a running Docker daemon for Aspire's built-in image build/push and VCR digest inspection. +- Vercel authentication from an existing CLI login or the `VERCEL_TOKEN` environment variable. +- Vercel project access that allows `vercel pull` to mint the `VERCEL_OIDC_TOKEN` used by VCR. +- Any domains, deployment protection, or build-time environment variables configured in Vercel. + +## Known limitations + +This preview integration intentionally supports a narrow Vercel Dockerfile deployment contract: + +| Aspire concept | Preview behavior | +| --- | --- | +| Image-build compute resources | Supported through Aspire's built-in project/Dockerfile image build and push pipeline, Vercel Container Registry, digest resolution, and Build Output API prebuilt deploy. .NET projects, checked-in custom Dockerfile names, non-root Dockerfile paths, and generated Dockerfiles from language app integrations are supported. | +| Container registries, image build, image push | Uses Vercel Container Registry and the Vercel OIDC token from `vercel pull` as the deployment target registry for Aspire's built-in push steps. Resource-level `WithContainerRegistry` is rejected for Vercel-targeted resources. | +| Public routing and project/service cardinality | A single resource deploys as one project for compatibility. With multiple resources, external HTTP workloads are public Vercel project roots; referenced non-public workloads become private Vercel Services inside one owning project. Shared private services referenced by multiple public roots are rejected. | +| Non-secret environment variables | Written into the generated per-service Build Output function config; names must use letters, digits, and underscores and values are redacted from publish output. | +| Secret parameters and connection strings | Configured as sensitive Vercel project environment variables with `vercel env add --force --sensitive`; values are supplied on stdin and redacted from publish output. Missing secret values are rejected. | +| Docker build args/secrets | Supported when the underlying Aspire image build integration supports them; Aspire's built-in build step handles the local Docker build before Vercel deploy receives only Build Output API metadata. | +| Service discovery, endpoint references, `WithReference` to another resource | Same-project direct URL references become Vercel service bindings. Cross-project references are supported for external HTTP(S) endpoints on workloads in the same Vercel production environment by using deterministic `https://.vercel.app` URLs. Cross-project references are rejected for preview/custom targets because those URLs are assigned after deployment. | +| Endpoints | External HTTP and HTTPS endpoints with HTTP transports are accepted as public project roots when they map to one target port. Internal HTTP endpoints are accepted only for services owned by one public root or for single-resource compatibility. Non-HTTP(S) endpoints/transports or multiple target ports are rejected. | +| Volumes, bind mounts, Aspire container files | Rejected. Include required files in the source tree or use a Vercel-supported external service for state. | +| Health checks, replicas, wait/dependency ordering | Ignored by deployment planning until they are mapped to Vercel-native behavior. Probes are rejected. | +| Container entrypoint overrides and Aspire command-line args | Rejected. Configure runtime command behavior through the Dockerfile/workload publish output or Vercel project settings. | +| Existing linked Vercel projects | Supported for deploy. Destroy preserves projects that were linked before deploy and only deletes Aspire-created projects recorded in state. | + +Managed Vercel project names are inferred from the public root source directory name when no `.vercel/project.json` link exists and no explicit `WithVercelProjectName` is configured. The integration slugifies inferred names to Vercel's lowercase, hyphenated project-name form before deployment and rejects duplicate public project names within the same Vercel environment, including linked projects and explicitly configured names. Service names are inferred from Aspire resource names and must be unique within the Vercel environment. This preview intentionally does not yet expose resource-level alias, domain, framework/output, build-setting, deployment-protection, shared-private-service, or per-resource target APIs; link public roots to existing Vercel projects before deploy when you need existing provider project identities. + +If the source root already contains a `vercel.json`, the integration reads it only for validation; the source file is not modified. Top-level Vercel build, env, routing, function, and services settings are rejected because this preview owns the generated Build Output API container function, catch-all route, and AppHost-driven environment variables. + +## Additional documentation + +- [Run any Dockerfile on Vercel](https://vercel.com/blog/dockerfile-on-vercel) +- [Vercel Container Images](https://vercel.com/docs/functions/container-images) +- [Vercel Container Registry](https://vercel.com/docs/container-registry) +- [Vercel Build Output API](https://vercel.com/docs/build-output-api) +- [Build Output API configuration](https://vercel.com/docs/build-output-api/configuration) +- [Vercel Services](https://vercel.com/docs/vercel-platform/services) +- [Vercel Service bindings](https://vercel.com/docs/vercel-platform/services/service-bindings) +- [Vercel CLI deploy](https://vercel.com/docs/cli/deploy) +- [Vercel CLI link](https://vercel.com/docs/cli/link) +- [Vercel CLI pull](https://vercel.com/docs/cli/pull) +- [Vercel CLI env](https://vercel.com/docs/cli/env) +- [Vercel CLI inspect](https://vercel.com/docs/cli/inspect) +- [Sensitive environment variables](https://vercel.com/docs/environment-variables/sensitive-environment-variables) + +## Feedback & contributing + +This integration is part of the .NET Aspire Community Toolkit. File issues and contribute at . diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelBuildOutputWriter.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelBuildOutputWriter.cs new file mode 100644 index 000000000..c14c8179b --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelBuildOutputWriter.cs @@ -0,0 +1,153 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +/// +/// Writes the minimal Vercel Build Output API directory that points Vercel at an Aspire-built +/// container image instead of asking Vercel to build source code. +/// +internal static class VercelBuildOutputWriter +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + WriteIndented = true + }; + + public static async Task WriteAsync( + VercelDeploymentProjectGroup group, + VercelPulledProject project, + IReadOnlyList resolvedDeployments, + CancellationToken cancellationToken) + { + // Vercel Build Output API v3 expects the file-system contract documented at + // https://vercel.com/docs/build-output-api and + // https://vercel.com/docs/build-output-api/configuration: + // .vercel/project.json copied project identity from `vercel pull` + // .vercel/output/config.json routes, services, bindings, and API version + // .vercel/output/services/{service}/functions/index.func/.vc-config.json + // { "runtime": "container", "handler": "@sha256:..." } + // There is intentionally no user source copy here; Aspire's build/push pipeline has + // already built the image, and Vercel deploy uploads only metadata that points at it. + string vercelDirectory = Path.Combine(group.RootEntry.EffectiveDeployDirectory, VercelConstants.DirectoryName); + string outputDirectory = Path.Combine(vercelDirectory, VercelConstants.OutputDirectoryName); + Directory.CreateDirectory(outputDirectory); + + await File.WriteAllTextAsync(Path.Combine(vercelDirectory, VercelConstants.ProjectFileName), project.ProjectJsonContent, cancellationToken).ConfigureAwait(false); + + var resolvedByResourceName = resolvedDeployments.ToDictionary(static resolved => resolved.PreparedDeployment.Entry.Resource.Name, StringComparer.Ordinal); + JsonObject experimentalServices = []; + JsonArray services = []; + + foreach (var service in group.Services) + { + var resolved = resolvedByResourceName[service.Entry.Resource.Name]; + string serviceOutputDirectory = Path.Combine(outputDirectory, "services", service.ServiceName); + string functionDirectory = Path.Combine(serviceOutputDirectory, "functions", "index.func"); + Directory.CreateDirectory(functionDirectory); + + var serviceConfig = new JsonObject + { + ["version"] = VercelConstants.BuildOutputApiVersion, + ["routes"] = new JsonArray + { + new JsonObject + { + ["handle"] = "filesystem" + }, + new JsonObject + { + ["src"] = "/(.*)", + ["dest"] = "/index" + } + }, + ["crons"] = new JsonArray() + }; + + var functionConfig = new JsonObject + { + ["handler"] = resolved.Image.Reference, + ["runtime"] = "container", + ["environment"] = CreateEnvironmentObject(resolved.PreparedDeployment.EnvironmentConfiguration.DeploymentEnvironmentVariables) + }; + + await File.WriteAllTextAsync(Path.Combine(serviceOutputDirectory, "config.json"), serviceConfig.ToJsonString(JsonOptions), cancellationToken).ConfigureAwait(false); + await File.WriteAllTextAsync(Path.Combine(functionDirectory, ".vc-config.json"), functionConfig.ToJsonString(JsonOptions), cancellationToken).ConfigureAwait(false); + + var serviceDefinition = new JsonObject + { + ["root"] = $"{service.ServiceName}/" + }; + var serviceManifestEntry = new JsonObject + { + ["schema"] = "experimentalServicesV2", + ["name"] = service.ServiceName, + ["root"] = service.ServiceName + }; + + JsonArray bindings = CreateBindingsArray(resolved.PreparedDeployment.EnvironmentConfiguration.ServiceBindings); + if (bindings.Count > 0) + { + serviceDefinition["bindings"] = bindings.DeepClone(); + serviceManifestEntry["bindings"] = bindings; + } + + experimentalServices[service.ServiceName] = serviceDefinition; + services.Add(serviceManifestEntry); + } + + var outputConfig = new JsonObject + { + ["version"] = VercelConstants.BuildOutputApiVersion, + ["routes"] = new JsonArray + { + new JsonObject + { + ["handle"] = "filesystem" + }, + new JsonObject + { + ["src"] = "^(?:/(.*))$", + ["destination"] = new JsonObject + { + ["service"] = group.Root.ServiceName, + ["type"] = "service" + } + } + }, + ["crons"] = new JsonArray(), + ["experimentalServicesV2"] = experimentalServices, + ["services"] = services + }; + + await File.WriteAllTextAsync(Path.Combine(outputDirectory, "config.json"), outputConfig.ToJsonString(JsonOptions), cancellationToken).ConfigureAwait(false); + } + + private static JsonObject CreateEnvironmentObject(IReadOnlyList> environmentVariables) + { + JsonObject environment = []; + foreach (var environmentVariable in environmentVariables) + { + environment[environmentVariable.Key] = environmentVariable.Value; + } + + return environment; + } + + private static JsonArray CreateBindingsArray(IReadOnlyList serviceBindings) + { + JsonArray bindings = []; + foreach (var binding in serviceBindings) + { + bindings.Add(new JsonObject + { + ["type"] = "service", + ["service"] = binding.ServiceName, + ["format"] = "url", + ["env"] = binding.EnvironmentVariableName + }); + } + + return bindings; + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliOutputParser.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliOutputParser.cs new file mode 100644 index 000000000..099d09494 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliOutputParser.cs @@ -0,0 +1,262 @@ +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using Aspire.Hosting; + +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +/// +/// Parses the structured and legacy text shapes emitted by Vercel CLI commands so the rest of +/// the deploy pipeline deals in typed results instead of CLI-specific output formats. +/// +internal static class VercelCliOutputParser +{ + public static string GetDeploymentUrl(string standardOutput) + => GetDeploymentResult(standardOutput).DeploymentUrl; + + public static VercelDeploymentResult GetDeploymentResult(string standardOutput) + { + // `vercel deploy` output has changed between CLI versions and flags. Prefer structured + // JSON when present, then fall back to the last plain HTTP(S) URL printed by the CLI. + if (TryGetJsonDeploymentResult(standardOutput) is { } jsonDeploymentResult) + { + return jsonDeploymentResult; + } + + // Older CLI versions printed the deployment URL as plain text. Keep the fallback so + // we fail only when no usable URL exists, not because formatting changed slightly. + string[] lines = standardOutput + .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + string? deploymentUrl = lines.LastOrDefault(IsHttpUrl); + if (deploymentUrl is null) + { + throw new DistributedApplicationException($"Vercel deploy output did not contain an HTTP or HTTPS deployment URL. Output: {GetTrimmedOutput(standardOutput)}"); + } + + return new(DeploymentId: null, deploymentUrl); + } + + public static VercelDeploymentInspection GetDeploymentInspection(string standardOutput) + { + try + { + // Parse the Vercel inspect JSON shapes observed across CLI versions: + // { "readyState": "READY" } + // { "state": "READY" } + // { "deployment": { "readyState": "READY" } } + // { "deployment": { "state": "READY" } } + var output = JsonSerializer.Deserialize(standardOutput); + string? readyState = output?.ReadyState + ?? output?.State + ?? output?.Deployment?.ReadyState + ?? output?.Deployment?.State; + + return new(readyState); + } + catch (JsonException ex) + { + throw new DistributedApplicationException("Failed to parse JSON output from 'vercel inspect'.", ex); + } + } + + public static bool TryGetCliVersion(string output, [NotNullWhen(true)] out Version? version) + { + // The CLI can print banners/warnings around the version. Extract the first semantic + // x.y.z token instead of requiring a line to be exactly the version string. + var match = Regex.Match(output, @"(?\d+)\.(?\d+)\.(?\d+)(?!\d)", RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(100)); + if (!match.Success) + { + version = null; + return false; + } + + version = new( + int.Parse(match.Groups["major"].Value, CultureInfo.InvariantCulture), + int.Parse(match.Groups["minor"].Value, CultureInfo.InvariantCulture), + int.Parse(match.Groups["patch"].Value, CultureInfo.InvariantCulture)); + return true; + } + + public static bool ProjectListContainsProject(string standardOutput, string projectName) + { + try + { + foreach (var project in DeserializeArrayOrNamedArray(standardOutput, "projects")) + { + if (string.Equals(project.Name, projectName, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + catch (JsonException ex) + { + throw new DistributedApplicationException("Failed to parse JSON output from 'vercel project ls'.", ex); + } + } + + public static bool EnvironmentVariableListContainsName(string standardOutput, string name) + { + try + { + // Vercel env JSON includes gitBranch for branch-scoped values. Aspire-managed + // project env vars are target-wide, so branch-scoped matches must not block add/rm. + // See https://vercel.com/docs/cli/env. + foreach (var environmentVariable in DeserializeArrayOrNamedArray(standardOutput, "envs")) + { + if (string.Equals(environmentVariable.Key, name, StringComparison.Ordinal) + && string.IsNullOrWhiteSpace(environmentVariable.GitBranch)) + { + return true; + } + } + + return false; + } + catch (JsonException ex) + { + throw new DistributedApplicationException("Failed to parse JSON output from 'vercel env ls'.", ex); + } + } + + public static string GetTrimmedOutput(string output) + => string.IsNullOrWhiteSpace(output) ? "" : output.Trim(); + + private static VercelDeploymentResult? TryGetJsonDeploymentResult(string standardOutput) + { + if (!standardOutput.AsSpan().TrimStart().StartsWith("{", StringComparison.Ordinal)) + { + return null; + } + + try + { + var output = JsonSerializer.Deserialize(standardOutput); + if (output is not null && TryGetDeploymentResult(output, out var deploymentResult)) + { + return deploymentResult; + } + } + catch (JsonException) + { + // Older Vercel CLI output is plain text; fall back to line-based URL extraction. + } + + return null; + } + + private static bool TryGetDeploymentResult(VercelDeployOutput output, [NotNullWhen(true)] out VercelDeploymentResult? deploymentResult) + { + // Parse the Vercel deploy JSON shapes observed from different CLI versions: + // { "deployment": { "url": "https://...", "id": "..." } } + // { "url": "https://...", "id": "..." } + // Callers fall back to line-based extraction when deploy output is plain text. + if (TryGetHttpUrl(output.Deployment?.Url, out var nestedDeploymentUrl)) + { + deploymentResult = new(output.Deployment?.Id, nestedDeploymentUrl); + return true; + } + + if (TryGetHttpUrl(output.Url, out var rootDeploymentUrl)) + { + deploymentResult = new(output.Id, rootDeploymentUrl); + return true; + } + + deploymentResult = null; + return false; + } + + private static bool TryGetHttpUrl(string? url, [NotNullWhen(true)] out string? deploymentUrl) + { + deploymentUrl = url; + return IsHttpUrl(deploymentUrl); + } + + private static bool IsHttpUrl([NotNullWhen(true)] string? url) + => Uri.TryCreate(url, UriKind.Absolute, out var uri) && uri.Scheme is "https" or "http"; + + private static T[] DeserializeArrayOrNamedArray(string json, string propertyName) + { + // Vercel CLI JSON has used both bare arrays and named-array wrappers + // ({ "projects": [...] }, { "envs": [...] }) across commands/versions. + // Keep this format tolerance localized to provider-output parsing. + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + if (root.ValueKind == JsonValueKind.Array) + { + return root.Deserialize() ?? []; + } + + if (root.ValueKind == JsonValueKind.Object + && root.TryGetProperty(propertyName, out var array) + && array.ValueKind == JsonValueKind.Array) + { + return array.Deserialize() ?? []; + } + + throw new JsonException($"Expected JSON array or object property '{propertyName}'."); + } + + private sealed class VercelDeployOutput + { + [JsonPropertyName("deployment")] + public VercelDeployDeployment? Deployment { get; init; } + + [JsonPropertyName("url")] + public string? Url { get; init; } + + [JsonPropertyName("id")] + public string? Id { get; init; } + } + + private sealed class VercelDeployDeployment + { + [JsonPropertyName("url")] + public string? Url { get; init; } + + [JsonPropertyName("id")] + public string? Id { get; init; } + } + + private sealed class VercelInspectOutput + { + [JsonPropertyName("readyState")] + public string? ReadyState { get; init; } + + [JsonPropertyName("state")] + public string? State { get; init; } + + [JsonPropertyName("deployment")] + public VercelInspectDeployment? Deployment { get; init; } + } + + private sealed class VercelInspectDeployment + { + [JsonPropertyName("readyState")] + public string? ReadyState { get; init; } + + [JsonPropertyName("state")] + public string? State { get; init; } + } + + private sealed class VercelListedProject + { + [JsonPropertyName("name")] + public string? Name { get; init; } + } + + private sealed class VercelListedEnvironmentVariable + { + [JsonPropertyName("key")] + public string? Key { get; init; } + + [JsonPropertyName("gitBranch")] + public string? GitBranch { get; init; } + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliRunner.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliRunner.cs new file mode 100644 index 000000000..100ed79ad --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelCliRunner.cs @@ -0,0 +1,426 @@ +using Aspire.Hosting; +using System.ComponentModel; +using System.Diagnostics; +using System.Text; + +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +/// +/// Runs typed Vercel and Docker CLI operations for the Vercel integration. Keeping this behind +/// an interface lets tests validate deploy behavior without invoking the real tools or passing +/// generic argument bags through deployment code. +/// +internal interface IVercelCliRunner +{ + Task GetVersionAsync(CancellationToken cancellationToken); + + Task GetCurrentUserAsync(CancellationToken cancellationToken); + + Task ListProjectsAsync(VercelEnvironmentOptionsAnnotation options, string? filter, CancellationToken cancellationToken); + + Task AddProjectAsync(VercelEnvironmentOptionsAnnotation options, string projectName, CancellationToken cancellationToken); + + Task RemoveProjectAsync(VercelEnvironmentOptionsAnnotation options, string projectName, CancellationToken cancellationToken); + + Task LinkProjectAsync(VercelEnvironmentOptionsAnnotation options, string projectLinkDirectory, string projectNameOrId, CancellationToken cancellationToken); + + Task PullProjectSettingsAsync(VercelEnvironmentOptionsAnnotation options, string projectLinkDirectory, string targetEnvironment, CancellationToken cancellationToken); + + Task ListProjectEnvironmentVariablesAsync(VercelEnvironmentOptionsAnnotation options, string projectLinkDirectory, string targetEnvironment, CancellationToken cancellationToken); + + Task AddProjectEnvironmentVariableAsync(VercelEnvironmentOptionsAnnotation options, string projectLinkDirectory, string name, string targetEnvironment, string value, CancellationToken cancellationToken); + + Task RemoveProjectEnvironmentVariableAsync(VercelEnvironmentOptionsAnnotation options, string projectLinkDirectory, string name, string targetEnvironment, CancellationToken cancellationToken); + + Task DeployPrebuiltAsync(VercelEnvironmentOptionsAnnotation options, string deployDirectory, string? projectNameOrId, IReadOnlyList> environmentVariables, CancellationToken cancellationToken); + + Task InspectDeploymentAsync(VercelEnvironmentOptionsAnnotation options, string deploymentUrl, CancellationToken cancellationToken); + + Task ValidateDockerBuildxAsync(CancellationToken cancellationToken); + + Task LoginToVcrAsync(string ownerId, string oidcToken, CancellationToken cancellationToken); + + Task InspectDockerImageDigestAsync(string imageReference, CancellationToken cancellationToken); +} + +/// +/// Process-based implementation of that preserves argument and +/// stdin boundaries for cross-platform quoting and secret handling. +/// +internal sealed class VercelCliRunner : IVercelCliRunner +{ + internal delegate Task ProcessRunner( + string fileName, + IReadOnlyList arguments, + string? workingDirectory, + CancellationToken cancellationToken, + string? standardInput); + + private readonly ProcessRunner _processRunner; + + public VercelCliRunner() + : this(RunProcessAsync) + { + } + + internal VercelCliRunner(ProcessRunner processRunner) + { + _processRunner = processRunner; + } + + public Task GetVersionAsync(CancellationToken cancellationToken) + => RunVercelAsync(["--version"], workingDirectory: null, cancellationToken); + + public Task GetCurrentUserAsync(CancellationToken cancellationToken) + => RunVercelAsync(["whoami"], workingDirectory: null, cancellationToken); + + public Task ListProjectsAsync( + VercelEnvironmentOptionsAnnotation options, + string? filter, + CancellationToken cancellationToken) + { + List arguments = ["project", "ls"]; + AddOptionalScopeArgument(arguments, options); + if (!string.IsNullOrWhiteSpace(filter)) + { + arguments.Add("--filter"); + arguments.Add(filter); + } + + arguments.Add("--format=json"); + + return RunVercelAsync(arguments, workingDirectory: null, cancellationToken); + } + + public Task AddProjectAsync( + VercelEnvironmentOptionsAnnotation options, + string projectName, + CancellationToken cancellationToken) + { + List arguments = ["project", "add", projectName]; + AddOptionalScopeArgument(arguments, options); + + return RunVercelAsync(arguments, workingDirectory: null, cancellationToken); + } + + public Task RemoveProjectAsync( + VercelEnvironmentOptionsAnnotation options, + string projectName, + CancellationToken cancellationToken) + { + List arguments = ["project", "remove", projectName]; + AddOptionalScopeArgument(arguments, options); + + return RunVercelAsync(arguments, workingDirectory: null, cancellationToken, standardInput: "y\n"); + } + + public Task LinkProjectAsync( + VercelEnvironmentOptionsAnnotation options, + string projectLinkDirectory, + string projectNameOrId, + CancellationToken cancellationToken) + { + List arguments = ["link"]; + AddOptionalScopeArgument(arguments, options); + // Link writes .vercel/project.json in the selected working directory. Always link a + // scratch directory; checked-in .vercel/project.json in the source root is read-only + // user intent, not something deploy should mutate. + // See https://vercel.com/docs/cli/link. + arguments.Add("--cwd"); + arguments.Add(projectLinkDirectory); + arguments.Add("--yes"); + arguments.Add("--project"); + arguments.Add(projectNameOrId); + + return RunVercelAsync(arguments, projectLinkDirectory, cancellationToken); + } + + public Task PullProjectSettingsAsync( + VercelEnvironmentOptionsAnnotation options, + string projectLinkDirectory, + string targetEnvironment, + CancellationToken cancellationToken) + { + List arguments = ["pull"]; + AddOptionalScopeArgument(arguments, options); + // Pull materializes .vercel/project.json plus .vercel/.env..local. + // The integration reads the project identity and VCR OIDC token, then deletes the + // pulled env files before writing deploy artifacts. + // See https://vercel.com/docs/cli/pull. + arguments.Add("--cwd"); + arguments.Add(projectLinkDirectory); + arguments.Add("--yes"); + arguments.Add("--environment"); + arguments.Add(targetEnvironment); + + return RunVercelAsync(arguments, projectLinkDirectory, cancellationToken); + } + + public Task ListProjectEnvironmentVariablesAsync( + VercelEnvironmentOptionsAnnotation options, + string projectLinkDirectory, + string targetEnvironment, + CancellationToken cancellationToken) + { + List arguments = ["env", "ls", targetEnvironment]; + AddOptionalScopeArgument(arguments, options); + // Environment variables are project-scoped in the Vercel CLI. The linked scratch + // directory selects the project; JSON output avoids parsing localized table text. + // See https://vercel.com/docs/cli/env. + arguments.Add("--cwd"); + arguments.Add(projectLinkDirectory); + arguments.Add("--format=json"); + + return RunVercelAsync(arguments, projectLinkDirectory, cancellationToken); + } + + public Task AddProjectEnvironmentVariableAsync( + VercelEnvironmentOptionsAnnotation options, + string projectLinkDirectory, + string name, + string targetEnvironment, + string value, + CancellationToken cancellationToken) + { + List arguments = ["env", "add", name, targetEnvironment]; + AddOptionalScopeArgument(arguments, options); + // Vercel env commands must run inside a linked project directory. Use the + // Aspire-owned scratch link instead of the source root so provider metadata + // and pulled env files never mutate the user's checkout. + // --sensitive ensures the value is stored in Vercel's project env store instead + // of being visible in generated Build Output or deploy command arguments. + // See https://vercel.com/docs/cli/env and https://vercel.com/docs/environment-variables/sensitive-environment-variables. + arguments.Add("--cwd"); + arguments.Add(projectLinkDirectory); + arguments.Add("--yes"); + arguments.Add("--force"); + arguments.Add("--sensitive"); + + return RunVercelAsync(arguments, projectLinkDirectory, cancellationToken, standardInput: value); + } + + public Task RemoveProjectEnvironmentVariableAsync( + VercelEnvironmentOptionsAnnotation options, + string projectLinkDirectory, + string name, + string targetEnvironment, + CancellationToken cancellationToken) + { + List arguments = ["env", "rm", name, targetEnvironment]; + AddOptionalScopeArgument(arguments, options); + arguments.Add("--cwd"); + arguments.Add(projectLinkDirectory); + arguments.Add("--yes"); + + return RunVercelAsync(arguments, projectLinkDirectory, cancellationToken); + } + + public Task DeployPrebuiltAsync( + VercelEnvironmentOptionsAnnotation options, + string deployDirectory, + string? projectNameOrId, + IReadOnlyList> environmentVariables, + CancellationToken cancellationToken) + => RunVercelAsync(BuildDeployPrebuiltArguments(options, deployDirectory, projectNameOrId, environmentVariables), deployDirectory, cancellationToken); + + public Task InspectDeploymentAsync( + VercelEnvironmentOptionsAnnotation options, + string deploymentUrl, + CancellationToken cancellationToken) + { + List arguments = ["inspect", deploymentUrl]; + AddOptionalScopeArgument(arguments, options); + // Deploy returning successfully only confirms submission. Ask inspect to wait for the + // provider's final JSON state before deployment state is persisted. + // See https://vercel.com/docs/cli/inspect. + arguments.Add("--wait"); + arguments.Add("--timeout"); + arguments.Add("120s"); + arguments.Add("--format=json"); + + return RunVercelAsync(arguments, workingDirectory: null, cancellationToken); + } + + public Task ValidateDockerBuildxAsync(CancellationToken cancellationToken) + => RunDockerAsync(["buildx", "version"], workingDirectory: null, cancellationToken); + + public Task LoginToVcrAsync(string ownerId, string oidcToken, CancellationToken cancellationToken) + => RunDockerAsync(["login", VercelConstants.VcrRegistry, "--username", ownerId, "--password-stdin"], workingDirectory: null, cancellationToken, standardInput: oidcToken); + + public Task InspectDockerImageDigestAsync(string imageReference, CancellationToken cancellationToken) + // Inspect the pushed tag through Docker buildx so deploy can pin the Vercel Build + // Output API container handler to the linux/amd64 manifest digest Vercel accepts. + // See https://vercel.com/docs/functions/container-images. + => RunDockerAsync(BuildDockerInspectDigestArguments(imageReference), workingDirectory: null, cancellationToken); + + internal static string[] BuildDeployPrebuiltArgumentsForPlan( + VercelEnvironmentOptionsAnnotation options, + string deployDirectory, + string? projectNameOrId, + IReadOnlyList> environmentVariables) + => BuildDeployPrebuiltArguments(options, deployDirectory, projectNameOrId, environmentVariables); + + internal static string BuildDisplayDeployCommand( + VercelEnvironmentOptionsAnnotation options, + string resourceName, + string serviceName, + IReadOnlyList> environmentVariables) + { + // The plan should explain the command shape without leaking concrete source roots, + // project names, or environment values that may be machine- or account-specific. + var displayEnvironmentVariables = environmentVariables + .Select(static environmentVariable => new KeyValuePair(environmentVariable.Key, "")) + .ToArray(); + + string displayImage = $"{VercelConstants.VcrRegistry}///{serviceName}:"; + string displayDeployDirectory = $"<{resourceName}-build-output>"; + string displayProject = $"<{resourceName}-vercel-project>"; + return $"vercel pull --cwd <{resourceName}-vercel-project-link> --yes --environment {VercelProjectEnvironment.GetName(options)} && aspire build/push {resourceName} -> {displayImage} && docker {string.Join(" ", BuildDockerInspectDigestArguments(displayImage))} && vercel {string.Join(" ", BuildDeployPrebuiltArguments(options, displayDeployDirectory, displayProject, displayEnvironmentVariables))}"; + } + + private Task RunVercelAsync( + IReadOnlyList arguments, + string? workingDirectory, + CancellationToken cancellationToken, + string? standardInput = null) + => _processRunner(VercelConstants.CliFileName, arguments, workingDirectory, cancellationToken, standardInput); + + private Task RunDockerAsync( + IReadOnlyList arguments, + string? workingDirectory, + CancellationToken cancellationToken, + string? standardInput = null) + => _processRunner(VercelConstants.DockerCliFileName, arguments, workingDirectory, cancellationToken, standardInput); + + private static string[] BuildDockerInspectDigestArguments(string imageReference) + => ["buildx", "imagetools", "inspect", "--format", "{{json .Manifest}}", imageReference]; + + private static string[] BuildDeployPrebuiltArguments( + VercelEnvironmentOptionsAnnotation options, + string deployDirectory, + string? projectNameOrId, + IReadOnlyList> environmentVariables) + { + List arguments = ["deploy"]; + AddOptionalScopeArgument(arguments, options); + arguments.Add("--cwd"); + arguments.Add(deployDirectory); + AddOptionalProjectArgument(arguments, projectNameOrId); + // --prebuilt tells the CLI to upload the generated .vercel/output tree rather than + // running a source build. That tree is produced by VercelBuildOutputWriter. + // See https://vercel.com/docs/cli/deploy and https://vercel.com/docs/build-output-api. + arguments.Add("--prebuilt"); + arguments.Add("--yes"); + + if (options.Production) + { + arguments.Add("--prod"); + } + + if (!string.IsNullOrWhiteSpace(options.Target)) + { + arguments.Add("--target"); + arguments.Add(options.Target); + } + + foreach (var environmentVariable in environmentVariables.OrderBy(static variable => variable.Key, StringComparer.Ordinal)) + { + arguments.Add("--env"); + arguments.Add($"{environmentVariable.Key}={environmentVariable.Value}"); + } + + return [.. arguments]; + } + + private static void AddOptionalProjectArgument(List arguments, string? projectNameOrId) + { + if (!string.IsNullOrWhiteSpace(projectNameOrId)) + { + arguments.Add("--project"); + arguments.Add(projectNameOrId); + } + } + + private static void AddOptionalScopeArgument(List arguments, VercelEnvironmentOptionsAnnotation options) + { + if (!string.IsNullOrWhiteSpace(options.Scope)) + { + arguments.Add("--scope"); + arguments.Add(options.Scope); + } + } + + private static async Task RunProcessAsync( + string fileName, + IReadOnlyList arguments, + string? workingDirectory, + CancellationToken cancellationToken, + string? standardInput) + { + // Keep all target CLI calls behind this runner so unit tests can assert exact + // executable/argument/stdin boundaries. Secrets use stdin; arguments are never + // shell-concatenated, which avoids platform quoting differences. + ProcessStartInfo startInfo = new() + { + FileName = fileName, + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = standardInput is not null, + UseShellExecute = false, + CreateNoWindow = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8 + }; + + if (!string.IsNullOrWhiteSpace(workingDirectory)) + { + startInfo.WorkingDirectory = workingDirectory; + } + + // Vercel CLI can emit ANSI color codes that make parser failure messages harder to + // read and snapshot. Disable them at the process boundary for deterministic output. + startInfo.Environment["NO_COLOR"] = "1"; + + foreach (var argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } + + try + { + using var process = Process.Start(startInfo) + ?? throw new DistributedApplicationException($"The Vercel CLI process '{fileName}' could not be started."); + + var standardOutputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); + var standardErrorTask = process.StandardError.ReadToEndAsync(cancellationToken); + + if (standardInput is not null) + { + await process.StandardInput.WriteAsync(standardInput.AsMemory(), cancellationToken).ConfigureAwait(false); + process.StandardInput.Close(); + } + + await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + + var standardOutput = await standardOutputTask.ConfigureAwait(false); + var standardError = await standardErrorTask.ConfigureAwait(false); + + return new VercelCliResult(process.ExitCode, standardOutput, standardError); + } + catch (Win32Exception ex) + { + throw new DistributedApplicationException( + $"The Vercel CLI '{fileName}' could not be started. Install Vercel CLI from https://vercel.com/docs/cli and ensure it is available on PATH.", + ex); + } + } +} + +/// +/// Captures a completed CLI invocation so callers can decide whether to parse stdout, +/// surface stderr, or combine both for diagnostics. +/// +internal sealed record VercelCliResult(int ExitCode, string StandardOutput, string StandardError) +{ + public bool Succeeded => ExitCode == 0; +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelConstants.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelConstants.cs new file mode 100644 index 000000000..4eedcac5f --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelConstants.cs @@ -0,0 +1,23 @@ +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +/// +/// Centralizes Vercel protocol names and generated-file constants so Build Output, VCR, state, +/// and CLI components stay aligned as provider contracts evolve. +/// +internal static class VercelConstants +{ + internal const string DeploymentPlanFileName = "vercel-deployments.json"; + internal const string StateSectionNamePrefix = "communitytoolkit.vercel."; + internal const int DeploymentStateSchemaVersion = 1; + internal const int ProjectNameMaxLength = 100; + internal const string CliFileName = "vercel"; + internal const string DockerCliFileName = "docker"; + internal const string VcrRegistry = "vcr.vercel.com"; + internal const string JsonFileName = "vercel.json"; + internal const string ProjectFileName = "project.json"; + internal const string DirectoryName = ".vercel"; + internal const string OutputDirectoryName = "output"; + internal const string OidcTokenEnvironmentVariable = "VERCEL_OIDC_TOKEN"; + internal const string ContainerServiceName = "app"; + internal const int BuildOutputApiVersion = 3; +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelContainerRegistryClient.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelContainerRegistryClient.cs new file mode 100644 index 000000000..499b7243b --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelContainerRegistryClient.cs @@ -0,0 +1,70 @@ +using Aspire.Hosting; +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; + +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +/// +/// Ensures project-scoped Vercel Container Registry repositories exist before Aspire pushes +/// workload images to VCR. +/// +internal interface IVercelContainerRegistryClient +{ + Task EnsureRepositoryAsync(string token, VercelOidcClaims claims, string repository, CancellationToken cancellationToken); +} + +/// +/// Calls the Vercel Container Registry API to converge the repository that Aspire's built-in +/// image push step will target. +/// +internal sealed class VercelContainerRegistryClient : IVercelContainerRegistryClient +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + public async Task EnsureRepositoryAsync(string token, VercelOidcClaims claims, string repository, CancellationToken cancellationToken) + { + // VCR repositories are project-scoped under owner/project. The integration currently + // creates the leaf repository ("app") only; any future nested repository name should + // be provisioned by the provider or a separate explicit API before this method runs. + // See https://vercel.com/docs/container-registry. + if (repository.Contains('/', StringComparison.Ordinal)) + { + return; + } + + if (string.IsNullOrWhiteSpace(claims.OwnerId) + || string.IsNullOrWhiteSpace(claims.ProjectId)) + { + throw new DistributedApplicationException("The Vercel OIDC token did not include the owner_id and project_id claims required to create the VCR repository."); + } + + string apiUrl = (Environment.GetEnvironmentVariable("VERCEL_API_URL") ?? "https://api.vercel.com").TrimEnd('/'); + string requestUri = $"{apiUrl}/v1/vcr/repository?teamId={Uri.EscapeDataString(claims.OwnerId)}"; + string requestBody = JsonSerializer.Serialize(new + { + name = repository, + projectId = claims.ProjectId + }, JsonOptions); + + using HttpClient httpClient = new(); + using HttpRequestMessage request = new(HttpMethod.Post, requestUri) + { + Content = new StringContent(requestBody, Encoding.UTF8, "application/json") + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + using HttpResponseMessage response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + if (response.IsSuccessStatusCode + || response.StatusCode == HttpStatusCode.Conflict) + { + // Conflict means the repository already exists for this project, which is the + // desired converged state for a retry or update deployment. + return; + } + + string responseBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + throw new DistributedApplicationException($"Failed to create Vercel Container Registry repository '{repository}' (HTTP {(int)response.StatusCode}). {responseBody}"); + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentModel.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentModel.cs new file mode 100644 index 000000000..f28f9ec7c --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentModel.cs @@ -0,0 +1,273 @@ +#pragma warning disable ASPIREPIPELINES001 +#pragma warning disable ASPIREPIPELINES004 +#pragma warning disable ASPIRECOMPUTE003 +#pragma warning disable ASPIREPROBES001 +#pragma warning disable CTASPIREVERCEL001 + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.ApplicationModel.Docker; +using Aspire.Hosting.Pipelines; +using Aspire.Hosting.Publishing; +using Aspire.Hosting; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Sockets; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +/// +/// Projects Aspire compute resources into the subset of the app model that Vercel Dockerfile +/// hosting can represent, and rejects unsupported constructs before provider mutation starts. +/// +internal static class VercelDeploymentModel +{ + private static readonly string[] VercelJsonBuildOutputUnsupportedKeys = + [ + "build", + "builds", + "buildCommand", + "devCommand", + "env", + "experimentalServices", + "experimentalServicesV2", + "framework", + "functions", + "headers", + "ignoreCommand", + "installCommand", + "outputDirectory", + "redirects", + "rewrites", + "routes", + "services" + ]; + + public static IReadOnlyDictionary GetEntriesByResourceName(IReadOnlyList entries) + => entries.ToDictionary(static entry => entry.Resource.Name, StringComparer.Ordinal); + + public static IEnumerable GetEntries(DistributedApplicationModel model, VercelEnvironmentResource environment) + { + var computeEnvironments = model.Resources.OfType().Take(2).ToArray(); + // Match Aspire's single-environment convention only when Vercel is the sole compute + // environment. With mixed targets, implicit selection would accidentally deploy + // untargeted resources to every environment. + bool allowImplicitTargeting = computeEnvironments.Length == 1 && ReferenceEquals(computeEnvironments[0], environment); + + foreach (var resource in model.Resources.OfType()) + { + if (!IsTargetedToEnvironment(resource, environment, allowImplicitTargeting)) + { + continue; + } + + if (resource.TryGetLastAnnotation(out var dockerfile)) + { + yield return new(resource, dockerfile.ContextPath, dockerfile.DockerfilePath, dockerfile); + continue; + } + + if (resource is ProjectResource project) + { + string projectPath = project.GetProjectMetadata().ProjectPath; + string sourceRoot = Path.GetDirectoryName(projectPath) + ?? throw new DistributedApplicationException($"Project resource '{resource.Name}' has project path '{projectPath}' without a containing directory."); + yield return new(resource, sourceRoot); + continue; + } + + throw new DistributedApplicationException($"Resource '{resource.Name}' targets Vercel but is not an Aspire image build resource. Use a .NET project, a workload integration that publishes Dockerfile metadata, call PublishAsDockerFile, or configure the resource with WithDockerfile, WithDockerfileFactory, or WithDockerfileBuilder."); + } + } + + public static void ValidateEntries(IReadOnlyList entries) + { + // Keep unsupported Vercel-preview cases in validation so publish/prereq/deploy fail + // before mutating provider projects. Each failure names the Aspire concept that + // cannot be projected rather than letting a later Vercel CLI call fail opaquely. + if (entries.Count == 0) + { + throw new DistributedApplicationException("No image-build compute resources target Vercel. Add a .NET project, a workload with Aspire Dockerfile publish metadata, or use WithComputeEnvironment to target Vercel when multiple compute environments are present."); + } + + foreach (var entry in entries) + { + if (!Directory.Exists(entry.SourceRoot)) + { + throw new DistributedApplicationException($"The Vercel source root '{entry.SourceRoot}' for resource '{entry.Resource.Name}' does not exist."); + } + + if (entry.Dockerfile is { DockerfileFactory: null } && !File.Exists(entry.DockerfilePath!)) + { + throw new DistributedApplicationException($"The Vercel Dockerfile '{entry.DockerfilePath}' for resource '{entry.Resource.Name}' does not exist. Configure the resource with an existing Dockerfile or Aspire-generated Dockerfile metadata."); + } + + ValidateUnsupportedResourceModel(entry); + } + + } + + public static async Task PrepareEntryAsync(PipelineStepContext context, VercelDeploymentEntry entry) + { + await ValidateVercelJsonAsync(entry.Resource, entry.SourceRoot, context.CancellationToken).ConfigureAwait(false); + + var outputService = context.Services.GetRequiredService(); + string tempDirectory = outputService.GetTempDirectory(entry.Resource); + string deployDirectory = Path.Combine(tempDirectory, "vercel-build-output"); + Directory.CreateDirectory(tempDirectory); + if (Directory.Exists(deployDirectory)) + { + Directory.Delete(deployDirectory, recursive: true); + } + + // This is an output-only Build Output API root, not a staged copy of the source. + // Aspire's built-in build/push steps read the real source/project directly; Vercel + // deploy receives only generated metadata that points at the digest already pushed to VCR. + Directory.CreateDirectory(deployDirectory); + + return entry with + { + TempDirectory = tempDirectory, + DeployDirectory = deployDirectory + }; + } + + public static async Task ValidateVercelJsonAsync( + IResource resource, + string sourceRoot, + CancellationToken cancellationToken) + { + // User vercel.json can coexist only when it does not claim the same Build Output API + // routes/functions/services surface this integration generates under .vercel/output. + // See https://vercel.com/docs/project-configuration and https://vercel.com/docs/build-output-api. + string vercelJsonPath = Path.Combine(sourceRoot, VercelConstants.JsonFileName); + if (!File.Exists(vercelJsonPath)) + { + return; + } + + JsonObject root; + try + { + root = JsonNode.Parse(await File.ReadAllTextAsync(vercelJsonPath, cancellationToken).ConfigureAwait(false)) as JsonObject + ?? throw new DistributedApplicationException($"Resource '{resource.Name}' source root contains '{VercelConstants.JsonFileName}', but it is not a JSON object."); + } + catch (JsonException ex) + { + throw new DistributedApplicationException($"Resource '{resource.Name}' source root contains invalid '{VercelConstants.JsonFileName}'.", ex); + } + + var unsupportedKey = VercelJsonBuildOutputUnsupportedKeys.FirstOrDefault(root.ContainsKey); + if (unsupportedKey is not null) + { + throw new DistributedApplicationException( + $"Resource '{resource.Name}' source root contains '{VercelConstants.JsonFileName}' with top-level '{unsupportedKey}', but the Aspire Vercel integration owns the generated Build Output API container function and catch-all routing configuration. Move that setting into the Dockerfile, AppHost environment variables, or Vercel project settings before deploying with the Aspire Vercel integration."); + } + } + + public static string GetDisplayDockerfilePath(VercelDeploymentEntry entry) + => entry.Dockerfile is null + ? "" + : entry.Dockerfile.DockerfileFactory is null + ? Path.GetRelativePath(entry.SourceRoot, entry.DockerfilePath!) + : ""; + + public static bool IsHttpEndpoint(EndpointAnnotation endpoint) + // URI scheme alone is not enough: Aspire can model a TCP transport with an HTTP + // URI scheme. Vercel's container ingress here is HTTP-family traffic over TCP. + => endpoint.Protocol == ProtocolType.Tcp + && (string.Equals(endpoint.UriScheme, "http", StringComparison.OrdinalIgnoreCase) + || string.Equals(endpoint.UriScheme, "https", StringComparison.OrdinalIgnoreCase)) + && (string.Equals(endpoint.Transport, "http", StringComparison.OrdinalIgnoreCase) + || string.Equals(endpoint.Transport, "http2", StringComparison.OrdinalIgnoreCase)); + + private static bool IsTargetedToEnvironment(IResource resource, VercelEnvironmentResource environment, bool allowImplicitTargeting) + { + var computeEnvironment = resource.GetComputeEnvironment(); + // Match Aspire's single-environment convention: when Vercel is the only compute + // environment, image-build workloads implicitly target it. + return ReferenceEquals(computeEnvironment, environment) + || (computeEnvironment is null && allowImplicitTargeting); + } + + private static void ValidateUnsupportedResourceModel(VercelDeploymentEntry entry) + { + IResource resource = entry.Resource; + + // This method is intentionally conservative. Each rejected annotation has run-mode + // or another deployment-target semantics that Vercel's Dockerfile deploy cannot + // project without changing what the user modeled in the AppHost. If Vercel gains a + // native equivalent later, add the mapping and tests here instead of silently ignoring it. + if (resource.Annotations.OfType().Any()) + { + throw new DistributedApplicationException( + $"Resource '{resource.Name}' configures Aspire generic container registry/image push metadata, but Vercel deployments use Vercel Container Registry through a provider-owned prebuilt artifact. Remove WithContainerRegistry before deploying with the Aspire Vercel integration."); + } + + if (resource.Annotations.OfType().Any()) + { + throw new DistributedApplicationException( + $"Resource '{resource.Name}' configures Aspire container volumes or bind mounts, but Vercel Dockerfile deployments do not support Aspire-managed container mounts. Move persistent state to a Vercel-supported external service or remove the mount."); + } + + if (resource.Annotations.OfType().Any() + || resource.Annotations.OfType().Any() + || resource.Annotations.OfType().Any()) + { + throw new DistributedApplicationException( + $"Resource '{resource.Name}' configures Aspire container file mounts, but Vercel Dockerfile deployments build the checked-in source tree directly. Include required files in the source tree or deploy this resource outside the Aspire Vercel integration."); + } + + if (resource.Annotations.OfType().Any() + || resource.Annotations.OfType().Any()) + { + throw new DistributedApplicationException( + $"Resource '{resource.Name}' configures Aspire container probes, but the Vercel preview integration does not map them to Vercel-native checks. Remove the Aspire probes or configure health behavior in Vercel."); + } + + ValidateEndpointModel(entry); + ValidateProjectName(entry); + } + + private static void ValidateEndpointModel(VercelDeploymentEntry entry) + { + var endpoints = entry.Resource.Annotations.OfType().ToArray(); + + if (endpoints.Length == 0) + { + return; + } + + var unsupportedEndpoint = endpoints.FirstOrDefault(static endpoint => !IsHttpEndpoint(endpoint)); + if (unsupportedEndpoint is not null) + { + throw new DistributedApplicationException( + $"Resource '{entry.Resource.Name}' configures endpoint '{unsupportedEndpoint.Name}' with scheme '{unsupportedEndpoint.UriScheme}' and transport '{unsupportedEndpoint.Transport}', but Vercel Dockerfile deployments support only HTTP or HTTPS endpoints with HTTP transports."); + } + + var targetPorts = endpoints + .Select(static endpoint => endpoint.TargetPort) + .Where(static targetPort => targetPort.HasValue) + .Select(static targetPort => targetPort!.Value) + .Distinct() + .ToArray(); + + // Vercel provides one runtime listener through $PORT. Additional target ports would + // look like ACA extra ports, but Vercel has no equivalent modeled here. + if (targetPorts.Length > 1) + { + throw new DistributedApplicationException( + $"Resource '{entry.Resource.Name}' configures multiple Aspire endpoint target ports, but Vercel Dockerfile deployments support only one HTTP listening port exposed through the $PORT environment variable."); + } + } + + private static void ValidateProjectName(VercelDeploymentEntry entry) + { + if (VercelProjectNameResolver.HasProjectLinkFile(entry.SourceRoot)) + { + return; + } + + _ = VercelProjectNameResolver.GetProjectName(entry); + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentPlanWriter.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentPlanWriter.cs new file mode 100644 index 000000000..28c06ca0c --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentPlanWriter.cs @@ -0,0 +1,182 @@ +#pragma warning disable ASPIREPIPELINES001 +#pragma warning disable CTASPIREVERCEL001 + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Publishing; +using Microsoft.Extensions.Logging; +using System.Text.Json; +using Aspire.Hosting; + +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +/// +/// Produces the publish-time Vercel deployment plan: a deterministic, reviewable artifact that +/// shows commands and environment variable names without resolving secrets or mutating Vercel. +/// +internal static class VercelDeploymentPlanWriter +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + WriteIndented = true + }; + + public static async Task WriteAsync( + DistributedApplicationModel model, + VercelEnvironmentResource environment, + string outputDirectory, + CancellationToken cancellationToken) + => await WriteAsync( + executionContext: null, + logger: null, + model, + environment, + outputDirectory, + cancellationToken).ConfigureAwait(false); + + public static async Task WriteAsync( + DistributedApplicationExecutionContext? executionContext, + ILogger? logger, + DistributedApplicationModel model, + VercelEnvironmentResource environment, + string outputDirectory, + CancellationToken cancellationToken) + { + var entries = VercelDeploymentModel.GetEntries(model, environment).ToList(); + VercelDeploymentModel.ValidateEntries(entries); + var projectMap = executionContext is null || logger is null + ? VercelDeploymentProjectGrouper.CreateMap(entries, entries.ToDictionary(static entry => entry.Resource.Name, static _ => Array.Empty(), StringComparer.Ordinal)) + : await VercelDeploymentProjectGrouper.CreateMapAsync(executionContext, logger, entries, cancellationToken).ConfigureAwait(false); + + // Publish is a reviewable handoff, not a dry-run deploy. Keep it deterministic: + // show commands, Dockerfile paths, and env var names without resolving secrets or + // depending on mutable Vercel state. + Directory.CreateDirectory(outputDirectory); + var options = environment.GetVercelOptions(); + + var plan = new VercelDeploymentPlan( + environment.Name, + await CreateEntriesAsync( + executionContext, + logger, + options, + entries, + projectMap, + cancellationToken).ConfigureAwait(false)); + + string planPath = Path.Combine(outputDirectory, VercelConstants.DeploymentPlanFileName); + await using FileStream stream = File.Create(planPath); + await JsonSerializer.SerializeAsync(stream, plan, JsonOptions, cancellationToken).ConfigureAwait(false); + + return planPath; + } + + public static async Task BuildDeployArgumentsAsync( + DistributedApplicationExecutionContext executionContext, + ILogger logger, + VercelEnvironmentOptionsAnnotation options, + VercelDeploymentEntry entry, + CancellationToken cancellationToken) + => await BuildDeployArgumentsAsync( + executionContext, + logger, + options, + entry, + [entry], + cancellationToken).ConfigureAwait(false); + + public static async Task BuildDeployArgumentsAsync( + DistributedApplicationExecutionContext executionContext, + ILogger logger, + VercelEnvironmentOptionsAnnotation options, + VercelDeploymentEntry entry, + IReadOnlyList entries, + CancellationToken cancellationToken) + { + // Publish plans must be useful without Vercel credentials or secret resolution. + // Secret-bearing values are reduced to names/placeholders here; deploy resolves them + // only when they are sent to Vercel's project env store over stdin. + var projectMap = await VercelDeploymentProjectGrouper.CreateMapAsync(executionContext, logger, entries, cancellationToken).ConfigureAwait(false); + var entriesByResourceName = VercelDeploymentModel.GetEntriesByResourceName(entries); + var environmentConfiguration = await GetEnvironmentConfigurationAsync( + executionContext, + logger, + options, + entry, + entriesByResourceName, + projectMap, + resolveProjectEnvironmentVariableValues: false, + cancellationToken).ConfigureAwait(false); + + return VercelCliRunner.BuildDeployPrebuiltArgumentsForPlan(options, entry.EffectiveDeployDirectory, VercelProjectNameResolver.GetProjectOption(entry), environmentConfiguration.DeploymentEnvironmentVariables); + } + + public static async Task GetEnvironmentConfigurationAsync( + DistributedApplicationExecutionContext executionContext, + ILogger logger, + VercelEnvironmentOptionsAnnotation options, + VercelDeploymentEntry entry, + IReadOnlyDictionary entriesByResourceName, + VercelDeploymentProjectMap? projectMap, + bool resolveProjectEnvironmentVariableValues, + CancellationToken cancellationToken) + { + var executionConfiguration = await ExecutionConfigurationBuilder + .Create(entry.Resource) + .WithEnvironmentVariablesConfig() + .WithArgumentsConfig() + .BuildAsync(executionContext, logger, cancellationToken) + .ConfigureAwait(false); + + if (executionConfiguration.Exception is not null) + { + throw new DistributedApplicationException($"Failed to process deployment configuration for resource '{entry.Resource.Name}'.", executionConfiguration.Exception); + } + + VercelEnvironmentMapper.ValidateUnsupportedRuntimeConfiguration(entry.Resource, executionConfiguration); + + var environmentVariables = await VercelEnvironmentMapper.GetConfigurationAsync( + entry.Resource, + options, + executionConfiguration, + entriesByResourceName, + projectMap, + resolveProjectEnvironmentVariableValues, + cancellationToken).ConfigureAwait(false); + + return environmentVariables; + } + + private static async Task CreateEntriesAsync( + DistributedApplicationExecutionContext? executionContext, + ILogger? logger, + VercelEnvironmentOptionsAnnotation options, + IReadOnlyList entries, + VercelDeploymentProjectMap projectMap, + CancellationToken cancellationToken) + { + List planEntries = []; + var entriesByResourceName = VercelDeploymentModel.GetEntriesByResourceName(entries); + + foreach (var entry in entries) + { + // When execution context is available, include the same target-native env names + // deploy will use. Values stay redacted because publish output is committed or + // handed off more often than deploy logs. + var environmentConfiguration = executionContext is null || logger is null + ? VercelEnvironmentConfiguration.Empty + : await GetEnvironmentConfigurationAsync(executionContext, logger, options, entry, entriesByResourceName, projectMap, resolveProjectEnvironmentVariableValues: false, cancellationToken).ConfigureAwait(false); + + string serviceName = projectMap.TryGetService(entry.Resource.Name, out var service) + ? service.ServiceName + : VercelConstants.ContainerServiceName; + + planEntries.Add(new( + entry.Resource.Name, + VercelDeploymentModel.GetDisplayDockerfilePath(entry), + VercelCliRunner.BuildDisplayDeployCommand(options, entry.Resource.Name, serviceName, []), + [.. environmentConfiguration.AllEnvironmentVariableNames.Order(StringComparer.Ordinal)])); + } + + return [.. planEntries]; + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentProjectGrouper.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentProjectGrouper.cs new file mode 100644 index 000000000..16a6e2502 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentProjectGrouper.cs @@ -0,0 +1,202 @@ +#pragma warning disable ASPIREPIPELINES001 +#pragma warning disable CTASPIREVERCEL001 + +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Publishing; +using Microsoft.Extensions.Logging; + +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +/// +/// Converts the flat set of Vercel-targeted Aspire resources into Vercel project groups. +/// +internal static class VercelDeploymentProjectGrouper +{ + public static async Task CreateMapAsync( + DistributedApplicationExecutionContext executionContext, + ILogger logger, + IReadOnlyList entries, + CancellationToken cancellationToken) + { + Dictionary referencesByResourceName = new(StringComparer.Ordinal); + foreach (var entry in entries) + { + var executionConfiguration = await ExecutionConfigurationBuilder + .Create(entry.Resource) + .WithEnvironmentVariablesConfig() + .WithArgumentsConfig() + .BuildAsync(executionContext, logger, cancellationToken) + .ConfigureAwait(false); + + if (executionConfiguration.Exception is not null) + { + throw new DistributedApplicationException($"Failed to process deployment configuration for resource '{entry.Resource.Name}'.", executionConfiguration.Exception); + } + + referencesByResourceName[entry.Resource.Name] = [.. VercelEnvironmentMapper.GetReferencedResourceNames(entry.Resource, executionConfiguration) + .Where(name => entries.Any(candidate => string.Equals(candidate.Resource.Name, name, StringComparison.Ordinal))) + .Order(StringComparer.Ordinal)]; + } + + return CreateMap(entries, referencesByResourceName); + } + + public static VercelDeploymentProjectMap CreateMap( + IReadOnlyList entries, + IReadOnlyDictionary referencesByResourceName) + { + if (entries.Count == 1) + { + var single = CreateService(entries[0], isPublicRoot: true); + return new([new(single, [single])]); + } + + var entriesByName = entries.ToDictionary(static entry => entry.Resource.Name, StringComparer.Ordinal); + var publicRoots = entries + .Where(HasPublicHttpEndpoint) + .Select(entry => CreateService(entry, isPublicRoot: true)) + .ToArray(); + + if (publicRoots.Length == 0) + { + throw new DistributedApplicationException("Multiple resources target Vercel, but none exposes an external HTTP endpoint that can be used as a Vercel project root. Add an external HTTP endpoint to the public workload or deploy a single resource."); + } + + ValidateUniqueProjectNames(publicRoots); + ValidateUniqueServiceNames(entries); + + var publicRootNames = publicRoots + .Select(static root => root.Entry.Resource.Name) + .ToHashSet(StringComparer.Ordinal); + + Dictionary> servicesByRootName = publicRoots + .ToDictionary(static root => root.Entry.Resource.Name, static root => new List { root }, StringComparer.Ordinal); + + foreach (var entry in entries) + { + if (publicRootNames.Contains(entry.Resource.Name)) + { + continue; + } + + if (entry.Resource.TryGetLastAnnotation(out _)) + { + throw new DistributedApplicationException($"Resource '{entry.Resource.Name}' is an internal Vercel service because it is owned by a public project root. WithVercelProjectName can only be applied to public Vercel project roots."); + } + + var owners = publicRoots + .Where(root => ReferencesResourceTransitively(root.Entry.Resource.Name, entry.Resource.Name, referencesByResourceName, publicRootNames)) + .ToArray(); + + if (owners.Length == 0) + { + throw new DistributedApplicationException($"Resource '{entry.Resource.Name}' targets Vercel but is not public and is not referenced by any public Vercel project root. Add an external HTTP endpoint to make it a public project, reference it from one public workload, or deploy it separately."); + } + + if (owners.Length > 1) + { + string ownerNames = string.Join(", ", owners.Select(static owner => $"'{owner.Entry.Resource.Name}'").Order(StringComparer.Ordinal)); + throw new DistributedApplicationException($"Resource '{entry.Resource.Name}' is referenced by multiple public Vercel project roots ({ownerNames}). Shared internal Vercel services are ambiguous; deploy the service separately or reference it from exactly one public workload."); + } + + servicesByRootName[owners[0].Entry.Resource.Name].Add(CreateService(entry, isPublicRoot: false)); + } + + List groups = []; + foreach (var root in publicRoots) + { + groups.Add(new(root, [.. servicesByRootName[root.Entry.Resource.Name].OrderBy(static service => service.IsPublicRoot ? 0 : 1).ThenBy(static service => service.ServiceName, StringComparer.Ordinal)])); + } + + return new(groups); + } + + private static VercelDeploymentService CreateService(VercelDeploymentEntry entry, bool isPublicRoot) + => new(entry, VercelProjectNameResolver.GetServiceName(entry.Resource), isPublicRoot); + + private static bool HasPublicHttpEndpoint(VercelDeploymentEntry entry) + => entry.Resource.Annotations + .OfType() + .Any(static endpoint => endpoint.IsExternal && VercelDeploymentModel.IsHttpEndpoint(endpoint)); + + private static bool ReferencesResourceTransitively( + string sourceResourceName, + string targetResourceName, + IReadOnlyDictionary referencesByResourceName, + HashSet publicRootNames) + { + Queue queue = []; + HashSet visited = new(StringComparer.Ordinal); + queue.Enqueue(sourceResourceName); + + while (queue.Count > 0) + { + string current = queue.Dequeue(); + if (!visited.Add(current) + || !referencesByResourceName.TryGetValue(current, out string[]? references)) + { + continue; + } + + foreach (string reference in references) + { + if (string.Equals(reference, targetResourceName, StringComparison.Ordinal)) + { + return true; + } + + if (!publicRootNames.Contains(reference)) + { + queue.Enqueue(reference); + } + } + } + + return false; + } + + private static void ValidateUniqueProjectNames(IReadOnlyList publicRoots) + { + var projectNames = publicRoots + .Select(root => new + { + Root = root, + ProjectLink = VercelProjectNameResolver.GetProjectLink(root.Entry) + }) + .GroupBy(item => item.ProjectLink.ProjectName, StringComparer.Ordinal) + .Where(static group => group.Count() > 1) + .ToArray(); + + if (projectNames.Length == 0) + { + return; + } + + var collision = projectNames[0]; + string resources = string.Join(", ", collision.Select(static item => $"'{item.Root.Entry.Resource.Name}'").Order(StringComparer.Ordinal)); + throw new DistributedApplicationException($"Multiple public Vercel project roots resolve to project name '{collision.Key}' ({resources}). Use WithVercelProjectName, distinct source directory names, or link each root to a distinct Vercel project with .vercel/project.json."); + } + + private static void ValidateUniqueServiceNames(IReadOnlyList entries) + { + var serviceNames = entries + .Select(static entry => new + { + Entry = entry, + ServiceName = VercelProjectNameResolver.GetServiceName(entry.Resource) + }) + .GroupBy(static item => item.ServiceName, StringComparer.Ordinal) + .Where(static group => group.Count() > 1) + .ToArray(); + + if (serviceNames.Length == 0) + { + return; + } + + var collision = serviceNames[0]; + string resources = string.Join(", ", collision.Select(static item => $"'{item.Entry.Resource.Name}'").Order(StringComparer.Ordinal)); + throw new DistributedApplicationException($"Multiple Vercel services resolve to service name '{collision.Key}' ({resources}). Rename one of the Aspire resources so each Vercel service has a unique name."); + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStateStore.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStateStore.cs new file mode 100644 index 000000000..ad84a6d66 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStateStore.cs @@ -0,0 +1,191 @@ +#pragma warning disable ASPIREPIPELINES001 +#pragma warning disable ASPIREPIPELINES002 +#pragma warning disable CTASPIREVERCEL001 + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Pipelines; +using Microsoft.Extensions.DependencyInjection; +using Aspire.Hosting; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +/// +/// Owns the Aspire deployment-state section for Vercel so deploy and destroy know which Vercel +/// projects and environment variables this integration created, even if the AppHost changes later. +/// +internal static class VercelDeploymentStateStore +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + WriteIndented = true + }; + + public static async Task ValidateExistingAsync( + PipelineStepContext context, + VercelEnvironmentResource environment, + VercelEnvironmentOptionsAnnotation options) + { + var stateManager = context.Services.GetRequiredService(); + var stateSection = await stateManager.AcquireSectionAsync(GetSectionName(environment), context.CancellationToken).ConfigureAwait(false); + var existingState = Read(stateSection); + if (existingState is not null) + { + Validate(environment, options, existingState); + } + } + + public static async Task GetPreviousAsync( + PipelineStepContext context, + VercelEnvironmentResource environment, + VercelEnvironmentOptionsAnnotation options, + string resourceName, + string projectName) + { + var stateManager = context.Services.GetRequiredService(); + var stateSection = await stateManager.AcquireSectionAsync(GetSectionName(environment), context.CancellationToken).ConfigureAwait(false); + var existingState = Read(stateSection); + if (existingState is null) + { + return null; + } + + Validate(environment, options, existingState); + var entry = existingState.Deployments.FirstOrDefault(deployment => + string.Equals(deployment.ResourceName, resourceName, StringComparison.Ordinal) + && string.Equals(deployment.ProjectName, projectName, StringComparison.Ordinal)); + + return entry is null + ? null + : new(entry, VercelProjectEnvironment.GetName(existingState)); + } + + public static async Task SaveEntryAsync( + PipelineStepContext context, + VercelEnvironmentResource environment, + VercelEnvironmentOptionsAnnotation options, + VercelDeploymentStateEntry deployment) + { + var stateManager = context.Services.GetRequiredService(); + var stateSection = await stateManager.AcquireSectionAsync(GetSectionName(environment), context.CancellationToken).ConfigureAwait(false); + var existingState = Read(stateSection); + var state = existingState is null + ? Create(environment, options, [deployment]) + : Merge(environment, options, existingState, deployment); + + stateSection.SetValue(Serialize(state)); + + await stateManager.SaveSectionAsync(stateSection, context.CancellationToken).ConfigureAwait(false); + } + + public static VercelDeploymentState? Read(DeploymentStateSection stateSection) + { + // DeploymentStateSection storage shape has changed across Aspire builds. Accept the + // known wrappers so destroy can still clean up projects created by an older CLI. + // The Vercel state payload itself is versioned below; this tolerance is only for the + // Aspire storage envelope, not for provider object ownership. + if (stateSection.Data.TryGetPropertyValue("value", out JsonNode? value) + && value is not null) + { + return Deserialize(value); + } + + value = stateSection.Data.FirstOrDefault().Value; + if (value is not null) + { + return Deserialize(value); + } + + if (stateSection.Data.ContainsKey("schemaVersion")) + { + return stateSection.Data.Deserialize(JsonOptions); + } + + return null; + } + + public static void Validate( + VercelEnvironmentResource environment, + VercelEnvironmentOptionsAnnotation options, + VercelDeploymentState state) + { + if (state.SchemaVersion != VercelConstants.DeploymentStateSchemaVersion) + { + throw new DistributedApplicationException($"Vercel deployment state for environment '{environment.Name}' uses unsupported schema version '{state.SchemaVersion}'. Redeploy the environment before destroying it."); + } + + if (!string.Equals(state.Environment, environment.Name, StringComparison.Ordinal)) + { + throw new DistributedApplicationException($"Vercel deployment state for environment '{state.Environment}' cannot be used to destroy environment '{environment.Name}'."); + } + + string? configuredScope = NormalizeScope(options.Scope); + if (!string.Equals(state.Scope, configuredScope, StringComparison.Ordinal)) + { + string stateScope = string.IsNullOrWhiteSpace(state.Scope) ? "" : state.Scope; + string requestedScope = string.IsNullOrWhiteSpace(configuredScope) ? "" : configuredScope; + throw new DistributedApplicationException($"Vercel deployment state for environment '{environment.Name}' was created for scope '{stateScope}', but destroy is configured for scope '{requestedScope}'. Use the same Vercel scope that created the deployment state."); + } + } + + public static VercelDeploymentState RemoveManagedProject(VercelDeploymentState state, string projectName) + => state with + { + Deployments = state.Deployments + .Where(deployment => !deployment.ManagedByAspire || !string.Equals(deployment.ProjectName, projectName, StringComparison.Ordinal)) + .ToArray() + }; + + public static string GetSectionName(VercelEnvironmentResource environment) => $"{VercelConstants.StateSectionNamePrefix}{environment.Name}"; + + public static string? GetProductionUrl(VercelEnvironmentOptionsAnnotation options, string projectName) + => options.Production ? $"https://{projectName}.vercel.app" : null; + + private static VercelDeploymentState Create( + VercelEnvironmentResource environment, + VercelEnvironmentOptionsAnnotation options, + VercelDeploymentStateEntry[] deployments) + => new( + VercelConstants.DeploymentStateSchemaVersion, + environment.Name, + NormalizeScope(options.Scope), + NormalizeTarget(options.Target), + options.Production, + deployments); + + private static VercelDeploymentState Merge( + VercelEnvironmentResource environment, + VercelEnvironmentOptionsAnnotation options, + VercelDeploymentState existingState, + VercelDeploymentStateEntry deployment) + { + Validate(environment, options, existingState); + + return Create( + environment, + options, + [ + .. existingState.Deployments.Where(existing => + !string.Equals(existing.ResourceName, deployment.ResourceName, StringComparison.Ordinal) + || !string.Equals(existing.ProjectName, deployment.ProjectName, StringComparison.Ordinal)), + deployment + ]); + } + + private static VercelDeploymentState? Deserialize(JsonNode value) + { + return value.GetValueKind() == JsonValueKind.String + ? JsonSerializer.Deserialize(value.GetValue(), JsonOptions) + : value.Deserialize(JsonOptions); + } + + public static string Serialize(VercelDeploymentState state) + => JsonSerializer.Serialize(state, JsonOptions); + + private static string? NormalizeScope(string? scope) + => string.IsNullOrWhiteSpace(scope) ? null : scope; + + private static string? NormalizeTarget(string? target) + => string.IsNullOrWhiteSpace(target) ? null : target; +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.BuildOutput.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.BuildOutput.cs new file mode 100644 index 000000000..11713b6dc --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.BuildOutput.cs @@ -0,0 +1,145 @@ +#pragma warning disable ASPIREPIPELINES001 +#pragma warning disable ASPIREPIPELINES004 + +using Aspire.Hosting; +using Aspire.Hosting.Pipelines; +using Microsoft.Extensions.DependencyInjection; + +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +/// +/// Vercel deployment-step helpers for preparing linked project directories, pulling provider +/// metadata, and writing Build Output API files before invoking vercel deploy --prebuilt. +/// +internal static partial class VercelDeploymentStep +{ + private static async Task PrepareProjectEnvironmentDirectoryAsync( + PipelineStepContext context, + IVercelCliRunner runner, + VercelEnvironmentOptionsAnnotation options, + VercelDeploymentEntry entry) + { + var outputService = context.Services.GetRequiredService(); + string projectLinkDirectory = Path.Combine(outputService.GetTempDirectory(entry.Resource), ".vercel-project"); + if (Directory.Exists(projectLinkDirectory)) + { + Directory.Delete(projectLinkDirectory, recursive: true); + } + Directory.CreateDirectory(projectLinkDirectory); + + // `vercel env add` is project-scoped but intentionally does not accept --project. + // See https://vercel.com/docs/cli/env and https://vercel.com/docs/cli/link. + // Link a scratch directory instead of the source root so secret configuration can use + // the CLI's native project lookup without writing .vercel metadata into user code. + var result = await runner.LinkProjectAsync(options, projectLinkDirectory, VercelProjectNameResolver.GetProjectOption(entry), context.CancellationToken).ConfigureAwait(false); + if (!result.Succeeded) + { + throw CreateCliException($"prepare temporary Vercel project link for resource '{entry.Resource.Name}'", VercelCliFileName, result); + } + + return projectLinkDirectory; + } + + private static async Task PullProjectSettingsAsync( + PipelineStepContext context, + IVercelCliRunner runner, + VercelEnvironmentOptionsAnnotation options, + VercelDeploymentEntry entry, + string projectLinkDirectory) + { + // `vercel pull` is the documented way to materialize project settings and environment + // files under `.vercel/`; VCR authentication depends on the pulled VERCEL_OIDC_TOKEN. + // See https://vercel.com/docs/cli/pull and https://vercel.com/docs/container-registry. + string targetEnvironment = VercelProjectEnvironment.GetName(options); + var result = await runner.PullProjectSettingsAsync(options, projectLinkDirectory, targetEnvironment, context.CancellationToken).ConfigureAwait(false); + if (!result.Succeeded) + { + throw CreateCliException($"pull Vercel project settings for resource '{entry.Resource.Name}'", VercelCliFileName, result); + } + + string vercelDirectory = Path.Combine(projectLinkDirectory, VercelConstants.DirectoryName); + string projectJsonPath = Path.Combine(vercelDirectory, VercelConstants.ProjectFileName); + string environmentPath = Path.Combine(vercelDirectory, $".env.{targetEnvironment}.local"); + + if (!File.Exists(projectJsonPath)) + { + throw new DistributedApplicationException($"Vercel pull did not write expected project settings file '{projectJsonPath}' for resource '{entry.Resource.Name}'."); + } + + if (!File.Exists(environmentPath)) + { + throw new DistributedApplicationException($"Vercel pull did not write expected environment file '{environmentPath}' for resource '{entry.Resource.Name}'."); + } + + var environmentVariables = VercelDotEnvParser.Parse(await File.ReadAllLinesAsync(environmentPath, context.CancellationToken).ConfigureAwait(false)); + if (!environmentVariables.TryGetValue(VercelConstants.OidcTokenEnvironmentVariable, out string? oidcToken) + || string.IsNullOrWhiteSpace(oidcToken)) + { + throw new DistributedApplicationException($"Vercel pull did not provide {VercelConstants.OidcTokenEnvironmentVariable}, which is required to authenticate local Docker builds to VCR."); + } + + string projectJsonContent = await File.ReadAllTextAsync(projectJsonPath, context.CancellationToken).ConfigureAwait(false); + var project = VercelProjectSettingsReader.Read(projectJsonPath, projectJsonContent); + + // `vercel pull` materializes project secrets next to the scratch link so local + // builders can read them. This integration only needs the short-lived OIDC token + // and project metadata; delete the env files before creating deploy artifacts. + if (File.Exists(environmentPath)) + { + File.Delete(environmentPath); + } + + string localEnvironmentPath = Path.Combine(projectLinkDirectory, ".env.local"); + if (File.Exists(localEnvironmentPath)) + { + File.Delete(localEnvironmentPath); + } + + return new(project.ProjectName, project.ProjectId, project.OrgId, projectJsonContent, oidcToken); + } + + private static async Task LoginToVcrAsync( + PipelineStepContext context, + IVercelCliRunner runner, + string oidcToken, + VercelOidcClaims claims) + => await LoginToVcrAsync(runner, oidcToken, claims, context.CancellationToken).ConfigureAwait(false); + + internal static async Task LoginToVcrAsync( + IVercelCliRunner runner, + string oidcToken, + VercelOidcClaims claims, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(claims.OwnerId)) + { + throw new DistributedApplicationException("The Vercel OIDC token did not include the owner_id claim required to authenticate to VCR."); + } + + // VCR supports Docker-compatible tooling at vcr.vercel.com. This login uses the + // Vercel-issued OIDC token pulled for the linked project. + // See https://vercel.com/docs/container-registry. + var result = await runner.LoginToVcrAsync(claims.OwnerId, oidcToken, cancellationToken).ConfigureAwait(false); + if (!result.Succeeded) + { + throw CreateCliException("authenticate Docker to VCR", DockerCliFileName, result); + } + } + + private static async Task EnsureManagedProjectAsync( + PipelineStepContext context, + IVercelCliRunner runner, + VercelEnvironmentOptionsAnnotation options, + VercelDeploymentEntry entry) + { + // `vercel project add` is idempotent for the current login/scope in the CLI versions + // this integration supports: it creates the project or validates that it already + // exists and is accessible. Failure here means deploy should not proceed to image push. + string projectName = VercelProjectNameResolver.GetProjectName(entry); + var result = await runner.AddProjectAsync(options, projectName, context.CancellationToken).ConfigureAwait(false); + if (!result.Succeeded) + { + throw CreateCliException($"create or validate Vercel project '{projectName}' for resource '{entry.Resource.Name}'", VercelCliFileName, result); + } + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Cli.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Cli.cs new file mode 100644 index 000000000..4d0e21666 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Cli.cs @@ -0,0 +1,97 @@ +#pragma warning disable ASPIREPIPELINES001 +#pragma warning disable CTASPIREVERCEL001 + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Pipelines; +using Microsoft.Extensions.DependencyInjection; +using Aspire.Hosting; + +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +/// +/// Vercel deployment-step helpers for CLI prerequisite validation, deployment verification, +/// and shared error shaping around external tool failures. +/// +internal static partial class VercelDeploymentStep +{ + public static async Task ValidateCliPrerequisitesAsync(PipelineStepContext context, VercelEnvironmentResource environment) + { + var options = environment.GetVercelOptions(); + var runner = context.Services.GetRequiredService(); + + var versionResult = await runner.GetVersionAsync(context.CancellationToken).ConfigureAwait(false); + if (!versionResult.Succeeded) + { + throw CreateCliException("validate Vercel CLI installation", VercelCliFileName, versionResult); + } + + var versionOutput = $"{versionResult.StandardOutput}{Environment.NewLine}{versionResult.StandardError}"; + if (!VercelCliOutputParser.TryGetCliVersion(versionOutput, out var version)) + { + throw new DistributedApplicationException( + $"Failed to determine Vercel CLI version from '{VercelCliOutputParser.GetTrimmedOutput(versionOutput)}'. Install Vercel CLI {MinimumVercelCliVersion} or later from https://vercel.com/docs/cli."); + } + + // The preview relies on newer CLI behavior: project-scoped link/pull, + // prebuilt deploys, deployment-scoped --env, JSON inspect output with + // --wait/--timeout, and project removal. + if (version < MinimumVercelCliVersion) + { + throw new DistributedApplicationException( + $"Vercel CLI version '{version}' is not supported. Install Vercel CLI {MinimumVercelCliVersion} or later from https://vercel.com/docs/cli."); + } + + var whoamiResult = await runner.GetCurrentUserAsync(context.CancellationToken).ConfigureAwait(false); + if (!whoamiResult.Succeeded) + { + throw CreateCliException("validate Vercel authentication", VercelCliFileName, whoamiResult); + } + + if (!string.IsNullOrWhiteSpace(options.Scope)) + { + var scopeResult = await runner.ListProjectsAsync(options, filter: null, context.CancellationToken).ConfigureAwait(false); + if (!scopeResult.Succeeded) + { + throw CreateCliException($"validate Vercel scope '{options.Scope}'", VercelCliFileName, scopeResult); + } + } + } + + private static async Task VerifyDeploymentAsync( + PipelineStepContext context, + IVercelCliRunner runner, + VercelEnvironmentOptionsAnnotation options, + string resourceName, + VercelDeploymentResult deploymentResult) + { + // A successful `vercel deploy` only means the CLI accepted the submission. + // Query the provider before recording state so Aspire does not persist failed + // or still-building deployments as successfully applied resources. + var result = await runner.InspectDeploymentAsync(options, deploymentResult.DeploymentUrl, context.CancellationToken).ConfigureAwait(false); + + if (!result.Succeeded) + { + throw CreateCliException($"verify Vercel deployment for resource '{resourceName}'", VercelCliFileName, result); + } + + var inspection = VercelCliOutputParser.GetDeploymentInspection(result.StandardOutput); + if (inspection.ReadyState is null) + { + throw new DistributedApplicationException($"Vercel inspect output for resource '{resourceName}' did not include a deployment ready state. Output: {VercelCliOutputParser.GetTrimmedOutput(result.StandardOutput)}"); + } + + if (!string.Equals(inspection.ReadyState, "READY", StringComparison.OrdinalIgnoreCase)) + { + throw new DistributedApplicationException($"Vercel deployment for resource '{resourceName}' finished with state '{inspection.ReadyState}' instead of 'READY'."); + } + } + + private static DistributedApplicationException CreateCliException(string operation, string cliPath, VercelCliResult result) + { + string output = string.IsNullOrWhiteSpace(result.StandardError) + ? result.StandardOutput.Trim() + : result.StandardError.Trim(); + + return new DistributedApplicationException($"Failed to {operation} using '{cliPath}' (exit code {result.ExitCode}). {output}"); + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Plan.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Plan.cs new file mode 100644 index 000000000..6115a1a72 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Plan.cs @@ -0,0 +1,68 @@ +#pragma warning disable ASPIREPIPELINES001 +#pragma warning disable ASPIREPIPELINES004 +#pragma warning disable CTASPIREVERCEL001 + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Pipelines; +using Aspire.Hosting.Publishing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Aspire.Hosting; + +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +/// +/// Vercel deployment-step helpers that bridge Aspire pipeline callbacks to the testable +/// publish-plan writer. +/// +internal static partial class VercelDeploymentStep +{ + public static async Task WriteDeploymentPlanAsync(PipelineStepContext context, VercelEnvironmentResource environment) + { + var outputService = context.Services.GetRequiredService(); + string outputDirectory = outputService.GetOutputDirectory(environment); + + string planPath = await VercelDeploymentPlanWriter.WriteAsync( + context.ExecutionContext, + context.Logger, + context.Model, + environment, + outputDirectory, + context.CancellationToken).ConfigureAwait(false); + + context.Summary.Add("Vercel deployment plan", planPath); + } + + internal static async Task WriteDeploymentPlanAsync( + DistributedApplicationModel model, + VercelEnvironmentResource environment, + string outputDirectory, + CancellationToken cancellationToken) + => await VercelDeploymentPlanWriter.WriteAsync(model, environment, outputDirectory, cancellationToken).ConfigureAwait(false); + + internal static async Task WriteDeploymentPlanAsync( + DistributedApplicationExecutionContext? executionContext, + ILogger? logger, + DistributedApplicationModel model, + VercelEnvironmentResource environment, + string outputDirectory, + CancellationToken cancellationToken) + => await VercelDeploymentPlanWriter.WriteAsync(executionContext, logger, model, environment, outputDirectory, cancellationToken).ConfigureAwait(false); + + internal static async Task BuildDeployArgumentsAsync( + DistributedApplicationExecutionContext executionContext, + ILogger logger, + VercelEnvironmentOptionsAnnotation options, + VercelDeploymentEntry entry, + CancellationToken cancellationToken) + => await VercelDeploymentPlanWriter.BuildDeployArgumentsAsync(executionContext, logger, options, entry, cancellationToken).ConfigureAwait(false); + + internal static async Task BuildDeployArgumentsAsync( + DistributedApplicationExecutionContext executionContext, + ILogger logger, + VercelEnvironmentOptionsAnnotation options, + VercelDeploymentEntry entry, + IReadOnlyList entries, + CancellationToken cancellationToken) + => await VercelDeploymentPlanWriter.BuildDeployArgumentsAsync(executionContext, logger, options, entry, entries, cancellationToken).ConfigureAwait(false); +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.State.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.State.cs new file mode 100644 index 000000000..3fded97b3 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.State.cs @@ -0,0 +1,267 @@ +#pragma warning disable ASPIREPIPELINES001 +#pragma warning disable ASPIREPIPELINES002 +#pragma warning disable ASPIREPIPELINES004 +#pragma warning disable CTASPIREVERCEL001 + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Pipelines; +using Microsoft.Extensions.DependencyInjection; +using System.Text.Json; + +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +/// +/// Vercel deployment-step helpers for state-driven destroy. Cleanup uses persisted ownership +/// instead of the current model so removed resources can still be deleted safely. +/// +internal static partial class VercelDeploymentStep +{ + public static async Task DestroyAsync(PipelineStepContext context, VercelEnvironmentResource environment) + { + var options = environment.GetVercelOptions(); + var stateManager = context.Services.GetRequiredService(); + var stateSection = await stateManager.AcquireSectionAsync(VercelDeploymentStateStore.GetSectionName(environment), context.CancellationToken).ConfigureAwait(false); + var state = VercelDeploymentStateStore.Read(stateSection); + if (state is null) + { + // Keep no-op destroy cheap and offline. If Aspire has no deployment state, + // there is no recorded provider object that this integration owns. + context.Summary.Add("Vercel destroy", $"No Vercel deployment state was found for environment '{environment.Name}'. Nothing to destroy."); + return; + } + + VercelDeploymentStateStore.Validate(environment, options, state); + + // Destroy is state-first rather than model-first. The current AppHost may no + // longer contain the resources that created these Vercel projects, but persisted + // state records which provider objects Aspire is allowed to delete. + var projects = state.Deployments + .Where(static deployment => deployment.ManagedByAspire) + .Select(static deployment => deployment.ProjectName) + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal) + .ToArray(); + var linkedDeploymentsWithEnvironmentVariables = state.Deployments + .Where(static deployment => !deployment.ManagedByAspire && deployment.ProjectEnvironmentVariables.Length > 0) + .OrderBy(static deployment => deployment.ProjectName, StringComparer.Ordinal) + .ThenBy(static deployment => deployment.ResourceName, StringComparer.Ordinal) + .ToArray(); + + if (projects.Length == 0 && linkedDeploymentsWithEnvironmentVariables.Length == 0) + { + // Linked .vercel projects are valid deploy targets but remain externally owned, + // so clearing state is safer than deleting provider projects the user brought. + context.Summary.Add("Vercel destroy", $"No Aspire-managed Vercel deployments were found for environment '{environment.Name}'."); + await stateManager.DeleteSectionAsync(stateSection, context.CancellationToken).ConfigureAwait(false); + return; + } + + // Validate auth only after we know there is provider state to mutate. This keeps + // `aspire destroy` usable in clean workspaces or after state has already been removed. + await ValidateCliPrerequisitesAsync(context, environment).ConfigureAwait(false); + var runner = context.Services.GetRequiredService(); + + foreach (var deployment in linkedDeploymentsWithEnvironmentVariables) + { + await RemoveLinkedProjectEnvironmentVariablesAsync( + context, + runner, + options, + environment, + deployment, + VercelProjectEnvironment.GetName(state)).ConfigureAwait(false); + } + + if (projects.Length == 0) + { + context.Summary.Add("Vercel destroy", $"No Aspire-managed Vercel deployments were found for environment '{environment.Name}'."); + await stateManager.DeleteSectionAsync(stateSection, context.CancellationToken).ConfigureAwait(false); + return; + } + + foreach (string projectName in projects) + { + if (!await ProjectExistsAsync(context, runner, options, projectName).ConfigureAwait(false)) + { + context.Summary.Add("Vercel project already absent", projectName); + } + else + { + var result = await runner.RemoveProjectAsync(options, projectName, context.CancellationToken).ConfigureAwait(false); + + if (!result.Succeeded) + { + if (await ProjectExistsAsync(context, runner, options, projectName).ConfigureAwait(false)) + { + throw CreateCliException($"destroy Vercel project '{projectName}'", VercelCliFileName, result); + } + + context.Summary.Add("Vercel project already absent", projectName); + } + else + { + context.Summary.Add("Vercel project removed", projectName); + } + } + + state = VercelDeploymentStateStore.RemoveManagedProject(state, projectName); + // Save after each project removal so a later CLI failure leaves retryable state + // for projects that still exist instead of forgetting partially cleaned resources. + stateSection.SetValue(VercelDeploymentStateStore.Serialize(state)); + await stateManager.SaveSectionAsync(stateSection, context.CancellationToken).ConfigureAwait(false); + } + + await stateManager.DeleteSectionAsync(stateSection, context.CancellationToken).ConfigureAwait(false); + } + + private static async Task ProjectExistsAsync( + PipelineStepContext context, + IVercelCliRunner runner, + VercelEnvironmentOptionsAnnotation options, + string projectName) + { + // Avoid treating localized or reformatted CLI errors as provider state. The Vercel + // CLI exposes project lists as JSON, so destroy checks exact project names before + // deleting and again after a failed delete to distinguish races from real failures. + var result = await runner.ListProjectsAsync(options, projectName, context.CancellationToken).ConfigureAwait(false); + if (!result.Succeeded) + { + throw CreateCliException($"list Vercel projects while checking for '{projectName}'", VercelCliFileName, result); + } + + return VercelCliOutputParser.ProjectListContainsProject(result.StandardOutput, projectName); + } + + private static async Task ProjectEnvironmentVariableExistsAsync( + PipelineStepContext context, + IVercelCliRunner runner, + VercelEnvironmentOptionsAnnotation options, + string projectLinkDirectory, + string name, + string targetEnvironment) + { + // `vercel env rm` reports absence as human text, but `vercel env ls --format=json` + // returns the linked project's exact keys. Use that provider read for idempotent + // stale-secret cleanup instead of parsing failure prose. + var result = await runner.ListProjectEnvironmentVariablesAsync(options, projectLinkDirectory, targetEnvironment, context.CancellationToken).ConfigureAwait(false); + if (!result.Succeeded) + { + throw CreateCliException($"list Vercel project environment variables before removing '{name}'", VercelCliFileName, result); + } + + return VercelCliOutputParser.EnvironmentVariableListContainsName(result.StandardOutput, name); + } + + private static async Task ConfigureProjectEnvironmentVariablesAsync( + PipelineStepContext context, + IVercelCliRunner runner, + VercelEnvironmentOptionsAnnotation options, + VercelDeploymentEntry entry, + string projectLinkDirectory, + IReadOnlyList> environmentVariables, + PreviousVercelDeployment? previousDeployment) + { + var currentNames = environmentVariables + .Select(static variable => variable.Key) + .ToHashSet(StringComparer.Ordinal); + + if (previousDeployment is not null) + { + string[] staleNames = previousDeployment.Entry.ProjectEnvironmentVariables + .Where(name => !currentNames.Contains(name)) + .Order(StringComparer.Ordinal) + .ToArray(); + + await RemoveProjectEnvironmentVariablesAsync( + context, + runner, + options, + projectLinkDirectory, + entry.Resource.Name, + staleNames, + previousDeployment.ProjectEnvironment).ConfigureAwait(false); + } + + if (environmentVariables.Count == 0) + { + return; + } + + string targetEnvironment = VercelProjectEnvironment.GetName(options); + foreach (var environmentVariable in environmentVariables.OrderBy(static variable => variable.Key, StringComparer.Ordinal)) + { + var result = await runner.AddProjectEnvironmentVariableAsync(options, projectLinkDirectory, environmentVariable.Key, targetEnvironment, environmentVariable.Value, context.CancellationToken).ConfigureAwait(false); + if (!result.Succeeded) + { + throw CreateCliException($"configure Vercel project environment variable '{environmentVariable.Key}' for resource '{entry.Resource.Name}'", VercelCliFileName, result); + } + } + } + + private static async Task RemoveProjectEnvironmentVariablesAsync( + PipelineStepContext context, + IVercelCliRunner runner, + VercelEnvironmentOptionsAnnotation options, + string projectLinkDirectory, + string resourceName, + IReadOnlyList names, + string targetEnvironment) + { + foreach (string name in names) + { + if (!await ProjectEnvironmentVariableExistsAsync(context, runner, options, projectLinkDirectory, name, targetEnvironment).ConfigureAwait(false)) + { + continue; + } + + var result = await runner.RemoveProjectEnvironmentVariableAsync(options, projectLinkDirectory, name, targetEnvironment, context.CancellationToken).ConfigureAwait(false); + if (!result.Succeeded + && await ProjectEnvironmentVariableExistsAsync(context, runner, options, projectLinkDirectory, name, targetEnvironment).ConfigureAwait(false)) + { + throw CreateCliException($"remove stale Vercel project environment variable '{name}' for resource '{resourceName}'", VercelCliFileName, result); + } + } + } + + private static async Task RemoveLinkedProjectEnvironmentVariablesAsync( + PipelineStepContext context, + IVercelCliRunner runner, + VercelEnvironmentOptionsAnnotation options, + VercelEnvironmentResource environment, + VercelDeploymentStateEntry deployment, + string targetEnvironment) + { + var outputService = context.Services.GetRequiredService(); + string projectLinkDirectory = Path.Combine(outputService.GetTempDirectory(environment), ".vercel-projects", deployment.ProjectName); + if (Directory.Exists(projectLinkDirectory)) + { + Directory.Delete(projectLinkDirectory, recursive: true); + } + Directory.CreateDirectory(projectLinkDirectory); + + try + { + var linkResult = await runner.LinkProjectAsync(options, projectLinkDirectory, deployment.ProjectId ?? deployment.ProjectName, context.CancellationToken).ConfigureAwait(false); + if (!linkResult.Succeeded) + { + throw CreateCliException($"link Vercel project '{deployment.ProjectName}' for environment variable cleanup", VercelCliFileName, linkResult); + } + + await RemoveProjectEnvironmentVariablesAsync( + context, + runner, + options, + projectLinkDirectory, + deployment.ResourceName, + deployment.ProjectEnvironmentVariables, + targetEnvironment).ConfigureAwait(false); + } + finally + { + if (Directory.Exists(projectLinkDirectory)) + { + Directory.Delete(projectLinkDirectory, recursive: true); + } + } + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Types.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Types.cs new file mode 100644 index 000000000..b70a265b5 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.Types.cs @@ -0,0 +1,228 @@ +#pragma warning disable ASPIREPIPELINES001 +#pragma warning disable ASPIRECOMPUTE003 +#pragma warning disable CTASPIREVERCEL001 + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.ApplicationModel.Docker; + +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +/// +/// Captures the Aspire workload and source/build metadata needed to prepare one Vercel project +/// without repeatedly walking the app model. +/// +internal sealed record VercelDeploymentEntry( + IResource Resource, + string SourceRoot, + string? DockerfilePath = null, + DockerfileBuildAnnotation? Dockerfile = null, + string TempDirectory = "", + string DeployDirectory = "") +{ + public string EffectiveDeployDirectory => string.IsNullOrWhiteSpace(DeployDirectory) ? SourceRoot : DeployDirectory; +} + +/// +/// Serializable publish output that lets users review which workloads would be deployed to Vercel. +/// +internal sealed record VercelDeploymentPlan(string Environment, VercelDeploymentPlanEntry[] Deployments); + +/// +/// One workload row in the publish plan, including the Dockerfile source and deploy command shape. +/// +internal sealed record VercelDeploymentPlanEntry(string ResourceName, string DockerfilePath, string DeployCommand, string[] EnvironmentVariables); + +/// +/// Splits Aspire environment values into Vercel deployment arguments and project environment +/// variables because secrets must be configured through provider storage instead of CLI args. +/// +internal sealed record VercelEnvironmentConfiguration( + IReadOnlyList> DeploymentEnvironmentVariables, + IReadOnlyList> ProjectEnvironmentVariables, + IReadOnlyList ServiceBindings) +{ + public static VercelEnvironmentConfiguration Empty { get; } = new([], [], []); + + public IEnumerable AllEnvironmentVariableNames => + DeploymentEnvironmentVariables.Select(static variable => variable.Key) + .Concat(ProjectEnvironmentVariables.Select(static variable => variable.Key)) + .Concat(ServiceBindings.Select(static binding => binding.EnvironmentVariableName)); +} + +/// +/// Describes one Vercel service binding that injects a private target service URL into the caller. +/// +internal sealed record VercelServiceBinding(string EnvironmentVariableName, string ServiceName); + +/// +/// Typed result from vercel deploy; the URL is used for verification and the optional ID +/// is persisted when the CLI provides it. +/// +internal sealed record VercelDeploymentResult(string? DeploymentId, string DeploymentUrl); + +/// +/// Typed result from vercel inspect containing only the provider readiness state deploy needs. +/// +internal sealed record VercelDeploymentInspection(string? ReadyState); + +/// +/// Combines a persisted deployment entry with the Vercel environment name that originally +/// configured it, so redeploy and cleanup target the same provider scope. +/// +internal sealed record PreviousVercelDeployment(VercelDeploymentStateEntry Entry, string ProjectEnvironment); + +/// +/// Persisted ownership record for a Vercel environment. Destroy uses this state instead of the +/// current AppHost so projects can be cleaned up after resources are renamed or removed. +/// +internal sealed record VercelDeploymentState( + int SchemaVersion, + string Environment, + string? Scope, + string? Target, + bool Production, + VercelDeploymentStateEntry[] Deployments); + +/// +/// Persisted ownership details for one Vercel project/deployment created or updated by Aspire. +/// +internal sealed record VercelDeploymentStateEntry( + string ResourceName, + string ProjectName, + string? ProjectId, + string? DeploymentId, + string? DeploymentUrl, + string SourceRoot, + bool ManagedByAspire) +{ + public string? ProductionUrl { get; init; } + + public string? VcrImageDigest { get; init; } + + public VercelServiceDeploymentStateEntry[] Services { get; init; } = []; + + public int? BuildOutputApiVersion { get; init; } + + public string[] ProjectEnvironmentVariables { get; init; } = []; +} + +/// +/// Immutable VCR image reference paired with the digest Vercel Build Output API should deploy. +/// +internal sealed record VercelImageReference(string Reference, string Digest); + +/// +/// Captures one Aspire workload as a Vercel service within a project group. +/// +internal sealed record VercelDeploymentService( + VercelDeploymentEntry Entry, + string ServiceName, + bool IsPublicRoot); + +/// +/// A Vercel project deployment unit: one public/root service plus private services it owns. +/// +internal sealed record VercelDeploymentProjectGroup( + VercelDeploymentService Root, + VercelDeploymentService[] Services) +{ + public VercelDeploymentEntry RootEntry => Root.Entry; +} + +/// +/// Lookup structure used while translating Aspire references into Vercel project/service concepts. +/// +internal sealed class VercelDeploymentProjectMap(IReadOnlyList groups) +{ + private readonly Dictionary _groupsByResourceName = groups + .SelectMany(group => group.Services.Select(service => new { service.Entry.Resource.Name, Group = group })) + .ToDictionary(static item => item.Name, static item => item.Group, StringComparer.Ordinal); + + private readonly Dictionary _servicesByResourceName = groups + .SelectMany(static group => group.Services) + .ToDictionary(static service => service.Entry.Resource.Name, StringComparer.Ordinal); + + public IReadOnlyList Groups { get; } = groups; + + public bool TryGetService(string resourceName, out VercelDeploymentService service) + => _servicesByResourceName.TryGetValue(resourceName, out service!); + + public bool TryGetGroup(string resourceName, out VercelDeploymentProjectGroup group) + => _groupsByResourceName.TryGetValue(resourceName, out group!); + + public bool AreInSameProject(string sourceResourceName, string targetResourceName) + => _groupsByResourceName.TryGetValue(sourceResourceName, out var sourceGroup) + && _groupsByResourceName.TryGetValue(targetResourceName, out var targetGroup) + && ReferenceEquals(sourceGroup, targetGroup); +} + +/// +/// Pairs a prepared Vercel service with the immutable image digest resolved after Aspire pushes it. +/// +internal sealed record VercelResolvedDeployment( + VercelPreparedDeploymentAnnotation PreparedDeployment, + VercelImageReference Image); + +/// +/// Persisted digest/repository details for one service inside a Vercel project deployment. +/// +internal sealed record VercelServiceDeploymentStateEntry( + string ResourceName, + string ServiceName, + string? VcrImageDigest); + +/// +/// Resource annotation produced during Vercel prerequisite work and consumed by Aspire's image +/// push decorator and deploy step to keep project, token, and image-tag context together. +/// +internal sealed record VercelPreparedDeploymentAnnotation( + VercelDeploymentEntry Entry, + string ServiceName, + VercelProjectLink ProjectLink, + VercelPulledProjectContext ProjectContext, + VercelEnvironmentConfiguration EnvironmentConfiguration, + bool ManagedByAspire, + string RemoteImageName, + string RemoteImageTag, + string TaggedImageReference) : IResourceAnnotation; + +/// +/// Marker annotation used to attach Vercel push options only after project preparation has +/// produced provider-specific VCR image metadata. +/// +internal sealed class VercelImagePushOptionsCallbackAnnotation : IResourceAnnotation +{ +} + +/// +/// Resolved Vercel project identity, either from a checked-in link file or an Aspire-managed name. +/// +internal sealed record VercelProjectLink(string ProjectName, string? ProjectId); + +/// +/// Project metadata and OIDC token materialized by vercel pull for one deployment. +/// +internal sealed record VercelPulledProject( + string ProjectName, + string? ProjectId, + string? OrgId, + string ProjectJsonContent, + string OidcToken); + +/// +/// Complete provider context needed after project preparation: environment variables, pulled +/// project settings, and decoded claims for VCR operations. +/// +internal sealed record VercelPulledProjectContext( + VercelPulledProject PulledProject, + VercelOidcClaims OidcClaims); + +/// +/// Safe subset of .vercel/project.json identity fields that can be stored in state. +/// +internal sealed record VercelPulledProjectSettings(string ProjectName, string? ProjectId, string? OrgId); + +/// +/// Vercel OIDC claims used for VCR routing and repository creation, not for local auth decisions. +/// +internal sealed record VercelOidcClaims(string? OwnerId, string? Owner, string? Project, string? ProjectId); diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs new file mode 100644 index 000000000..46d7f2148 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDeploymentStep.cs @@ -0,0 +1,714 @@ +#pragma warning disable ASPIREPIPELINES001 +#pragma warning disable ASPIREPIPELINES002 +#pragma warning disable ASPIREPIPELINES003 +#pragma warning disable ASPIREPIPELINES004 +#pragma warning disable ASPIRECOMPUTE003 +#pragma warning disable ASPIREPROBES001 +#pragma warning disable CTASPIREVERCEL001 + +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.ApplicationModel.Docker; +using Aspire.Hosting.Pipelines; +using Aspire.Hosting.Publishing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Net.Sockets; +using System.Runtime.ExceptionServices; +using System.Text; +using System.Text.RegularExpressions; + +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +/// +/// Coordinates the Aspire publish, deploy, and destroy pipeline steps for Vercel while +/// delegating provider formats and app-model rules to focused testable helpers. +/// +internal static partial class VercelDeploymentStep +{ + // E2E contract: + // publish => deterministic, reviewable vercel-deployments.json with no provider mutation + // deploy prereq => validate tools/auth, create/link projects, configure VCR registry annotations + // built-in Aspire build/push => build the actual workload image and push it to VCR + // deploy => resolve the pushed tag to a linux/amd64 digest, write Build Output API metadata, + // run `vercel deploy --prebuilt`, verify with `vercel inspect`, then save state + // destroy => use saved state, not the current model, and delete only Aspire-managed projects. + // Keep provider/protocol parsing in small internal helpers so tests can assert exact behavior + // without live Vercel credentials. + // + // Vercel contracts referenced by the non-obvious deploy behavior below: + // Container Images: https://vercel.com/docs/functions/container-images + // Container Registry: https://vercel.com/docs/container-registry + // Build Output API: https://vercel.com/docs/build-output-api + // Prebuilt deploy: https://vercel.com/docs/cli/deploy + public const string PublishStepNamePrefix = "vercel-generate-plan-"; + public const string DeployPrereqStepNamePrefix = "vercel-prepare-projects-"; + public const string DeployStepNamePrefix = "vercel-deploy-prebuilt-"; + public const string DestroyPrereqStepNamePrefix = "vercel-prepare-destroy-"; + public const string DestroyStepNamePrefix = "vercel-destroy-"; + + private const string VercelCliFileName = VercelConstants.CliFileName; + internal const string DockerCliFileName = VercelConstants.DockerCliFileName; + private const string VcrRegistry = VercelConstants.VcrRegistry; + private const string VercelContainerServiceName = VercelConstants.ContainerServiceName; + private const string PushPrereqStepName = "push-prereq"; + private static readonly Version MinimumVercelCliVersion = new(54, 18, 6); + + public static async Task ValidatePrerequisitesAsync(PipelineStepContext context, VercelEnvironmentResource environment) + { + // This is the deploy prerequisite step, not merely static validation. It intentionally + // performs the provider reads/mutations required before Aspire's shared push-prereq + // step can validate registry metadata: project create/link, Vercel pull, VCR login, + // VCR repository ensure, and deployment target annotation setup. + var options = environment.GetVercelOptions(); + var entries = VercelDeploymentModel.GetEntries(context.Model, environment).ToList(); + VercelDeploymentModel.ValidateEntries(entries); + foreach (var entry in entries) + { + await VercelDeploymentModel.ValidateVercelJsonAsync(entry.Resource, entry.SourceRoot, context.CancellationToken).ConfigureAwait(false); + } + + var projectMap = await VercelDeploymentProjectGrouper.CreateMapAsync(context.ExecutionContext, context.Logger, entries, context.CancellationToken).ConfigureAwait(false); + + await ValidateCliPrerequisitesAsync(context, environment).ConfigureAwait(false); + await ValidateDockerDigestInspectionPrerequisitesAsync(context).ConfigureAwait(false); + var runner = context.Services.GetRequiredService(); + var registryClient = context.Services.GetRequiredService(); + await VercelDeploymentStateStore.ValidateExistingAsync(context, environment, options).ConfigureAwait(false); + var entriesByResourceName = VercelDeploymentModel.GetEntriesByResourceName(entries); + + foreach (var group in projectMap.Groups) + { + await PrepareProjectGroupForBuiltInImagePushAsync( + context, + environment, + options, + runner, + registryClient, + entriesByResourceName, + projectMap, + group).ConfigureAwait(false); + } + } + + private static async Task ValidateDockerDigestInspectionPrerequisitesAsync(PipelineStepContext context) + { + var runner = context.Services.GetRequiredService(); + var result = await runner.ValidateDockerBuildxAsync(context.CancellationToken).ConfigureAwait(false); + if (!result.Succeeded) + { + throw CreateCliException("validate Docker buildx for VCR image digest inspection", DockerCliFileName, result); + } + } + + public static async Task DeployAsync(PipelineStepContext context, VercelEnvironmentResource environment) + { + var options = environment.GetVercelOptions(); + var runner = context.Services.GetRequiredService(); + var entries = VercelDeploymentModel.GetEntries(context.Model, environment).ToList(); + + VercelDeploymentModel.ValidateEntries(entries); + var projectMap = await VercelDeploymentProjectGrouper.CreateMapAsync(context.ExecutionContext, context.Logger, entries, context.CancellationToken).ConfigureAwait(false); + // Normal pipeline execution runs the prereq step first. Keep this fallback so the + // deploy delegate remains directly unit-testable and safe if invoked by a custom runner. + if (entries.Any(entry => GetPreparedDeployment(entry.Resource) is null)) + { + await ValidatePrerequisitesAsync(context, environment).ConfigureAwait(false); + } + + List<( + VercelDeploymentProjectGroup Group, + VercelPreparedDeploymentAnnotation RootDeployment, + IReadOnlyList ResolvedDeployments)> preparedGroups = []; + + foreach (var group in projectMap.Groups) + { + List resolvedDeployments = []; + foreach (var service in group.Services) + { + var preparedDeployment = GetPreparedDeployment(service.Entry.Resource); + if (preparedDeployment is null) + { + throw new DistributedApplicationException($"Resource '{service.Entry.Resource.Name}' was not prepared for Vercel deployment. Run the '{DeployPrereqStepNamePrefix}{environment.Name}' pipeline step before deploying."); + } + + // Docker auth for VCR is registry-host scoped, so keep the re-login/digest inspect + // phase sequential before producing the single Vercel project deployment. + var image = await ResolvePushedImageDigestAsync(context, runner, preparedDeployment).ConfigureAwait(false); + resolvedDeployments.Add(new(preparedDeployment, image)); + } + + var rootDeployment = resolvedDeployments.Single(resolved => resolved.PreparedDeployment.Entry.Resource.Name == group.RootEntry.Resource.Name).PreparedDeployment; + VercelDeploymentService[] preparedServices = [.. group.Services + .Select(service => + { + var preparedDeployment = resolvedDeployments.Single(resolved => resolved.PreparedDeployment.Entry.Resource.Name == service.Entry.Resource.Name).PreparedDeployment; + return service with + { + Entry = preparedDeployment.Entry, + ServiceName = preparedDeployment.ServiceName + }; + })]; + var preparedGroup = group with + { + Root = preparedServices.Single(service => string.Equals(service.Entry.Resource.Name, group.RootEntry.Resource.Name, StringComparison.Ordinal)), + Services = preparedServices + }; + + preparedGroups.Add((preparedGroup, rootDeployment, resolvedDeployments)); + } + + var deploymentOutcomes = await Task.WhenAll(preparedGroups.Select((preparedGroup, index) => DeployProjectGroupAsync(preparedGroup, index))).ConfigureAwait(false); + foreach (var outcome in deploymentOutcomes.OrderBy(static outcome => outcome.Index)) + { + if (outcome.Exception is not null || outcome.DeploymentResult is null) + { + continue; + } + + string? productionUrl = VercelDeploymentStateStore.GetProductionUrl(options, outcome.RootDeployment.ProjectLink.ProjectName); + var stateEntry = CreateSuccessfulDeploymentStateEntry( + outcome.Group, + outcome.RootDeployment.ProjectLink, + outcome.RootDeployment.ProjectContext, + outcome.DeploymentResult, + outcome.ResolvedDeployments, + outcome.RootDeployment.ManagedByAspire, + productionUrl); + + // Persist each verified project group independently. Another group can fail after + // Vercel has already created earlier projects, and destroy must still know which + // managed projects are safe to remove or retry. + await VercelDeploymentStateStore.SaveEntryAsync(context, environment, options, stateEntry).ConfigureAwait(false); + + AddDeploymentSummary(context, outcome.Group.RootEntry.Resource.Name, outcome.DeploymentResult, productionUrl); + } + + var failure = deploymentOutcomes.FirstOrDefault(static outcome => outcome.Exception is not null); + if (failure.Exception is not null) + { + ExceptionDispatchInfo.Capture(failure.Exception).Throw(); + } + + async Task<( + int Index, + VercelDeploymentProjectGroup Group, + VercelPreparedDeploymentAnnotation RootDeployment, + IReadOnlyList ResolvedDeployments, + VercelDeploymentResult? DeploymentResult, + Exception? Exception)> DeployProjectGroupAsync( + (VercelDeploymentProjectGroup Group, VercelPreparedDeploymentAnnotation RootDeployment, IReadOnlyList ResolvedDeployments) preparedGroup, + int index) + { + try + { + var deploymentResult = await DeployPrebuiltOutputAsync( + context, + runner, + options, + preparedGroup.Group, + preparedGroup.RootDeployment, + preparedGroup.ResolvedDeployments).ConfigureAwait(false); + return (index, preparedGroup.Group, preparedGroup.RootDeployment, preparedGroup.ResolvedDeployments, deploymentResult, Exception: null); + } + catch (Exception ex) + { + return (index, preparedGroup.Group, preparedGroup.RootDeployment, preparedGroup.ResolvedDeployments, DeploymentResult: null, ex); + } + } + } + + private static async Task EnsureManagedProjectAndSaveInitialStateAsync( + PipelineStepContext context, + VercelEnvironmentResource environment, + VercelEnvironmentOptionsAnnotation options, + IVercelCliRunner runner, + VercelDeploymentEntry preparedEntry, + string sourceRoot, + VercelProjectLink projectLink, + PreviousVercelDeployment? previousDeployment) + { + // Create/validate the project before env configuration and VCR auth. Deploy can + // take --project; env configuration and `vercel pull` link only a scratch directory + // because the project-scoped env command has no --project option. + await EnsureManagedProjectAsync(context, runner, options, preparedEntry).ConfigureAwait(false); + + // Record the managed project as soon as Vercel accepts it. Build, env + // configuration, or deploy can still fail afterward, and destroy must be able + // to clean up that partially-created provider object. + await VercelDeploymentStateStore.SaveEntryAsync(context, environment, options, new( + preparedEntry.Resource.Name, + projectLink.ProjectName, + projectLink.ProjectId, + DeploymentId: null, + DeploymentUrl: null, + sourceRoot, + ManagedByAspire: true) + { + ProductionUrl = VercelDeploymentStateStore.GetProductionUrl(options, projectLink.ProjectName), + ProjectEnvironmentVariables = previousDeployment?.Entry.ProjectEnvironmentVariables ?? [] + }).ConfigureAwait(false); + } + + private static void ValidateProjectEnvironmentVariableCollisions( + VercelDeploymentProjectGroup group, + IReadOnlyDictionary environmentConfigurations) + { + var collisions = group.Services + .SelectMany(service => environmentConfigurations[service.Entry.Resource.Name].ProjectEnvironmentVariables + .Select(variable => new + { + variable.Key, + ResourceName = service.Entry.Resource.Name + })) + .GroupBy(static item => item.Key, StringComparer.Ordinal) + .Where(static grouping => grouping.Select(static item => item.ResourceName).Distinct(StringComparer.Ordinal).Count() > 1) + .ToArray(); + + if (collisions.Length == 0) + { + return; + } + + var collision = collisions[0]; + string resources = string.Join(", ", collision.Select(static item => $"'{item.ResourceName}'").Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal)); + throw new DistributedApplicationException($"Multiple services in Vercel project root '{group.RootEntry.Resource.Name}' configure secret project environment variable '{collision.Key}' ({resources}). Vercel project environment variables are project-scoped; use distinct environment variable names or move the value to a non-secret per-service setting."); + } + + public static void ConfigurePipeline(PipelineConfigurationContext context, VercelEnvironmentResource environment) + { + // Pipeline ordering is the main integration point with Aspire's built-in container + // support. Vercel prereq must happen before push-prereq/build/push so the resource has + // a VCR container registry annotation and linux/amd64 build target; Vercel deploy must + // then wait for the resource-specific push step. + var entries = VercelDeploymentModel.GetEntries(context.Model, environment).ToArray(); + if (entries.Length == 0) + { + return; + } + + string planStepName = $"{PublishStepNamePrefix}{environment.Name}"; + string prereqStepName = $"{DeployPrereqStepNamePrefix}{environment.Name}"; + var deploySteps = context.GetSteps(environment, "vercel-deploy").ToArray(); + var pushPrereqSteps = context.Steps + .Where(static step => string.Equals(step.Name, PushPrereqStepName, StringComparison.Ordinal)) + .ToArray(); + + foreach (var entry in entries) + { + EnsureVercelImagePushOptionsCallback(entry.Resource); + + var buildSteps = context.GetSteps(entry.Resource, WellKnownPipelineTags.BuildCompute); + foreach (var buildStep in buildSteps) + { + AddUnique(buildStep.DependsOnSteps, prereqStepName); + AddUnique(buildStep.RequiredBySteps, WellKnownPipelineSteps.Deploy); + RemoveDuplicates(buildStep.DependsOnSteps); + RemoveDuplicates(buildStep.RequiredBySteps); + } + + var pushSteps = context.GetSteps(entry.Resource, WellKnownPipelineTags.PushContainerImage).ToArray(); + foreach (var pushStep in pushSteps) + { + AddUnique(pushStep.DependsOnSteps, prereqStepName); + AddUnique(pushStep.RequiredBySteps, WellKnownPipelineSteps.Deploy); + RemoveDuplicates(pushStep.DependsOnSteps); + RemoveDuplicates(pushStep.RequiredBySteps); + } + + // Aspire's global push prerequisite validates that every pushed image has a + // registry. Vercel discovers the project-owned VCR registry through `vercel pull`, + // so that validation must run after the Vercel prereq has annotated the resources. + foreach (var pushPrereqStep in pushPrereqSteps) + { + AddUnique(pushPrereqStep.DependsOnSteps, prereqStepName); + RemoveDuplicates(pushPrereqStep.DependsOnSteps); + RemoveDuplicates(pushPrereqStep.RequiredBySteps); + } + + foreach (var deployStep in deploySteps) + { + AddUnique(deployStep.DependsOnSteps, planStepName); + foreach (var pushStep in pushSteps) + { + AddUnique(deployStep.DependsOnSteps, pushStep.Name); + } + + RemoveDuplicates(deployStep.DependsOnSteps); + RemoveDuplicates(deployStep.RequiredBySteps); + } + } + + NormalizePipelineDependencies(context); + } + + public static void NormalizePipelineDependencies(PipelineConfigurationContext context) + { + foreach (var step in context.Steps) + { + RemoveDuplicates(step.DependsOnSteps); + RemoveDuplicates(step.RequiredBySteps); + } + } + + private static async Task PrepareProjectGroupForBuiltInImagePushAsync( + PipelineStepContext context, + VercelEnvironmentResource environment, + VercelEnvironmentOptionsAnnotation options, + IVercelCliRunner runner, + IVercelContainerRegistryClient registryClient, + IReadOnlyDictionary entriesByResourceName, + VercelDeploymentProjectMap projectMap, + VercelDeploymentProjectGroup group) + { + List preparedServices = []; + foreach (var service in group.Services) + { + preparedServices.Add(service with + { + Entry = await VercelDeploymentModel.PrepareEntryAsync(context, service.Entry).ConfigureAwait(false) + }); + } + + group = group with + { + Root = preparedServices.Single(service => service.IsPublicRoot), + Services = [.. preparedServices] + }; + + var preparedRoot = group.Root.Entry; + var projectLink = VercelProjectNameResolver.GetProjectLink(preparedRoot); + var previousDeployment = await VercelDeploymentStateStore.GetPreviousAsync( + context, + environment, + options, + preparedRoot.Resource.Name, + projectLink.ProjectName).ConfigureAwait(false); + // A checked-in .vercel/project.json means the user linked an existing provider project, + // so deploy can target it but destroy must not claim ownership of it. + bool managedByAspire = !VercelProjectNameResolver.HasProjectLinkFile(preparedRoot.SourceRoot); + + // Managed projects are recorded before the image build begins. If Docker build, + // project-env configuration, or VCR push fails later, destroy/retry still has enough + // state to clean up the provider project and any tracked project env vars. + if (managedByAspire) + { + await EnsureManagedProjectAndSaveInitialStateAsync( + context, + environment, + options, + runner, + preparedRoot, + preparedRoot.SourceRoot, + projectLink, + previousDeployment).ConfigureAwait(false); + } + + var (projectContext, environmentConfigurations) = await PreparePulledProjectContextAsync( + context, + runner, + options, + group, + entriesByResourceName, + projectMap, + previousDeployment).ConfigureAwait(false); + + await LoginToVcrAsync(context, runner, projectContext.PulledProject.OidcToken, projectContext.OidcClaims).ConfigureAwait(false); + foreach (var service in group.Services) + { + await registryClient.EnsureRepositoryAsync(projectContext.PulledProject.OidcToken, projectContext.OidcClaims, service.ServiceName, context.CancellationToken).ConfigureAwait(false); + + AddVcrDeploymentAnnotations( + environment, + service, + projectLink, + projectContext, + environmentConfigurations[service.Entry.Resource.Name], + managedByAspire); + } + } + + internal static async Task<(VercelPulledProjectContext ProjectContext, IReadOnlyDictionary EnvironmentConfigurations)> PreparePulledProjectContextAsync( + PipelineStepContext context, + IVercelCliRunner runner, + VercelEnvironmentOptionsAnnotation options, + VercelDeploymentProjectGroup group, + IReadOnlyDictionary entriesByResourceName, + VercelDeploymentProjectMap projectMap, + PreviousVercelDeployment? previousDeployment = null) + { + string projectLinkDirectory = await PrepareProjectEnvironmentDirectoryAsync(context, runner, options, group.RootEntry).ConfigureAwait(false); + try + { + Dictionary environmentConfigurations = new(StringComparer.Ordinal); + foreach (var service in group.Services) + { + // Use Aspire's unprocessed values so endpoint references and secrets keep their + // graph meaning until Vercel-specific deployment translation happens. + environmentConfigurations[service.Entry.Resource.Name] = await VercelDeploymentPlanWriter.GetEnvironmentConfigurationAsync( + context.ExecutionContext, + context.Logger, + options, + service.Entry, + entriesByResourceName, + projectMap, + resolveProjectEnvironmentVariableValues: true, + context.CancellationToken).ConfigureAwait(false); + } + + ValidateProjectEnvironmentVariableCollisions(group, environmentConfigurations); + var projectEnvironmentVariables = environmentConfigurations + .SelectMany(static item => item.Value.ProjectEnvironmentVariables) + .GroupBy(static variable => variable.Key, StringComparer.Ordinal) + .Select(static group => group.First()) + .ToArray(); + + // Vercel resolves project env vars during the provider-side build/runtime. + // Configure secret-bearing values before deploy; non-secret per-deployment + // values remain on `vercel deploy --env`. + await ConfigureProjectEnvironmentVariablesAsync( + context, + runner, + options, + group.RootEntry, + projectLinkDirectory, + projectEnvironmentVariables, + previousDeployment).ConfigureAwait(false); + + // The VCR image is not enough for deployment. Vercel deploy consumes Build + // Output API metadata tied to a project, and VCR auth is minted by `vercel pull` + // for that linked project. Keep that link/pull in scratch space so the source + // root never receives provider metadata or pulled env files. + var pulledProject = await PullProjectSettingsAsync(context, runner, options, group.RootEntry, projectLinkDirectory).ConfigureAwait(false); + var oidcClaims = VercelOidcToken.DecodeUnvalidatedClaims(pulledProject.OidcToken); + + return (new(pulledProject, oidcClaims), environmentConfigurations); + } + finally + { + // `vercel pull` can write provider-owned env/config files into the linked + // directory. They may contain project secrets unrelated to the AppHost, so the + // scratch link is never copied, logged, stored in state, or left on disk. + if (Directory.Exists(projectLinkDirectory)) + { + Directory.Delete(projectLinkDirectory, recursive: true); + } + } + } + + private static async Task DeployPrebuiltOutputAsync( + PipelineStepContext context, + IVercelCliRunner runner, + VercelEnvironmentOptionsAnnotation options, + VercelDeploymentProjectGroup group, + VercelPreparedDeploymentAnnotation rootDeployment, + IReadOnlyList resolvedDeployments) + { + // The generated Build Output API tree is the provider-owned deploy artifact. + // It points at the immutable VCR digest and is written to temp/output storage so + // `vercel deploy --prebuilt` uploads metadata only, not a staged source tree. + await VercelBuildOutputWriter.WriteAsync(group, rootDeployment.ProjectContext.PulledProject, resolvedDeployments, context.CancellationToken).ConfigureAwait(false); + + var result = await runner.DeployPrebuiltAsync( + options, + group.RootEntry.DeployDirectory, + rootDeployment.ProjectLink.ProjectId ?? rootDeployment.ProjectLink.ProjectName, + [], + context.CancellationToken).ConfigureAwait(false); + if (!result.Succeeded) + { + throw CreateCliException($"deploy prebuilt project root '{group.RootEntry.Resource.Name}' to Vercel", VercelCliFileName, result); + } + + var deploymentResult = VercelCliOutputParser.GetDeploymentResult(result.StandardOutput); + await VerifyDeploymentAsync(context, runner, options, group.RootEntry.Resource.Name, deploymentResult).ConfigureAwait(false); + + return deploymentResult; + } + + private static void AddVcrDeploymentAnnotations( + VercelEnvironmentResource environment, + VercelDeploymentService service, + VercelProjectLink projectLink, + VercelPulledProjectContext projectContext, + VercelEnvironmentConfiguration environmentConfiguration, + bool managedByAspire) + { + var entry = service.Entry; + EnsureVercelImagePushOptionsCallback(entry.Resource); + + var claims = projectContext.OidcClaims; + if (string.IsNullOrWhiteSpace(claims.Owner) + || string.IsNullOrWhiteSpace(claims.Project)) + { + throw new DistributedApplicationException("The Vercel OIDC token did not include the owner and project claims required to construct a VCR image reference."); + } + + RemoveAnnotations(entry.Resource); + RemoveAnnotations( + entry.Resource, + annotation => ReferenceEquals(annotation.DeploymentTarget, environment)); + + string repository = $"{claims.Owner}/{claims.Project}"; + string tag = $"aspire-{Guid.NewGuid():N}"; + string taggedImageReference = $"{VcrRegistry}/{repository}/{service.ServiceName}:{tag}"; + var registry = new ContainerRegistryResource( + $"{environment.Name}-{entry.Resource.Name}-vcr", + ReferenceExpression.Create($"{VcrRegistry}"), + ReferenceExpression.Create($"{repository}")); + + entry.Resource.Annotations.Add(new DeploymentTargetAnnotation(environment) + { + ComputeEnvironment = entry.Resource.GetComputeEnvironment() ?? environment, + ContainerRegistry = registry + }); + + entry.Resource.Annotations.Add(new VercelPreparedDeploymentAnnotation( + entry, + service.ServiceName, + projectLink, + projectContext, + environmentConfiguration, + managedByAspire, + service.ServiceName, + tag, + taggedImageReference)); + } + + private static void EnsureVercelImagePushOptionsCallback(IResource resource) + { + if (resource.Annotations.OfType().Any()) + { + return; + } + + resource.Annotations.Add(new VercelImagePushOptionsCallbackAnnotation()); + resource.Annotations.Add(new ContainerBuildOptionsCallbackAnnotation(context => + { + // Vercel's container runtime requires linux/amd64. Force the shared Aspire + // build path to produce that platform even when the AppHost runs on ARM hosts. + context.TargetPlatform = ContainerTargetPlatform.LinuxAmd64; + })); + resource.Annotations.Add(new ContainerImagePushOptionsCallbackAnnotation(context => + { + var preparedDeployment = GetPreparedDeployment(context.Resource); + if (preparedDeployment is null) + { + throw new DistributedApplicationException($"Resource '{context.Resource.Name}' was not prepared with Vercel Container Registry image push settings."); + } + + context.Options.RemoteImageName = preparedDeployment.RemoteImageName; + context.Options.RemoteImageTag = preparedDeployment.RemoteImageTag; + })); + } + + private static VercelPreparedDeploymentAnnotation? GetPreparedDeployment(IResource resource) + => resource.Annotations.OfType().LastOrDefault(); + + private static async Task ResolvePushedImageDigestAsync( + PipelineStepContext context, + IVercelCliRunner runner, + VercelPreparedDeploymentAnnotation preparedDeployment) + { + // Re-login before inspection for the same project-scoped VCR-token reason as push: + // Docker credentials are keyed by vcr.vercel.com, while the OIDC token is scoped to + // one Vercel project/repository. + await LoginToVcrAsync( + runner, + preparedDeployment.ProjectContext.PulledProject.OidcToken, + preparedDeployment.ProjectContext.OidcClaims, + context.CancellationToken).ConfigureAwait(false); + + var result = await runner.InspectDockerImageDigestAsync(preparedDeployment.TaggedImageReference, context.CancellationToken).ConfigureAwait(false); + if (!result.Succeeded) + { + throw CreateCliException($"resolve pushed VCR image digest for resource '{preparedDeployment.Entry.Resource.Name}'", DockerCliFileName, result); + } + + string digest = VercelDockerImageDigestParser.GetDigest(result.StandardOutput); + string digestReference = preparedDeployment.TaggedImageReference[..preparedDeployment.TaggedImageReference.LastIndexOf(':')] + $"@{digest}"; + return new(digestReference, digest); + } + + private static VercelDeploymentStateEntry CreateSuccessfulDeploymentStateEntry( + VercelDeploymentProjectGroup group, + VercelProjectLink projectLink, + VercelPulledProjectContext projectContext, + VercelDeploymentResult deploymentResult, + IReadOnlyList resolvedDeployments, + bool managedByAspire, + string? productionUrl) + // State stores ownership, provider IDs/URLs, image digest, and env var names only. + // Never persist secret values or temp project-link files from `vercel pull`. + => new( + group.RootEntry.Resource.Name, + projectLink.ProjectName, + projectLink.ProjectId ?? projectContext.PulledProject.ProjectId, + deploymentResult.DeploymentId, + deploymentResult.DeploymentUrl, + group.RootEntry.SourceRoot, + managedByAspire) + { + ProductionUrl = productionUrl, + VcrImageDigest = resolvedDeployments.Single(resolved => resolved.PreparedDeployment.Entry.Resource.Name == group.RootEntry.Resource.Name).Image.Digest, + Services = [.. resolvedDeployments + .Select(static resolved => new VercelServiceDeploymentStateEntry( + resolved.PreparedDeployment.Entry.Resource.Name, + resolved.PreparedDeployment.ServiceName, + resolved.Image.Digest)) + .OrderBy(static service => service.ServiceName, StringComparer.Ordinal)], + BuildOutputApiVersion = VercelConstants.BuildOutputApiVersion, + ProjectEnvironmentVariables = [.. resolvedDeployments + .SelectMany(static resolved => resolved.PreparedDeployment.EnvironmentConfiguration.ProjectEnvironmentVariables) + .Select(static variable => variable.Key) + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal)] + }; + + private static void AddDeploymentSummary( + PipelineStepContext context, + string resourceName, + VercelDeploymentResult deploymentResult, + string? productionUrl) + { + context.Summary.Add($"{resourceName} Vercel deployment", deploymentResult.DeploymentUrl); + if (productionUrl is not null) + { + context.Summary.Add($"{resourceName} Vercel production URL", productionUrl); + } + } + + private static void AddUnique(ICollection values, string value) + { + if (!values.Contains(value, StringComparer.Ordinal)) + { + values.Add(value); + } + } + + private static void RemoveDuplicates(ICollection values) + { + string[] distinctValues = values.Distinct(StringComparer.Ordinal).ToArray(); + if (distinctValues.Length == values.Count) + { + return; + } + + values.Clear(); + foreach (string value in distinctValues) + { + values.Add(value); + } + } + + private static void RemoveAnnotations(IResource resource, Func? predicate = null) + where TAnnotation : IResourceAnnotation + { + foreach (var annotation in resource.Annotations.OfType().Where(annotation => predicate?.Invoke(annotation) ?? true).ToArray()) + { + resource.Annotations.Remove(annotation); + } + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDockerImageDigestParser.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDockerImageDigestParser.cs new file mode 100644 index 000000000..47165506a --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDockerImageDigestParser.cs @@ -0,0 +1,111 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using Aspire.Hosting; + +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +/// +/// Extracts the Vercel-compatible image manifest digest from Docker inspect output so Build +/// Output API deployments reference immutable VCR images instead of mutable tags or OCI indexes. +/// +internal static class VercelDockerImageDigestParser +{ + public static string GetDigest(string output) + { + string trimmed = output.Trim(); + if (trimmed.StartsWith("\"", StringComparison.Ordinal)) + { + try + { + // `docker buildx imagetools inspect --format '{{json .Digest}}'` style output + // is a JSON string. Older experiments used this shape before Vercel required + // selecting the concrete linux/amd64 manifest from the OCI index. + string? digest = JsonSerializer.Deserialize(trimmed); + if (IsSha256Digest(digest)) + { + return digest!; + } + } + catch (JsonException ex) + { + throw new DistributedApplicationException("Docker returned invalid JSON while resolving the pushed VCR image digest.", ex); + } + } + + if (trimmed.StartsWith("{", StringComparison.Ordinal)) + { + try + { + // Current path uses `--format '{{json .Manifest}}'`. Docker may return an OCI + // image index with a `manifests[]` array, or a single manifest object. Vercel's + // Container Images docs describe VCR-backed OCI images; live smoke tests rejected + // index digests, so prefer the linux/amd64 child. + // See https://vercel.com/docs/functions/container-images. + var manifestOutput = JsonSerializer.Deserialize(trimmed); + if (manifestOutput?.Manifests is { Length: > 0 } manifests) + { + foreach (var manifest in manifests) + { + if (string.Equals(manifest.Platform?.Os, "linux", StringComparison.OrdinalIgnoreCase) + && string.Equals(manifest.Platform?.Architecture, "amd64", StringComparison.OrdinalIgnoreCase) + && IsSha256Digest(manifest.Digest)) + { + return manifest.Digest!; + } + } + + throw new DistributedApplicationException("Docker did not return a linux/amd64 manifest digest for the pushed VCR image. Vercel requires linux/amd64 container images."); + } + + if (IsSha256Digest(manifestOutput?.Digest)) + { + return manifestOutput!.Digest!; + } + } + catch (JsonException ex) + { + throw new DistributedApplicationException("Docker returned invalid JSON while resolving the pushed VCR image digest.", ex); + } + } + + var match = Regex.Match(trimmed, @"sha256:[a-fA-F0-9]{64}", RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(100)); + if (match.Success) + { + return match.Value; + } + + throw new DistributedApplicationException($"Docker did not return a valid sha256 image digest. Output: {VercelCliOutputParser.GetTrimmedOutput(output)}"); + } + + private static bool IsSha256Digest([NotNullWhen(true)] string? value) + => value is not null && Regex.IsMatch(value, "^sha256:[a-f0-9]{64}$", RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(100)); + + private sealed class DockerManifestOutput + { + [JsonPropertyName("manifests")] + public DockerManifest[]? Manifests { get; init; } + + [JsonPropertyName("digest")] + public string? Digest { get; init; } + } + + private sealed class DockerManifest + { + [JsonPropertyName("digest")] + public string? Digest { get; init; } + + [JsonPropertyName("platform")] + public DockerManifestPlatform? Platform { get; init; } + } + + private sealed class DockerManifestPlatform + { + [JsonPropertyName("os")] + public string? Os { get; init; } + + [JsonPropertyName("architecture")] + public string? Architecture { get; init; } + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDotEnvParser.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDotEnvParser.cs new file mode 100644 index 000000000..52867776f --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelDotEnvParser.cs @@ -0,0 +1,54 @@ +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +/// +/// Parses the limited dotenv syntax emitted by vercel pull so deploy can read the +/// short-lived OIDC token without taking a dependency on user-authored dotenv semantics. +/// +internal static class VercelDotEnvParser +{ + public static Dictionary Parse(IEnumerable lines) + { + // Vercel writes dotenv files such as `.vercel/.env.production.local` during pull. + // We only need the VERCEL_OIDC_TOKEN line. This intentionally supports the subset the + // CLI emits: comments/blank lines, KEY=value, single/double quoted values, and common + // backslash escapes. It is not a general dotenv evaluator with interpolation. + // See https://vercel.com/docs/cli/pull. + Dictionary values = new(StringComparer.Ordinal); + + foreach (string rawLine in lines) + { + string line = rawLine.Trim(); + if (line.Length == 0 || line[0] == '#') + { + continue; + } + + int separator = line.IndexOf('='); + if (separator <= 0) + { + continue; + } + + string key = line[..separator].Trim(); + string value = line[(separator + 1)..].Trim(); + values[key] = UnquoteValue(value); + } + + return values; + } + + private static string UnquoteValue(string value) + { + if (value.Length >= 2 + && ((value[0] == '"' && value[^1] == '"') + || (value[0] == '\'' && value[^1] == '\''))) + { + value = value[1..^1]; + } + + return value.Replace("\\n", "\n", StringComparison.Ordinal) + .Replace("\\r", "\r", StringComparison.Ordinal) + .Replace("\\\"", "\"", StringComparison.Ordinal) + .Replace("\\\\", "\\", StringComparison.Ordinal); + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentMapper.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentMapper.cs new file mode 100644 index 000000000..842fe53f1 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentMapper.cs @@ -0,0 +1,526 @@ +#pragma warning disable ASPIRECOMPUTE003 + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Publishing; +using Aspire.Hosting; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; + +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +/// +/// Converts Aspire execution environment values into Vercel deploy-time and project-scoped +/// environment variables, preserving secret handling and rejecting unsupported references early. +/// +internal static class VercelEnvironmentMapper +{ + public static async Task GetConfigurationAsync( + IResource resource, + VercelEnvironmentOptionsAnnotation options, + IExecutionConfigurationResult executionConfiguration, + IReadOnlyDictionary entriesByResourceName, + VercelDeploymentProjectMap? projectMap, + bool resolveProjectEnvironmentVariableValues, + CancellationToken cancellationToken) + { + List> deploymentEnvironmentVariables = []; + List> projectEnvironmentVariables = []; + List serviceBindings = []; + HashSet names = new(StringComparer.Ordinal); + + // This is the env-var edge-case boundary. Vercel has deployment env args and + // project env secrets, but no Aspire-style connection binding/service-discovery + // object in this preview. Only deterministic production endpoint URLs are projected; + // other resource references fail here instead of becoming misleading strings. + // Keep a single pass over Aspire's unprocessed environment dictionary. The processed + // value can contain publish-mode manifest expressions, but deployment needs the + // original graph value so it can choose Vercel's concrete URL/secret mechanism. + foreach (var environmentVariable in executionConfiguration.EnvironmentVariablesWithUnprocessed) + { + string name = environmentVariable.Key; + object unprocessedValue = environmentVariable.Value.Item1; + string value = environmentVariable.Value.Item2; + + ValidateEnvironmentVariableName(resource, name); + if (!names.Add(name)) + { + throw new DistributedApplicationException( + $"Resource '{resource.Name}' configures environment variable '{name}' more than once. Vercel project environment variable names must be unique."); + } + + if (TryCreateServiceBinding(resource, name, projectMap, unprocessedValue, out var serviceBinding)) + { + serviceBindings.Add(serviceBinding); + continue; + } + + if (ContainsUnsupportedResourceReference(resource, unprocessedValue)) + { + throw new DistributedApplicationException( + $"Environment variable '{name}' for resource '{resource.Name}' references another Aspire resource or service in a way that cannot be represented as a Vercel deployment URL. Use endpoint references to Vercel production workloads, or configure the value in Vercel project environment variables."); + } + + // Non-secrets can be written into the generated per-service Build Output config; + // secret-bearing values must use Vercel project environment variables so values + // never appear in generated artifacts or CLI arguments. + // See https://vercel.com/docs/environment-variables/sensitive-environment-variables. + bool containsSecret = ContainsSecretReference(unprocessedValue); + if (containsSecret) + { + value = resolveProjectEnvironmentVariableValues + ? await GetProjectEnvironmentVariableValueAsync( + resource, + options, + entriesByResourceName, + projectMap, + unprocessedValue, + value, + cancellationToken).ConfigureAwait(false) + : ""; + } + else if (TryGetEnvironmentVariableValue(resource, options, entriesByResourceName, projectMap, unprocessedValue, out string? vercelValue)) + { + value = vercelValue; + } + + if (containsSecret) + { + projectEnvironmentVariables.Add(new(name, value)); + } + else + { + deploymentEnvironmentVariables.Add(new(name, value)); + } + } + + return new(deploymentEnvironmentVariables, projectEnvironmentVariables, serviceBindings); + } + + public static IEnumerable GetReferencedResourceNames( + IResource resource, + IExecutionConfigurationResult executionConfiguration) + { + foreach (var environmentVariable in executionConfiguration.EnvironmentVariablesWithUnprocessed) + { + foreach (string resourceName in GetReferencedResourceNames(resource, environmentVariable.Value.Item1)) + { + yield return resourceName; + } + } + + foreach (var argument in executionConfiguration.ArgumentsWithUnprocessed) + { + foreach (string resourceName in GetReferencedResourceNames(resource, argument.Item1)) + { + yield return resourceName; + } + } + } + + public static void ValidateUnsupportedRuntimeConfiguration( + IResource resource, + IExecutionConfigurationResult executionConfiguration) + { + // These Aspire concepts have no faithful Vercel Dockerfile-deploy equivalent in this + // preview. Rejecting them is safer than silently dropping entrypoint, args, or build + // values that would change the workload's deployed behavior. Aspire's built-in + // image build/push pipeline owns build-time Docker options; this validation only + // rejects runtime concepts the Vercel Build Output API path cannot preserve. + if (resource is ContainerResource { Entrypoint: not null }) + { + throw new DistributedApplicationException( + $"Resource '{resource.Name}' configures a container entrypoint, but Vercel Dockerfile deployments use the CMD/ENTRYPOINT from Aspire's publish output. Configure the workload's publish behavior or Vercel project settings instead."); + } + + if (executionConfiguration.ArgumentsWithUnprocessed.Any()) + { + throw new DistributedApplicationException( + $"Resource '{resource.Name}' configures Aspire command-line arguments, but Vercel Dockerfile deployments cannot override Docker CMD/ENTRYPOINT. Configure the workload's publish behavior or express the values as environment variables."); + } + } + + private static async ValueTask GetProjectEnvironmentVariableValueAsync( + IResource resource, + VercelEnvironmentOptionsAnnotation options, + IReadOnlyDictionary entriesByResourceName, + VercelDeploymentProjectMap? projectMap, + object? value, + string processedValue, + CancellationToken cancellationToken) + { + // This path resolves values for `vercel env add`, not `vercel deploy --env`. + // Values can be secret-bearing because they are sent on stdin to Vercel's secret + // store; they must not be copied into publish plans, command lines, or state. + // See https://vercel.com/docs/cli/env. + switch (value) + { + case null: + return processedValue; + case string stringValue: + return stringValue; + case ParameterResource parameter: + return await GetParameterValueAsync(parameter, cancellationToken).ConfigureAwait(false); + case IResourceBuilder parameterBuilder: + return await GetParameterValueAsync(parameterBuilder.Resource, cancellationToken).ConfigureAwait(false); + case IResourceWithConnectionString connectionStringResource: + return await GetValueProviderValueAsync(connectionStringResource.ConnectionStringExpression, $"connection string for resource '{connectionStringResource.Name}'", cancellationToken).ConfigureAwait(false); + case IResourceBuilder connectionStringBuilder: + return await GetValueProviderValueAsync(connectionStringBuilder.Resource.ConnectionStringExpression, $"connection string for resource '{connectionStringBuilder.Resource.Name}'", cancellationToken).ConfigureAwait(false); + case ReferenceExpression referenceExpression: + return await GetProjectReferenceExpressionValueAsync(resource, options, entriesByResourceName, projectMap, referenceExpression, cancellationToken).ConfigureAwait(false); + case IValueProvider valueProvider: + return await GetValueProviderValueAsync(valueProvider, "environment variable value", cancellationToken).ConfigureAwait(false); + default: + return processedValue; + } + } + + private static async ValueTask GetProjectReferenceExpressionValueAsync( + IResource resource, + VercelEnvironmentOptionsAnnotation options, + IReadOnlyDictionary entriesByResourceName, + VercelDeploymentProjectMap? projectMap, + ReferenceExpression referenceExpression, + CancellationToken cancellationToken) + { + if (referenceExpression.IsConditional) + { + throw new DistributedApplicationException("Vercel project environment variables do not support conditional reference expressions. Configure a concrete Vercel project environment variable instead."); + } + + var arguments = new object?[referenceExpression.ValueProviders.Count]; + for (int i = 0; i < referenceExpression.ValueProviders.Count; i++) + { + IValueProvider valueProvider = referenceExpression.ValueProviders[i]; + arguments[i] = valueProvider switch + { + // Secret-bearing project env vars may combine endpoint URLs with secret + // parameters because the final value is sent through Vercel's secret path. + EndpointReference endpointReference when IsCrossResourceEndpointReference(resource, endpointReference) => GetEndpointPropertyValue(resource, options, entriesByResourceName, projectMap, endpointReference.Property(EndpointProperty.Url)), + EndpointReferenceExpression endpointReferenceExpression when IsCrossResourceEndpointReference(resource, endpointReferenceExpression.Endpoint) => GetEndpointPropertyValue(resource, options, entriesByResourceName, projectMap, endpointReferenceExpression), + ParameterResource parameter => await GetParameterValueAsync(parameter, cancellationToken).ConfigureAwait(false), + IResourceWithConnectionString connectionStringResource => await GetValueProviderValueAsync(connectionStringResource.ConnectionStringExpression, $"connection string for resource '{connectionStringResource.Name}'", cancellationToken).ConfigureAwait(false), + _ => await GetValueProviderValueAsync(valueProvider, "reference expression value", cancellationToken).ConfigureAwait(false) + }; + + if (referenceExpression.StringFormats[i] is "uri" && arguments[i] is string stringValue) + { + arguments[i] = Uri.EscapeDataString(stringValue); + } + } + + return string.Format(CultureInfo.InvariantCulture, referenceExpression.Format, arguments); + } + + private static async ValueTask GetParameterValueAsync(ParameterResource parameter, CancellationToken cancellationToken) + { + try + { + string? value = await parameter.GetValueAsync(cancellationToken).ConfigureAwait(false); + return value ?? throw new DistributedApplicationException($"Secret parameter '{parameter.Name}' did not produce a value for Vercel project environment configuration."); + } + catch (MissingParameterValueException ex) + { + throw new DistributedApplicationException($"Secret parameter '{parameter.Name}' does not have a value. Provide a value before deploying to Vercel.", ex); + } + } + + private static async ValueTask GetValueProviderValueAsync(IValueProvider valueProvider, string description, CancellationToken cancellationToken) + { + try + { + string? value = await valueProvider.GetValueAsync(cancellationToken).ConfigureAwait(false); + return value ?? throw new DistributedApplicationException($"The {description} did not produce a value for Vercel project environment configuration."); + } + catch (MissingParameterValueException ex) + { + throw new DistributedApplicationException($"The {description} does not have a value. Provide a value before deploying to Vercel.", ex); + } + } + + private static bool TryGetEnvironmentVariableValue( + IResource resource, + VercelEnvironmentOptionsAnnotation options, + IReadOnlyDictionary entriesByResourceName, + VercelDeploymentProjectMap? projectMap, + object? value, + [NotNullWhen(true)] out string? vercelValue) + { + // Service-discovery env vars generated by WithReference also arrive as structured + // endpoint values, so translate by value shape instead of by environment variable name. + switch (value) + { + case EndpointReference endpointReference when IsCrossResourceEndpointReference(resource, endpointReference): + vercelValue = GetEndpointPropertyValue(resource, options, entriesByResourceName, projectMap, endpointReference.Property(EndpointProperty.Url)); + return true; + case EndpointReferenceExpression endpointReferenceExpression when IsCrossResourceEndpointReference(resource, endpointReferenceExpression.Endpoint): + vercelValue = GetEndpointPropertyValue(resource, options, entriesByResourceName, projectMap, endpointReferenceExpression); + return true; + case ReferenceExpression referenceExpression when ContainsCrossResourceEndpointReference(resource, referenceExpression): + vercelValue = GetReferenceExpressionValue(resource, options, entriesByResourceName, projectMap, referenceExpression); + return true; + default: + vercelValue = null; + return false; + } + } + + private static string GetReferenceExpressionValue( + IResource resource, + VercelEnvironmentOptionsAnnotation options, + IReadOnlyDictionary entriesByResourceName, + VercelDeploymentProjectMap? projectMap, + ReferenceExpression referenceExpression) + { + if (referenceExpression.IsConditional) + { + throw new DistributedApplicationException("Vercel endpoint references do not support conditional reference expressions. Configure a concrete Vercel project environment variable instead."); + } + + var arguments = new object?[referenceExpression.ValueProviders.Count]; + for (int i = 0; i < referenceExpression.ValueProviders.Count; i++) + { + IValueProvider valueProvider = referenceExpression.ValueProviders[i]; + arguments[i] = valueProvider switch + { + EndpointReference endpointReference when IsCrossResourceEndpointReference(resource, endpointReference) => GetEndpointPropertyValue(resource, options, entriesByResourceName, projectMap, endpointReference.Property(EndpointProperty.Url)), + EndpointReferenceExpression endpointReferenceExpression when IsCrossResourceEndpointReference(resource, endpointReferenceExpression.Endpoint) => GetEndpointPropertyValue(resource, options, entriesByResourceName, projectMap, endpointReferenceExpression), + // Mixed expressions can hide provider-specific ordering or secret semantics. + // Keep this path to deterministic endpoint-only production URLs. + _ => throw new DistributedApplicationException("Vercel endpoint reference expressions cannot be combined with parameters, secrets, or other value providers. Configure a concrete Vercel project environment variable instead.") + }; + + if (referenceExpression.StringFormats[i] is "uri" && arguments[i] is string stringValue) + { + arguments[i] = Uri.EscapeDataString(stringValue); + } + } + + return string.Format(CultureInfo.InvariantCulture, referenceExpression.Format, arguments); + } + + private static string GetEndpointPropertyValue( + IResource resource, + VercelEnvironmentOptionsAnnotation options, + IReadOnlyDictionary entriesByResourceName, + VercelDeploymentProjectMap? projectMap, + EndpointReferenceExpression endpointReferenceExpression) + { + // This is the endpoint-reference edge-case boundary: preview/custom URLs are + // post-deploy outputs, internal endpoints have no public Vercel edge address, and + // cross-environment references lack a stable same-deploy alias. Fail before values + // are written to Vercel env vars. + // See https://vercel.com/docs/deployments/generated-urls. + if (!options.Production) + { + throw new DistributedApplicationException( + "Vercel endpoint references require production deployments because preview and custom target URLs are assigned by Vercel after deployment. Call WithVercelProductionDeployments on the Vercel environment, or remove the reference."); + } + + var endpointReference = endpointReferenceExpression.Endpoint; + var endpoint = endpointReference.EndpointAnnotation; + if (projectMap?.AreInSameProject(resource.Name, endpointReference.Resource.Name) == true) + { + throw new DistributedApplicationException( + $"Resource '{resource.Name}' references endpoint '{endpoint.Name}' on resource '{endpointReference.Resource.Name}' inside the same Vercel project. Same-project references must be injected as Vercel service bindings and can only be used as direct URL-valued environment variables."); + } + + if (!endpoint.IsExternal) + { + throw new DistributedApplicationException( + $"Resource '{resource.Name}' references endpoint '{endpoint.Name}' on resource '{endpointReference.Resource.Name}', but Vercel endpoint references can only target external HTTP or HTTPS endpoints. Configure an external endpoint or remove the reference."); + } + + if (!VercelDeploymentModel.IsHttpEndpoint(endpoint)) + { + throw new DistributedApplicationException( + $"Resource '{resource.Name}' references endpoint '{endpoint.Name}' on resource '{endpointReference.Resource.Name}' with scheme '{endpoint.UriScheme}', but Vercel endpoint references support only HTTP or HTTPS endpoints."); + } + + if (!entriesByResourceName.TryGetValue(endpointReference.Resource.Name, out var referencedEntry)) + { + throw new DistributedApplicationException( + $"Resource '{resource.Name}' references endpoint '{endpoint.Name}' on resource '{endpointReference.Resource.Name}', but the referenced resource does not target this Vercel environment. Vercel endpoint references can only target workloads deployed to the same Vercel environment."); + } + + string host = $"{VercelProjectNameResolver.GetProjectName(referencedEntry)}.vercel.app"; + const int port = 443; + + return endpointReferenceExpression.Property switch + { + // Production aliases are deterministic before deploy; preview/custom URLs are not. + // Keep endpoint references on the stable caller-visible Vercel HTTPS surface. + EndpointProperty.Url => $"https://{host}", + EndpointProperty.Host or EndpointProperty.IPV4Host => host, + EndpointProperty.Port => port.ToString(CultureInfo.InvariantCulture), + EndpointProperty.TargetPort => endpoint.TargetPort is int targetPort + ? targetPort.ToString(CultureInfo.InvariantCulture) + : throw new DistributedApplicationException( + // Azure publishers can carry ContainerPortReference placeholders in + // Bicep/Helm. Vercel deploy receives concrete CLI env values, so an + // unresolved TargetPort would become a bogus string rather than a + // target-native reference. + $"Resource '{resource.Name}' references endpoint property '{EndpointProperty.TargetPort}' for endpoint '{endpoint.Name}' on resource '{endpointReference.Resource.Name}', but the endpoint does not define an explicit target port. Configure a target port or avoid passing TargetPort to Vercel."), + EndpointProperty.Scheme => "https", + EndpointProperty.HostAndPort => $"{host}:{port.ToString(CultureInfo.InvariantCulture)}", + EndpointProperty.TlsEnabled => bool.TrueString, + _ => throw new DistributedApplicationException($"The endpoint property '{endpointReferenceExpression.Property}' is not supported for Vercel endpoint references.") + }; + } + + private static void ValidateEnvironmentVariableName(IResource resource, string name) + { + // Do not remap names to satisfy Vercel's env var shape. A lossy rename would break + // the consuming workload's contract, so invalid names fail with the original key. + if (string.IsNullOrWhiteSpace(name) + || (!char.IsAsciiLetter(name[0]) && name[0] != '_') + || name.Any(static character => !char.IsAsciiLetterOrDigit(character) && character != '_')) + { + throw new DistributedApplicationException( + $"Resource '{resource.Name}' configures invalid Vercel environment variable name '{name}'. Use letters, digits, and underscores, and start with a letter or underscore."); + } + } + + private static bool TryCreateServiceBinding( + IResource resource, + string environmentVariableName, + VercelDeploymentProjectMap? projectMap, + object? value, + [NotNullWhen(true)] out VercelServiceBinding? binding) + { + binding = null; + if (projectMap is null) + { + return false; + } + + EndpointReferenceExpression? endpointReferenceExpression = value switch + { + EndpointReference reference => reference.Property(EndpointProperty.Url), + EndpointReferenceExpression expression => expression, + _ => null + }; + + if (endpointReferenceExpression is null) + { + return false; + } + + var endpointReference = endpointReferenceExpression.Endpoint; + if (!IsCrossResourceEndpointReference(resource, endpointReference) + || !projectMap.AreInSameProject(resource.Name, endpointReference.Resource.Name)) + { + return false; + } + + var endpoint = endpointReference.EndpointAnnotation; + if (!VercelDeploymentModel.IsHttpEndpoint(endpoint)) + { + throw new DistributedApplicationException($"Resource '{resource.Name}' references endpoint '{endpoint.Name}' on resource '{endpointReference.Resource.Name}' with scheme '{endpoint.UriScheme}', but Vercel service bindings support only HTTP or HTTPS endpoints."); + } + + if (endpointReferenceExpression.Property != EndpointProperty.Url) + { + throw new DistributedApplicationException($"Resource '{resource.Name}' references endpoint property '{endpointReferenceExpression.Property}' for same-project Vercel service '{endpointReference.Resource.Name}', but Vercel service bindings can only inject URL values."); + } + + if (!projectMap.TryGetService(endpointReference.Resource.Name, out var targetService)) + { + throw new DistributedApplicationException($"Resource '{resource.Name}' references resource '{endpointReference.Resource.Name}', but the referenced resource is not part of a Vercel project group."); + } + + binding = new(environmentVariableName, targetService.ServiceName); + return true; + } + + private static bool ContainsSecretReference(object? value) + { + // Connection strings are treated as secret-bearing even when the underlying provider + // does not mark each segment secret; Vercel should receive them through project env. + return value switch + { + null => false, + string => false, + ParameterResource parameter => parameter.Secret, + IResourceBuilder parameterBuilder => parameterBuilder.Resource.Secret, + IResourceWithConnectionString => true, + IResourceBuilder => true, + IValueWithReferences valueWithReferences => valueWithReferences.References.Any(ContainsSecretReference), + _ => false + }; + } + + private static bool ContainsCrossResourceEndpointReference(IResource resource, object? value) + { + return value switch + { + null => false, + EndpointReference endpointReference => IsCrossResourceEndpointReference(resource, endpointReference), + EndpointReferenceExpression endpointReferenceExpression => IsCrossResourceEndpointReference(resource, endpointReferenceExpression.Endpoint), + IValueWithReferences valueWithReferences => valueWithReferences.References.Any(reference => ContainsCrossResourceEndpointReference(resource, reference)), + _ => false + }; + } + + private static bool IsCrossResourceEndpointReference(IResource resource, EndpointReference endpointReference) + => !IsSameResource(resource, endpointReference.Resource); + + private static bool ContainsUnsupportedResourceReference(IResource resource, object? value) + { + // Vercel only knows how to turn endpoint references into deterministic production + // aliases. Other resource references can represent connection strings, parameters, + // or custom values that need a target-native mechanism this preview does not have. + return value switch + { + null => false, + string => false, + ParameterResource => false, + IResourceBuilder => false, + EndpointReference => false, + EndpointReferenceExpression => false, + IResource referencedResource => !IsSameResource(resource, referencedResource), + IValueWithReferences valueWithReferences => valueWithReferences.References.Any(reference => ContainsUnsupportedResourceReference(resource, reference)), + IResourceBuilder resourceBuilder => !IsSameResource(resource, resourceBuilder.Resource), + _ => false + }; + } + + private static bool IsSameResource(IResource resource, IResource otherResource) + // Compare by Aspire resource name rather than object identity. Polyglot/ATS flows + // can recreate resource references across an RPC boundary, but resource name is the + // app-model identity used by deployment target maps. + => string.Equals(resource.Name, otherResource.Name, StringComparison.Ordinal); + + private static IEnumerable GetReferencedResourceNames(IResource resource, object? value) + { + switch (value) + { + case null: + case string: + case ParameterResource: + case IResourceBuilder: + yield break; + case EndpointReference endpointReference when IsCrossResourceEndpointReference(resource, endpointReference): + yield return endpointReference.Resource.Name; + yield break; + case EndpointReferenceExpression endpointReferenceExpression when IsCrossResourceEndpointReference(resource, endpointReferenceExpression.Endpoint): + yield return endpointReferenceExpression.Endpoint.Resource.Name; + yield break; + case IResource referencedResource when !IsSameResource(resource, referencedResource): + yield return referencedResource.Name; + yield break; + case IResourceBuilder resourceBuilder when !IsSameResource(resource, resourceBuilder.Resource): + yield return resourceBuilder.Resource.Name; + yield break; + case IValueWithReferences valueWithReferences: + foreach (var reference in valueWithReferences.References) + { + foreach (string resourceName in GetReferencedResourceNames(resource, reference)) + { + yield return resourceName; + } + } + break; + } + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentOptionsAnnotation.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentOptionsAnnotation.cs new file mode 100644 index 000000000..374796f77 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentOptionsAnnotation.cs @@ -0,0 +1,16 @@ +using Aspire.Hosting.ApplicationModel; + +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +/// +/// Stores Vercel environment-level options on the Aspire resource model so pipeline steps can +/// consistently apply scope, production, and custom-target behavior. +/// +internal sealed record VercelEnvironmentOptionsAnnotation : IResourceAnnotation +{ + public string? Scope { get; init; } + + public string? Target { get; init; } + + public bool Production { get; init; } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResource.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResource.cs new file mode 100644 index 000000000..b0bc6dfa7 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResource.cs @@ -0,0 +1,97 @@ +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting; +using CommunityToolkit.Aspire.Hosting.Vercel; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Net.Sockets; + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Represents the Aspire compute environment for Vercel Dockerfile deployments. The resource +/// exists to attach publish/deploy pipeline steps and to model Vercel production aliases for +/// endpoint references. +/// +/// The Aspire resource name for the Vercel environment. +[Experimental("CTASPIREVERCEL001")] +[AspireExport(ExposeProperties = true)] +public sealed class VercelEnvironmentResource(string name) : Resource(name), IComputeEnvironmentResource +{ + /// + [Experimental("ASPIRECOMPUTE002", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public ReferenceExpression GetHostAddressExpression(EndpointReference endpointReference) + { + ArgumentNullException.ThrowIfNull(endpointReference); + + var options = this.GetVercelOptions(); + if (!options.Production) + { + throw new InvalidOperationException( + $"Vercel endpoint references require production deployments because preview and custom target URLs are assigned by Vercel after deployment. Call {nameof(VercelEnvironmentResourceBuilderExtensions.WithVercelProductionDeployments)} on the Vercel environment, or remove the reference."); + } + + var endpoint = endpointReference.EndpointAnnotation; + if (!endpoint.IsExternal) + { + throw new InvalidOperationException( + $"Vercel endpoint references can only target external HTTP or HTTPS endpoints. Endpoint '{endpoint.Name}' on resource '{endpointReference.Resource.Name}' is not external."); + } + + if (!IsHttpEndpoint(endpoint)) + { + throw new InvalidOperationException( + $"Vercel endpoint references support only HTTP or HTTPS endpoints with HTTP transports. Endpoint '{endpoint.Name}' on resource '{endpointReference.Resource.Name}' uses scheme '{endpoint.UriScheme}' and transport '{endpoint.Transport}'."); + } + + // Same-deploy references need an address that exists before `vercel deploy` + // finishes. Production project aliases are deterministic; preview URLs are not. + // See https://vercel.com/docs/deployments/generated-urls. + string projectName = VercelProjectNameResolver.GetProjectName(endpointReference.Resource); + return ReferenceExpression.Create($"{projectName}.vercel.app"); + } + + /// + [Experimental("ASPIRECOMPUTE002", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public ReferenceExpression GetEndpointPropertyExpression(EndpointReferenceExpression endpointReferenceExpression) + { + ArgumentNullException.ThrowIfNull(endpointReferenceExpression); + + var endpointReference = endpointReferenceExpression.Endpoint; + var property = endpointReferenceExpression.Property; + var endpoint = endpointReference.EndpointAnnotation; + var host = GetHostAddressExpression(endpointReference); + const int port = 443; + + // These expressions model the caller-visible Vercel edge endpoint, not the + // container listener. Vercel terminates TLS and forwards to the runtime's $PORT, + // so service references use HTTPS/443 while TargetPort remains container metadata. + // See https://vercel.com/docs/functions/container-images. + return property switch + { + // Vercel terminates public HTTPS at the platform edge, so callers always use + // the alias over 443 even when the container listens on $PORT internally. + EndpointProperty.Url => ReferenceExpression.Create($"https://{host}"), + EndpointProperty.Host or EndpointProperty.IPV4Host => host, + EndpointProperty.Port => ReferenceExpression.Create($"{port.ToString(CultureInfo.InvariantCulture)}"), + EndpointProperty.TargetPort => endpoint.TargetPort is int targetPort + ? ReferenceExpression.Create($"{targetPort.ToString(CultureInfo.InvariantCulture)}") + : throw new InvalidOperationException( + // Azure publishers can emit ContainerPortReference placeholders into + // Bicep/Helm. Vercel deploy receives concrete CLI env values, so an + // unresolved TargetPort would become a bogus string rather than a + // target-native reference. + $"The endpoint property '{EndpointProperty.TargetPort}' cannot be resolved for endpoint '{endpoint.Name}' on resource '{endpointReference.Resource.Name}' because the endpoint does not define an explicit target port."), + EndpointProperty.Scheme => ReferenceExpression.Create($"https"), + EndpointProperty.HostAndPort => ReferenceExpression.Create($"{host}:{port.ToString(CultureInfo.InvariantCulture)}"), + EndpointProperty.TlsEnabled => ReferenceExpression.Create($"{bool.TrueString}"), + _ => throw new InvalidOperationException($"The property '{property}' is not supported for the endpoint '{endpoint.Name}'.") + }; + } + + private static bool IsHttpEndpoint(EndpointAnnotation endpoint) + => endpoint.Protocol == ProtocolType.Tcp + && (string.Equals(endpoint.UriScheme, "http", StringComparison.OrdinalIgnoreCase) + || string.Equals(endpoint.UriScheme, "https", StringComparison.OrdinalIgnoreCase)) + && (string.Equals(endpoint.Transport, "http", StringComparison.OrdinalIgnoreCase) + || string.Equals(endpoint.Transport, "http2", StringComparison.OrdinalIgnoreCase)); +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResourceBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResourceBuilderExtensions.cs new file mode 100644 index 000000000..de861ac4f --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelEnvironmentResourceBuilderExtensions.cs @@ -0,0 +1,224 @@ +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Pipelines; +using Aspire.Hosting.Publishing; +using CommunityToolkit.Aspire.Hosting.Vercel; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using System.Diagnostics.CodeAnalysis; + +namespace Aspire.Hosting; + +/// +/// Provides the public AppHost entry points for adding a Vercel compute environment and choosing +/// how it maps to Vercel scopes, production deployments, and custom targets. +/// +[Experimental("CTASPIREVERCEL001")] +public static class VercelEnvironmentResourceBuilderExtensions +{ + /// + /// Adds a publish/deploy-only Vercel environment resource. + /// + /// The distributed application builder. + /// The Aspire resource name for the Vercel environment. + /// A resource builder for the Vercel environment. + /// + /// The Vercel environment is not added to the local run model. During publish and deploy it validates image-build resources and, + /// during deploy, invokes the Vercel CLI using the current login or VERCEL_TOKEN. + /// See Vercel CLI documentation. + /// + [AspireExport] + public static IResourceBuilder AddVercelEnvironment( + this IDistributedApplicationBuilder builder, + [ResourceName] string name) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + + builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(); + builder.Services.DecorateVercelContainerImageManager(); + + var resource = new VercelEnvironmentResource(name); + var resourceBuilder = builder.ExecutionContext.IsRunMode + ? builder.CreateResourceBuilder(resource) + : builder.AddResource(resource); + + // The actions are plain delegates over injectable services (CLI runner, registry + // client, state/output managers). Tests materialize the pipeline steps and invoke + // these delegates directly so publish/prereq/deploy/destroy logic does not require a + // live Aspire CLI pipeline process. + return resourceBuilder + .WithAnnotation(new VercelEnvironmentOptionsAnnotation(), ResourceAnnotationMutationBehavior.Replace) + .WithPipelineStepFactory(_ => + [ + new PipelineStep + { + Name = $"{VercelDeploymentStep.PublishStepNamePrefix}{resource.Name}", + Description = $"Generate the Vercel Build Output API plan for '{resource.Name}'.", + Resource = resource, + DependsOnSteps = [WellKnownPipelineSteps.ValidateComputeEnvironments], + RequiredBySteps = [WellKnownPipelineSteps.Publish, WellKnownPipelineSteps.Deploy], + Action = context => VercelDeploymentStep.WriteDeploymentPlanAsync(context, resource) + }, + new PipelineStep + { + Name = $"{VercelDeploymentStep.DeployPrereqStepNamePrefix}{resource.Name}", + Description = $"Create/link Vercel projects and configure VCR image pushes for '{resource.Name}'.", + Resource = resource, + DependsOnSteps = [WellKnownPipelineSteps.ValidateComputeEnvironments], + RequiredBySteps = [WellKnownPipelineSteps.Deploy], + Action = context => VercelDeploymentStep.ValidatePrerequisitesAsync(context, resource) + }, + new PipelineStep + { + Name = $"{VercelDeploymentStep.DeployStepNamePrefix}{resource.Name}", + Description = $"Deploy the digest-pinned VCR images to Vercel with --prebuilt for '{resource.Name}'.", + Resource = resource, + Tags = ["vercel-deploy"], + DependsOnSteps = [$"{VercelDeploymentStep.DeployPrereqStepNamePrefix}{resource.Name}"], + RequiredBySteps = [WellKnownPipelineSteps.Deploy], + Action = context => VercelDeploymentStep.DeployAsync(context, resource) + }, + new PipelineStep + { + Name = $"{VercelDeploymentStep.DestroyStepNamePrefix}{resource.Name}", + Description = $"Destroy Vercel resources for environment '{resource.Name}'.", + Resource = resource, + RequiredBySteps = [WellKnownPipelineSteps.Destroy], + Action = context => VercelDeploymentStep.DestroyAsync(context, resource) + } + ]) + .WithAnnotation(new PipelineConfigurationAnnotation(context => VercelDeploymentStep.ConfigurePipeline(context, resource))); + } + + /// + /// Configures the Vercel team or account scope for deployments. + /// + /// The Vercel environment builder. + /// The Vercel scope passed to vercel deploy --scope. + /// The Vercel environment builder. + [AspireExport] + public static IResourceBuilder WithVercelScope( + this IResourceBuilder builder, + string scope) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(scope); + + return builder.WithVercelOptions(options => options with { Scope = scope }); + } + + /// + /// Configures deployments to target Vercel production. + /// + /// The Vercel environment builder. + /// The Vercel environment builder. + /// + /// This adds --prod to Vercel CLI deployments and clears any custom target configured with . + /// Production deployments have deterministic https://{project}.vercel.app aliases that Aspire endpoint references can use. + /// See Vercel deploy CLI documentation. + /// + [AspireExport] + public static IResourceBuilder WithVercelProductionDeployments( + this IResourceBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + return builder.WithVercelOptions(options => options with { Production = true, Target = null }); + } + + /// + /// Configures deployments to use a Vercel target environment. + /// + /// The Vercel environment builder. + /// The target value passed to vercel deploy --target, such as preview or a custom environment. + /// The Vercel environment builder. + /// + /// This clears production deployment mode configured with . + /// Preview and custom-target URLs are assigned after deployment, so Aspire endpoint references are unavailable for these targets. + /// See Vercel generated URL documentation. + /// + [AspireExport] + public static IResourceBuilder WithVercelTarget( + this IResourceBuilder builder, + string target) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(target); + + return builder.WithVercelOptions(options => options with { Production = false, Target = target }); + } + + private static IResourceBuilder WithVercelOptions( + this IResourceBuilder builder, + Func configure) + { + var current = builder.Resource.GetVercelOptions(); + var updated = configure(current); + + return builder.WithAnnotation(updated, ResourceAnnotationMutationBehavior.Replace); + } + + private static void DecorateVercelContainerImageManager(this IServiceCollection services) + { + // Aspire owns image build/push. Vercel only needs to re-authenticate project-scoped + // VCR credentials immediately before Vercel-targeted pushes. Decorate the existing + // image manager once and leave all non-Vercel resources on the original path. + if (services.Any(static descriptor => descriptor.ServiceType == typeof(VercelContainerImageManagerDecorationMarker))) + { + return; + } + + int descriptorIndex = -1; + for (int i = services.Count - 1; i >= 0; i--) + { + if (services[i].ServiceType == typeof(IResourceContainerImageManager)) + { + descriptorIndex = i; + break; + } + } + + if (descriptorIndex < 0) + { + return; + } + + var descriptor = services[descriptorIndex]; + services.RemoveAt(descriptorIndex); + services.Insert( + descriptorIndex, + ServiceDescriptor.Describe( + typeof(IResourceContainerImageManager), + serviceProvider => + { + var inner = (IResourceContainerImageManager)CreateService(serviceProvider, descriptor); + return ActivatorUtilities.CreateInstance(serviceProvider, inner); + }, + descriptor.Lifetime)); + services.AddSingleton(); + } + + private static object CreateService(IServiceProvider serviceProvider, ServiceDescriptor descriptor) + { + if (descriptor.ImplementationInstance is not null) + { + return descriptor.ImplementationInstance; + } + + if (descriptor.ImplementationFactory is not null) + { + return descriptor.ImplementationFactory(serviceProvider)!; + } + + if (descriptor.ImplementationType is not null) + { + return ActivatorUtilities.CreateInstance(serviceProvider, descriptor.ImplementationType); + } + + throw new InvalidOperationException($"The {nameof(IResourceContainerImageManager)} service registration is missing an implementation."); + } + + private sealed class VercelContainerImageManagerDecorationMarker; + +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelOidcToken.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelOidcToken.cs new file mode 100644 index 000000000..ae6aa176b --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelOidcToken.cs @@ -0,0 +1,62 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Aspire.Hosting; + +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +/// +/// Decodes provider-issued OIDC token claims from vercel pull so the integration can +/// route VCR login and repository creation without treating the token as an authentication result. +/// +internal static class VercelOidcToken +{ + public static VercelOidcClaims DecodeUnvalidatedClaims(string token) + { + string[] parts = token.Split('.'); + if (parts.Length != 3) + { + throw new DistributedApplicationException("The Vercel OIDC token is not a valid compact JWT."); + } + + try + { + // This is an unvalidated decode of the Vercel-issued token from `vercel pull`. + // Docker/Vercel validate the token when it is used; here we only need routing + // metadata such as owner_id/project to construct the VCR login and repository. + // See https://vercel.com/docs/container-registry. + byte[] payloadBytes = Convert.FromBase64String(PadBase64Url(parts[1])); + var claims = JsonSerializer.Deserialize(payloadBytes); + + return new( + claims?.OwnerId, + claims?.Owner, + claims?.Project, + claims?.ProjectId); + } + catch (Exception ex) when (ex is FormatException or JsonException) + { + throw new DistributedApplicationException("The Vercel OIDC token payload could not be decoded.", ex); + } + } + + private static string PadBase64Url(string value) + { + string padded = value.Replace('-', '+').Replace('_', '/'); + return padded.PadRight(padded.Length + (4 - padded.Length % 4) % 4, '='); + } + + private sealed class VercelOidcTokenClaimsJson + { + [JsonPropertyName("owner_id")] + public string? OwnerId { get; init; } + + [JsonPropertyName("owner")] + public string? Owner { get; init; } + + [JsonPropertyName("project")] + public string? Project { get; init; } + + [JsonPropertyName("project_id")] + public string? ProjectId { get; init; } + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelPipelineFinalizerAnnotation.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelPipelineFinalizerAnnotation.cs new file mode 100644 index 000000000..e675b9abc --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelPipelineFinalizerAnnotation.cs @@ -0,0 +1,9 @@ +using Aspire.Hosting.ApplicationModel; + +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +/// +/// Marks resources that need a late pipeline normalization pass after Vercel project options +/// are attached to individual workload resources. +/// +internal sealed class VercelPipelineFinalizerAnnotation : IResourceAnnotation; diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectEnvironment.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectEnvironment.cs new file mode 100644 index 000000000..92d450287 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectEnvironment.cs @@ -0,0 +1,32 @@ +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +/// +/// Translates Aspire's Vercel deployment options into the environment names expected by Vercel +/// project environment-variable commands and persisted state cleanup. +/// +internal static class VercelProjectEnvironment +{ + public static string GetName(VercelEnvironmentOptionsAnnotation options) + { + // Vercel CLI env commands use "production" and "preview" environment names, while + // deploy uses --prod or --target. Keep this translation in one place so state cleanup + // removes variables from the same Vercel environment deploy configured. + // See https://vercel.com/docs/cli/env and https://vercel.com/docs/cli/deploy. + if (options.Production) + { + return "production"; + } + + return string.IsNullOrWhiteSpace(options.Target) ? "preview" : options.Target; + } + + public static string GetName(VercelDeploymentState state) + { + if (state.Production) + { + return "production"; + } + + return string.IsNullOrWhiteSpace(state.Target) ? "preview" : state.Target; + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectNameResolver.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectNameResolver.cs new file mode 100644 index 000000000..a423cbdf2 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectNameResolver.cs @@ -0,0 +1,192 @@ +#pragma warning disable ASPIRECOMPUTE003 +#pragma warning disable CTASPIREVERCEL001 + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.ApplicationModel.Docker; +using Aspire.Hosting; +using System.Diagnostics.CodeAnalysis; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +/// +/// Resolves the Vercel project identity for a workload, preferring existing linked project +/// metadata and otherwise creating stable Aspire-managed names for repeatable deploy/destroy. +/// +internal static class VercelProjectNameResolver +{ + public static string GetProjectName(VercelDeploymentEntry entry) + => GetProjectLink(entry).ProjectName; + + public static string GetProjectName(IResource resource) + { + if (resource.TryGetLastAnnotation(out var dockerfile)) + { + return GetProjectName(new VercelDeploymentEntry(resource, dockerfile.ContextPath, dockerfile.DockerfilePath, dockerfile)); + } + + if (resource is ProjectResource project) + { + string projectPath = project.GetProjectMetadata().ProjectPath; + string sourceRoot = Path.GetDirectoryName(projectPath) + ?? throw new DistributedApplicationException($"Project resource '{resource.Name}' has project path '{projectPath}' without a containing directory."); + return GetProjectName(new VercelDeploymentEntry(resource, sourceRoot)); + } + + throw new DistributedApplicationException($"Resource '{resource.Name}' targets Vercel but is not an Aspire image build resource. Use a .NET project, a workload integration that publishes Dockerfile metadata, call PublishAsDockerFile, or configure the resource with WithDockerfile, WithDockerfileFactory, or WithDockerfileBuilder."); + } + + public static VercelProjectLink GetProjectLink(VercelDeploymentEntry entry) + { + if (TryReadProjectLink(entry.SourceRoot, out var projectLink)) + { + return projectLink; + } + + return new(GetManagedProjectName(entry), ProjectId: null); + } + + public static string GetProjectOption(VercelDeploymentEntry entry) + { + var projectLink = GetProjectLink(entry); + return string.IsNullOrWhiteSpace(projectLink.ProjectId) + ? projectLink.ProjectName + : projectLink.ProjectId; + } + + public static bool HasProjectLinkFile(string sourceRoot) + => File.Exists(GetProjectJsonPath(sourceRoot)); + + public static bool IsValidProjectName(string projectName) + { + if (string.IsNullOrWhiteSpace(projectName) + || projectName.Length > VercelConstants.ProjectNameMaxLength + || !IsLowercaseAsciiLetterOrDigit(projectName[0]) + || !IsLowercaseAsciiLetterOrDigit(projectName[^1])) + { + return false; + } + + return projectName.All(static character => + IsLowercaseAsciiLetterOrDigit(character) + || character == '-'); + } + + public static string GetServiceName(IResource resource) + { + if (TryCreateProjectName(resource.Name, out string? serviceName)) + { + return serviceName; + } + + throw new DistributedApplicationException($"Could not infer a valid Vercel service name for resource '{resource.Name}'. Rename the Aspire resource to use letters, digits, or hyphens."); + } + + private static string GetManagedProjectName(VercelDeploymentEntry entry) + { + if (entry.Resource.TryGetLastAnnotation(out var options)) + { + return options.ProjectName; + } + + // The production endpoint contract is project-name based, so managed names must + // be stable and Vercel-valid before deploy starts. + // See https://vercel.com/docs/projects/overview. + string sourceRoot = Path.TrimEndingDirectorySeparator(entry.SourceRoot); + string sourceRootName = Path.GetFileName(sourceRoot); + + if (TryCreateProjectName(sourceRootName, out string? projectName) + || TryCreateProjectName(entry.Resource.Name, out projectName)) + { + return projectName; + } + + throw new DistributedApplicationException($"Could not infer a valid Vercel project name for resource '{entry.Resource.Name}' from source root '{entry.SourceRoot}'. Rename the source directory or link the source root to an existing Vercel project."); + } + + internal static bool TryCreateProjectName(string? value, [NotNullWhen(true)] out string? projectName) + { + if (string.IsNullOrWhiteSpace(value)) + { + projectName = null; + return false; + } + + var builder = new StringBuilder(value.Length); + bool previousWasSeparator = false; + + foreach (char character in value) + { + if (IsAsciiLetterOrDigit(character)) + { + builder.Append(char.ToLowerInvariant(character)); + previousWasSeparator = false; + } + else if (!previousWasSeparator && builder.Length > 0) + { + builder.Append('-'); + previousWasSeparator = true; + } + } + + projectName = builder + .ToString() + .Trim('-'); + + if (projectName.Length > VercelConstants.ProjectNameMaxLength) + { + projectName = projectName[..VercelConstants.ProjectNameMaxLength].Trim('-'); + } + + if (projectName.Length == 0) + { + projectName = null; + return false; + } + + return true; + } + + private static bool TryReadProjectLink(string sourceRoot, [NotNullWhen(true)] out VercelProjectLink? projectLink) + { + string projectJsonPath = GetProjectJsonPath(sourceRoot); + + if (File.Exists(projectJsonPath)) + { + // Vercel CLI writes linked project identity as: + // .vercel/project.json: { "projectId": "...", "orgId": "...", "projectName": "..." } + // Treat it as user/provider ownership metadata rather than regenerating a managed + // name. Destroy preserves these linked projects and only removes tracked env vars. + // See https://vercel.com/docs/cli/link. + var project = JsonSerializer.Deserialize(File.ReadAllText(projectJsonPath)); + if (project is { ProjectName: { } projectName } && !string.IsNullOrWhiteSpace(projectName)) + { + projectLink = new(projectName, project.ProjectId); + return true; + } + } + + projectLink = null; + return false; + } + + private static string GetProjectJsonPath(string sourceRoot) + => Path.Combine(sourceRoot, VercelConstants.DirectoryName, VercelConstants.ProjectFileName); + + private static bool IsAsciiLetterOrDigit(char character) + => character is >= 'a' and <= 'z' or >= 'A' and <= 'Z' or >= '0' and <= '9'; + + private static bool IsLowercaseAsciiLetterOrDigit(char character) + => character is >= 'a' and <= 'z' or >= '0' and <= '9'; + + private sealed class VercelLinkedProjectJson + { + [JsonPropertyName("projectName")] + public string? ProjectName { get; init; } + + [JsonPropertyName("projectId")] + public string? ProjectId { get; init; } + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectOptionsAnnotation.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectOptionsAnnotation.cs new file mode 100644 index 000000000..cc9a5e144 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectOptionsAnnotation.cs @@ -0,0 +1,9 @@ +using Aspire.Hosting.ApplicationModel; + +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +/// +/// Stores the user-specified Vercel project name for Aspire-managed projects when no +/// checked-in .vercel/project.json link owns the project identity. +/// +internal sealed record VercelProjectOptionsAnnotation(string ProjectName) : IResourceAnnotation; diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectSettingsReader.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectSettingsReader.cs new file mode 100644 index 000000000..66bf024e8 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelProjectSettingsReader.cs @@ -0,0 +1,41 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Aspire.Hosting; + +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +/// +/// Reads the project identity generated by vercel pull so deploy artifacts and state +/// reference the provider project that Vercel CLI linked. +/// +internal static class VercelProjectSettingsReader +{ + public static VercelPulledProjectSettings Read(string projectJsonPath, string projectJsonContent) + { + try + { + // `vercel pull` writes `.vercel/project.json`. Only project identity fields are + // needed: they select the linked provider project and are safe to persist in state. + // See https://vercel.com/docs/cli/pull. + var settings = JsonSerializer.Deserialize(projectJsonContent); + + return new(settings?.ProjectName ?? string.Empty, settings?.ProjectId, settings?.OrgId); + } + catch (JsonException ex) + { + throw new DistributedApplicationException($"Vercel project settings file '{projectJsonPath}' is invalid JSON.", ex); + } + } + + private sealed class VercelProjectSettingsJson + { + [JsonPropertyName("projectName")] + public string? ProjectName { get; init; } + + [JsonPropertyName("projectId")] + public string? ProjectId { get; init; } + + [JsonPropertyName("orgId")] + public string? OrgId { get; init; } + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelResourceBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelResourceBuilderExtensions.cs new file mode 100644 index 000000000..3cfaf4663 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelResourceBuilderExtensions.cs @@ -0,0 +1,45 @@ +using Aspire.Hosting.ApplicationModel; +using CommunityToolkit.Aspire.Hosting.Vercel; +using System.Diagnostics.CodeAnalysis; + +namespace Aspire.Hosting; + +/// +/// Provides public per-workload AppHost options for Vercel deployments, such as overriding the +/// Aspire-managed project name for repeatable provider resources. +/// +[Experimental("CTASPIREVERCEL001")] +public static class VercelResourceBuilderExtensions +{ + /// + /// Configures the Vercel project name to use when deploying an Aspire-managed Vercel project for the resource. + /// + /// The compute resource type. + /// The resource builder. + /// The Vercel project name. Use lowercase letters, digits, and hyphens. + /// The resource builder. + /// + /// This setting is used only when the source root is not already linked to a Vercel project with .vercel/project.json. + /// Linked projects keep their existing provider project identity. + /// + [AspireExport] + public static IResourceBuilder WithVercelProjectName( + this IResourceBuilder builder, + string projectName) + where TResource : IComputeResource + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(projectName); + + if (!VercelProjectNameResolver.IsValidProjectName(projectName)) + { + throw new ArgumentException( + $"Vercel project name '{projectName}' is invalid. Use lowercase letters, digits, and hyphens; start and end with a letter or digit; and keep the name at most 100 characters.", + nameof(projectName)); + } + + return builder + .WithAnnotation(new VercelProjectOptionsAnnotation(projectName), ResourceAnnotationMutationBehavior.Replace) + .WithVercelPipelineFinalizer(); + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelResourceContainerImageManager.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelResourceContainerImageManager.cs new file mode 100644 index 000000000..9c3165e36 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelResourceContainerImageManager.cs @@ -0,0 +1,53 @@ +#pragma warning disable ASPIREPIPELINES003 +#pragma warning disable CTASPIREVERCEL001 + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Publishing; + +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +/// +/// Decorates Aspire's container image manager to add the VCR login behavior needed before +/// pushing Vercel-targeted resources, while leaving unrelated resource pushes untouched. +/// +internal sealed class VercelResourceContainerImageManager( + IResourceContainerImageManager inner, + IVercelCliRunner runner) : IResourceContainerImageManager +{ + private readonly SemaphoreSlim _pushLock = new(1, 1); + + public Task BuildImageAsync(IResource resource, CancellationToken cancellationToken = default) + => inner.BuildImageAsync(resource, cancellationToken); + + public Task BuildImagesAsync(IEnumerable resources, CancellationToken cancellationToken = default) + => inner.BuildImagesAsync(resources, cancellationToken); + + public async Task PushImageAsync(IResource resource, CancellationToken cancellationToken) + { + var preparedDeployment = resource.Annotations.OfType().LastOrDefault(); + if (preparedDeployment is null) + { + await inner.PushImageAsync(resource, cancellationToken).ConfigureAwait(false); + return; + } + + // VCR OIDC tokens are scoped to a Vercel project, while Docker stores credentials + // by registry host. Built-in Aspire push steps can run in parallel, so serialize + // Vercel pushes and login immediately before each push to avoid cross-project token races. + await _pushLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await VercelDeploymentStep.LoginToVcrAsync( + runner, + preparedDeployment.ProjectContext.PulledProject.OidcToken, + preparedDeployment.ProjectContext.OidcClaims, + cancellationToken).ConfigureAwait(false); + + await inner.PushImageAsync(resource, cancellationToken).ConfigureAwait(false); + } + finally + { + _pushLock.Release(); + } + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelResourceExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelResourceExtensions.cs new file mode 100644 index 000000000..9c18ec03b --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Vercel/VercelResourceExtensions.cs @@ -0,0 +1,40 @@ +#pragma warning disable ASPIREPIPELINES001 +#pragma warning disable CTASPIREVERCEL001 + +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Pipelines; + +namespace CommunityToolkit.Aspire.Hosting.Vercel; + +/// +/// Keeps internal Vercel annotation access and pipeline-finalizer wiring out of the public +/// AppHost API while sharing those helpers across resource extension methods and pipeline code. +/// +internal static class VercelResourceExtensions +{ + [AspireExportIgnore(Reason = "Internal Vercel annotation access is not part of the generated AppHost API.")] + public static VercelEnvironmentOptionsAnnotation GetVercelOptions(this VercelEnvironmentResource resource) + { + if (resource.TryGetLastAnnotation(out var options)) + { + return options; + } + + return new VercelEnvironmentOptionsAnnotation(); + } + + [AspireExportIgnore(Reason = "Internal Vercel pipeline cleanup is not part of the generated AppHost API.")] + public static IResourceBuilder WithVercelPipelineFinalizer(this IResourceBuilder builder) + where TResource : IResource + { + if (builder.Resource.Annotations.OfType().Any()) + { + return builder; + } + + builder.Resource.Annotations.Add(new VercelPipelineFinalizerAnnotation()); + builder.Resource.Annotations.Add(new PipelineConfigurationAnnotation(VercelDeploymentStep.NormalizePipelineDependencies)); + return builder; + } +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Tests/TypeScriptAppHostTests.cs b/tests/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Tests/TypeScriptAppHostTests.cs index 78da54e48..6415596b5 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Tests/TypeScriptAppHostTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Tests/TypeScriptAppHostTests.cs @@ -13,6 +13,7 @@ await TypeScriptAppHostTest.Run( packageName: "CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector", exampleName: "opentelemetry-collector", waitForResources: ["collector"], + waitStatus: "up", cancellationToken: TestContext.Current.CancellationToken); } } diff --git a/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests.csproj b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests.csproj new file mode 100644 index 000000000..efeb47ca9 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests.csproj @@ -0,0 +1,17 @@ + + + + false + true + + + + + + + + + + + + diff --git a/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/TypeScriptAppHostTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/TypeScriptAppHostTests.cs new file mode 100644 index 000000000..cd79a81c8 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/TypeScriptAppHostTests.cs @@ -0,0 +1,80 @@ +using CommunityToolkit.Aspire.Testing; +using System.Text.Json; + +namespace CommunityToolkit.Aspire.Hosting.Vercel.Tests; + +public class TypeScriptAppHostTests +{ + [Fact] + public async Task TypeScriptAppHostCompilesAndStarts() + { + if (!IsOnPath("pwsh")) + { + Assert.Skip("TypeScript AppHost start smoke requires PowerShell (pwsh) because the TypeScript AppHost test harness launches Aspire scripts through pwsh."); + } + + await TypeScriptAppHostTest.Run( + appHostProject: "CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript", + packageName: "CommunityToolkit.Aspire.Hosting.Vercel", + exampleName: "vercel", + waitForResources: [], + cancellationToken: TestContext.Current.CancellationToken); + } + + [Fact] + public async Task TypeScriptAppHostPublishesWithExplicitComputeEnvironment() + { + string repoRoot = Path.GetFullPath(Path.Combine("..", "..", "..", "..", "..")); + string appHostRoot = Path.Combine(repoRoot, "examples", "vercel", "CommunityToolkit.Aspire.Hosting.Vercel.AppHost.TypeScript"); + string outputRoot = Path.Combine(appHostRoot, "aspire-output"); + string distRoot = Path.Combine(appHostRoot, "dist"); + + try + { + await ProcessTestUtilities.RunProcessAsync("aspire", ["restore"], appHostRoot, TestContext.Current.CancellationToken); + string node = OperatingSystem.IsWindows() ? "node.exe" : "node"; + await ProcessTestUtilities.RunProcessAsync(node, [Path.Combine("node_modules", "eslint", "bin", "eslint.js"), "apphost.mts"], appHostRoot, TestContext.Current.CancellationToken); + await ProcessTestUtilities.RunProcessAsync(node, [Path.Combine("node_modules", "typescript", "bin", "tsc")], appHostRoot, TestContext.Current.CancellationToken); + await ProcessTestUtilities.RunProcessAsync("aspire", ["publish", "--non-interactive"], appHostRoot, TestContext.Current.CancellationToken); + + string planPath = Path.Combine(outputRoot, "vercel", "vercel-deployments.json"); + using var document = JsonDocument.Parse(File.ReadAllText(planPath)); + JsonElement deployment = Assert.Single(document.RootElement.GetProperty("deployments").EnumerateArray()); + + Assert.Equal("api", deployment.GetProperty("resourceName").GetString()); + Assert.True(File.Exists(Path.Combine(outputRoot, "docker", "docker-compose.yaml"))); + } + finally + { + if (Directory.Exists(outputRoot)) + { + Directory.Delete(outputRoot, recursive: true); + } + + if (Directory.Exists(distRoot)) + { + Directory.Delete(distRoot, recursive: true); + } + } + } + + private static bool IsOnPath(string fileName) + { + string path = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; + foreach (string directory in path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) + { + string candidate = Path.Combine(directory, fileName); + if (File.Exists(candidate)) + { + return true; + } + + if (OperatingSystem.IsWindows() && File.Exists(candidate + ".exe")) + { + return true; + } + } + + return false; + } +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs new file mode 100644 index 000000000..7a23ec0c8 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Vercel.Tests/VercelEnvironmentTests.cs @@ -0,0 +1,5017 @@ +#pragma warning disable ASPIREPIPELINES001 +#pragma warning disable ASPIREPIPELINES002 +#pragma warning disable ASPIREPIPELINES003 +#pragma warning disable ASPIREPIPELINES004 +#pragma warning disable ASPIRECOMPUTE003 +#pragma warning disable ASPIREPROBES001 +#pragma warning disable CTASPIREVERCEL001 + +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Pipelines; +using Aspire.Hosting.Publishing; +using CommunityToolkit.Aspire.Hosting.Vercel; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using System.Net.Sockets; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace CommunityToolkit.Aspire.Hosting.Vercel.Tests; + +public class VercelEnvironmentTests +{ + [Fact] + public void AddVercelEnvironmentShouldThrowWhenBuilderIsNull() + { + IDistributedApplicationBuilder builder = null!; + + var action = () => builder.AddVercelEnvironment("vercel"); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(builder), exception.ParamName); + } + + [Fact] + public void AddVercelEnvironmentShouldThrowWhenNameIsNull() + { + IDistributedApplicationBuilder builder = new DistributedApplicationBuilder([]); + string name = null!; + + var action = () => builder.AddVercelEnvironment(name); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(name), exception.ParamName); + } + + [Fact] + public void RunModeDoesNotAddVercelEnvironment() + { + var builder = DistributedApplication.CreateBuilder(); + + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + + Assert.DoesNotContain(model.Resources, resource => resource is VercelEnvironmentResource); + } + + [Fact] + public void PublishModeAddsVercelEnvironmentAndDiscoversDockerfileResource() + { + using var sourceRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); + + var vercel = builder.AddVercelEnvironment("vercel") + .WithVercelScope("team") + .WithVercelTarget("preview"); + + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var api = Assert.Single(model.Resources.OfType()); + + Assert.Null(api.GetComputeEnvironment()); + Assert.True(api.TryGetLastAnnotation(out var dockerfile)); + Assert.Equal(sourceRoot.Path, dockerfile.ContextPath); + Assert.Equal(Path.Combine(sourceRoot.Path, "Dockerfile"), dockerfile.DockerfilePath); + var entry = Assert.Single(VercelDeploymentModel.GetEntries(model, environment)); + Assert.Same(api, entry.Resource); + + var options = environment.GetVercelOptions(); + Assert.Equal("team", options.Scope); + Assert.Equal("preview", options.Target); + Assert.False(options.Production); + } + + [Fact] + public void VercelEnvironmentOptionsCanBeConfigured() + { + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + + var vercel = builder.AddVercelEnvironment("vercel") + .WithVercelTarget("preview") + .WithVercelProductionDeployments() + .WithVercelTarget("staging"); + + var options = vercel.Resource.GetVercelOptions(); + Assert.Equal("staging", options.Target); + Assert.False(options.Production); + } + + [Fact] + public void WithVercelProjectNameThrowsForInvalidProjectName() + { + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + var api = builder.AddContainer("api", "api"); + + var exception = Assert.Throws(() => + api.WithVercelProjectName("Invalid_Project")); + + Assert.Equal("projectName", exception.ParamName); + Assert.Contains("Use lowercase letters, digits, and hyphens", exception.Message); + } + + [Fact] + public async Task AddVercelEnvironmentRegistersExpectedPipelineSteps() + { + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(new FakeVercelCliRunner()); + builder.Services.AddSingleton(new FakeDeploymentStateManager()); + var vercel = builder.AddVercelEnvironment("vercel"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var stepContext = CreatePipelineStepContext(builder, app); + var annotation = Assert.Single(environment.Annotations.OfType()); + + var steps = (await annotation.CreateStepsAsync(new() + { + PipelineContext = stepContext.PipelineContext, + Resource = environment + })).ToArray(); + + foreach (var step in steps) + { + Assert.DoesNotContain(WellKnownPipelineSteps.Build, step.DependsOnSteps); + Assert.DoesNotContain(WellKnownPipelineSteps.Push, step.DependsOnSteps); + } + + Assert.Collection( + steps, + step => + { + Assert.Equal("vercel-generate-plan-vercel", step.Name); + Assert.Same(vercel.Resource, step.Resource); + Assert.Equal([WellKnownPipelineSteps.ValidateComputeEnvironments], step.DependsOnSteps); + Assert.Equal([WellKnownPipelineSteps.Publish, WellKnownPipelineSteps.Deploy], step.RequiredBySteps); + }, + step => + { + Assert.Equal("vercel-prepare-projects-vercel", step.Name); + Assert.Same(vercel.Resource, step.Resource); + Assert.Equal([WellKnownPipelineSteps.ValidateComputeEnvironments], step.DependsOnSteps); + Assert.Equal([WellKnownPipelineSteps.Deploy], step.RequiredBySteps); + }, + step => + { + Assert.Equal("vercel-deploy-prebuilt-vercel", step.Name); + Assert.Same(vercel.Resource, step.Resource); + Assert.Equal(["vercel-prepare-projects-vercel"], step.DependsOnSteps); + Assert.Equal([WellKnownPipelineSteps.Deploy], step.RequiredBySteps); + }, + step => + { + Assert.Equal("vercel-destroy-vercel", step.Name); + Assert.Same(vercel.Resource, step.Resource); + Assert.Empty(step.DependsOnSteps); + Assert.Equal([WellKnownPipelineSteps.Destroy], step.RequiredBySteps); + }); + } + + [Fact] + public async Task VercelDeployStepDependsOnBuiltInImagePushSteps() + { + using var sourceRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var api = Assert.Single(model.Resources.OfType()); + var steps = await CreateConfiguredPipelineStepsAsync(builder, app); + + var buildStep = Assert.Single(steps, step => step.Name == "build-api"); + var pushStep = Assert.Single(steps, step => step.Name == "push-api"); + var pushPrereqStep = Assert.Single(steps, step => step.Name == "push-prereq"); + var deployStep = Assert.Single(steps, step => step.Name == "vercel-deploy-prebuilt-vercel"); + + AssertPipelineDependenciesAreDistinct(steps); + Assert.Contains("vercel-prepare-projects-vercel", buildStep.DependsOnSteps); + Assert.Contains(WellKnownPipelineSteps.Deploy, buildStep.RequiredBySteps); + Assert.Contains("vercel-prepare-projects-vercel", pushPrereqStep.DependsOnSteps); + Assert.Contains("vercel-prepare-projects-vercel", pushStep.DependsOnSteps); + Assert.Contains(WellKnownPipelineSteps.Deploy, pushStep.RequiredBySteps); + Assert.Contains("vercel-generate-plan-vercel", deployStep.DependsOnSteps); + Assert.Contains("push-api", deployStep.DependsOnSteps); + Assert.Contains(api.Annotations, annotation => annotation is ContainerImagePushOptionsCallbackAnnotation); + } + + [Fact] + public async Task GeneratedDockerfileResourceUsesBuiltInImageBuildAndPushSteps() + { + using var sourceRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "index.html"), "hello"); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.AddVercelEnvironment("vercel"); + builder.AddDockerfileFactory("api", sourceRoot.Path, _ => Task.FromResult(""" + FROM nginx:alpine + COPY index.html /usr/share/nginx/html/index.html + """)); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var api = Assert.Single(model.Resources.OfType()); + Assert.NotNull(Assert.Single(VercelDeploymentModel.GetEntries(model, environment)).Dockerfile!.DockerfileFactory); + + var steps = await CreateConfiguredPipelineStepsAsync(builder, app); + var buildStep = Assert.Single(steps, step => step.Name == "build-api"); + var pushStep = Assert.Single(steps, step => step.Name == "push-api"); + var deployStep = Assert.Single(steps, step => step.Name == "vercel-deploy-prebuilt-vercel"); + + AssertPipelineDependenciesAreDistinct(steps); + Assert.Contains("vercel-prepare-projects-vercel", buildStep.DependsOnSteps); + Assert.Contains(WellKnownPipelineSteps.Deploy, buildStep.RequiredBySteps); + Assert.Contains("vercel-prepare-projects-vercel", pushStep.DependsOnSteps); + Assert.Contains(WellKnownPipelineSteps.Deploy, pushStep.RequiredBySteps); + Assert.Contains("vercel-generate-plan-vercel", deployStep.DependsOnSteps); + Assert.Contains("push-api", deployStep.DependsOnSteps); + Assert.Contains(api.Annotations, annotation => annotation is ContainerImagePushOptionsCallbackAnnotation); + } + + [Fact] + public async Task VercelPipelineStepActionsCanBeUnitTested() + { + using var sourceRoot = TemporaryDirectory.Create("pipeline-action-project"); + using var outputRoot = TemporaryDirectory.Create(); + using var tempRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + var runner = new FakeVercelCliRunner( + new VercelCliResult(0, "https://pipeline-action-project.vercel.app", ""), + ReadyInspectResult()); + var stateManager = new FakeDeploymentStateManager(); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", outputRoot.Path]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); + builder.Services.AddSingleton(stateManager); + builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile"); + + using var app = builder.Build(); + var steps = await CreateConfiguredPipelineStepsAsync(builder, app); + var context = CreatePipelineStepContext(builder, app); + var publishStep = Assert.Single(steps, step => step.Name == "vercel-generate-plan-vercel"); + var prereqStep = Assert.Single(steps, step => step.Name == "vercel-prepare-projects-vercel"); + var deployStep = Assert.Single(steps, step => step.Name == "vercel-deploy-prebuilt-vercel"); + var destroyStep = Assert.Single(steps, step => step.Name == "vercel-destroy-vercel"); + + await publishStep.Action(context); + await prereqStep.Action(context); + await deployStep.Action(context); + await destroyStep.Action(context); + + Assert.True(File.Exists(Path.Combine(outputRoot.Path, VercelConstants.DeploymentPlanFileName))); + Assert.Contains(runner.Invocations, static invocation => invocation.FileName == "docker" && invocation.Arguments.Contains("imagetools")); + Assert.Contains(runner.Invocations, static invocation => invocation.FileName == "vercel" && invocation.Arguments.Contains("deploy")); + Assert.Contains(runner.Invocations, static invocation => invocation.FileName == "vercel" && invocation.Arguments is ["project", "remove", ..]); + Assert.NotEmpty(stateManager.SavedSections); + Assert.NotEmpty(stateManager.DeletedSections); + Assert.Contains(context.Summary.Items, item => item.Key == "Vercel deployment plan"); + Assert.Contains(context.Summary.Items, item => item.Key == "api Vercel deployment"); + Assert.Contains(context.Summary.Items, item => item.Key == "Vercel project removed"); + } + + [Fact] + public async Task ValidatePrerequisitesConfiguresVcrAsDeploymentTargetRegistry() + { + using var sourceRoot = TemporaryDirectory.Create("vcr-registry-project"); + using var outputRoot = TemporaryDirectory.Create(); + using var tempRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + var registryClient = new FakeVercelContainerRegistryClient(); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(new FakeVercelCliRunner()); + builder.Services.AddSingleton(registryClient); + builder.Services.AddSingleton(new FakeDeploymentStateManager()); + builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var api = Assert.Single(model.Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + await VercelDeploymentStep.ValidatePrerequisitesAsync(context, environment); + + var target = Assert.Single(api.Annotations.OfType()); + Assert.Same(environment, target.DeploymentTarget); + Assert.Same(environment, target.ComputeEnvironment); + Assert.NotNull(target.ContainerRegistry); + Assert.Equal("vcr.vercel.com", await target.ContainerRegistry.Endpoint.GetValueAsync(TestContext.Current.CancellationToken)); + Assert.Equal("test-team/test-project", await target.ContainerRegistry.Repository!.GetValueAsync(TestContext.Current.CancellationToken)); + Assert.Equal([ "api" ], registryClient.Repositories); + + var pushOptions = new ContainerImagePushOptions(); + var pushContext = new ContainerImagePushOptionsCallbackContext + { + Resource = api, + Options = pushOptions, + CancellationToken = TestContext.Current.CancellationToken + }; + foreach (var annotation in api.Annotations.OfType()) + { + await annotation.Callback(pushContext); + } + + Assert.Equal("api", pushOptions.RemoteImageName); + Assert.StartsWith("aspire-", pushOptions.RemoteImageTag, StringComparison.Ordinal); + + var buildContext = new ContainerBuildOptionsCallbackContext( + api, + app.Services, + NullLogger.Instance, + TestContext.Current.CancellationToken, + builder.ExecutionContext); + foreach (var annotation in api.Annotations.OfType()) + { + await annotation.Callback(buildContext); + } + + Assert.Equal(ContainerTargetPlatform.LinuxAmd64, buildContext.TargetPlatform); + } + + [Fact] + public async Task VercelImageManagerLogsInBeforeEachProjectScopedPush() + { + var runner = new FakeVercelCliRunner(); + var inner = new VerifyingResourceContainerImageManager(runner); + var manager = new VercelResourceContainerImageManager(inner, runner); + var api = new ContainerResource("api"); + var backend = new ContainerResource("backend"); + api.Annotations.Add(CreatePreparedDeployment(api, "api-token", "api-project")); + backend.Annotations.Add(CreatePreparedDeployment(backend, "backend-token", "backend-project")); + + await Task.WhenAll( + manager.PushImageAsync(api, TestContext.Current.CancellationToken), + manager.PushImageAsync(backend, TestContext.Current.CancellationToken)); + + var loginInvocations = runner.Invocations + .Where(static invocation => invocation.FileName == "docker" && invocation.Arguments.Contains("login")) + .ToArray(); + Assert.Equal(2, loginInvocations.Length); + Assert.Contains(loginInvocations, static invocation => invocation.StandardInput == "api-token"); + Assert.Contains(loginInvocations, static invocation => invocation.StandardInput == "backend-token"); + Assert.Equal(2, inner.PushedResources.Count); + } + + [Fact] + public async Task DestroyPipelineStepDoesNotValidateVercelCliWhenStateIsMissing() + { + var runner = new FakeVercelCliRunner(new VercelCliResult(1, "", "not logged in")); + var stateManager = new FakeDeploymentStateManager(); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); + builder.Services.AddSingleton(stateManager); + builder.AddVercelEnvironment("vercel"); + + using var app = builder.Build(); + var environment = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + var annotation = Assert.Single(environment.Annotations.OfType()); + var steps = (await annotation.CreateStepsAsync(new() + { + PipelineContext = context.PipelineContext, + Resource = environment + })).ToArray(); + var destroyStep = Assert.Single(steps, step => step.Name == "vercel-destroy-vercel"); + + await destroyStep.Action(context); + + Assert.Empty(runner.Invocations); + var summary = Assert.Single(context.Summary.Items); + Assert.Equal("Vercel destroy", summary.Key); + Assert.Contains("No Vercel deployment state", summary.Value); + } + + [Fact] + public void WithVercelProductionDeploymentsShouldThrowWhenBuilderIsNull() + { + IResourceBuilder builder = null!; + + var action = () => builder.WithVercelProductionDeployments(); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(builder), exception.ParamName); + } + + [Fact] + public void PublishProjectAsDockerFileIsDiscoveredInPublishMode() + { + using var sourceRoot = TemporaryDirectory.Create(); + string projectPath = Path.Combine(sourceRoot.Path, "Api.csproj"); + File.WriteAllText(projectPath, ""); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM mcr.microsoft.com/dotnet/aspnet:10.0"); + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.AddVercelEnvironment("vercel"); + var projectResource = new ProjectResource("api"); + var project = builder.AddResource(projectResource) + .WithAnnotation(new FakeProjectMetadata(projectPath)); + + project.PublishAsDockerFile(); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var api = Assert.Single(model.Resources.OfType(), resource => resource.Name == "api"); + + Assert.Null(api.GetComputeEnvironment()); + Assert.True(api.TryGetLastAnnotation(out var dockerfile)); + Assert.Equal(sourceRoot.Path, dockerfile.ContextPath); + Assert.Equal(Path.Combine(sourceRoot.Path, "Dockerfile"), dockerfile.DockerfilePath); + Assert.Same(api, Assert.Single(VercelDeploymentModel.GetEntries(model, environment)).Resource); + } + + [Fact] + public void ProjectResourceIsDiscoveredInPublishMode() + { + using var sourceRoot = TemporaryDirectory.Create(); + string projectPath = Path.Combine(sourceRoot.Path, "Api.csproj"); + File.WriteAllText(projectPath, ""); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.AddVercelEnvironment("vercel"); + var projectResource = new ProjectResource("api"); + builder.AddResource(projectResource) + .WithAnnotation(new FakeProjectMetadata(projectPath)); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + + var entry = Assert.Single(VercelDeploymentModel.GetEntries(model, environment)); + Assert.Same(projectResource, entry.Resource); + Assert.Equal(sourceRoot.Path, entry.SourceRoot); + Assert.Null(entry.Dockerfile); + Assert.Null(entry.DockerfilePath); + } + + [Fact] + public void PublishExecutableAsDockerFileIsDiscoveredInPublishMode() + { + using var sourceRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.AddVercelEnvironment("vercel"); + + builder.AddExecutable("api", "node", sourceRoot.Path) + .PublishAsDockerFile(); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + + var entry = Assert.Single(VercelDeploymentModel.GetEntries(model, environment)); + Assert.IsAssignableFrom(entry.Resource); + Assert.Equal(sourceRoot.Path, entry.SourceRoot); + Assert.Equal(Path.Combine(sourceRoot.Path, "Dockerfile"), entry.DockerfilePath); + } + + [Fact] + public void LanguageExecutableUsesExistingGeneratedDockerfileMetadata() + { + using var sourceRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "server.mjs"), "console.log('hello');"); + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.AddVercelEnvironment("vercel"); + var resource = new TestLanguageAppResource("api", "node", sourceRoot.Path); + + builder.AddResource(resource) + .PublishAsDockerFile(container => container.WithDockerfileFactory(sourceRoot.Path, _ => Task.FromResult(""" + FROM node:22-alpine + WORKDIR /app + COPY server.mjs . + CMD ["node", "server.mjs"] + """))); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + + var entry = Assert.Single(VercelDeploymentModel.GetEntries(model, environment)); + Assert.IsAssignableFrom(entry.Resource); + Assert.Equal(sourceRoot.Path, entry.SourceRoot); + Assert.NotNull(entry.Dockerfile!.DockerfileFactory); + } + + [Fact] + public void DockerfileContainerIsDiscoveredInPublishMode() + { + using var sourceRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile.custom"), "FROM nginx:alpine"); + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.AddVercelEnvironment("vercel"); + + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile.custom"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + + var entry = Assert.Single(VercelDeploymentModel.GetEntries(model, environment)); + Assert.Equal(sourceRoot.Path, entry.SourceRoot); + Assert.Equal(Path.Combine(sourceRoot.Path, "Dockerfile.custom"), entry.DockerfilePath); + } + + [Fact] + public void ExplicitComputeEnvironmentSelectsVercelResourceWhenMultipleExist() + { + using var apiRoot = TemporaryDirectory.Create("vercel-api-project"); + using var workerRoot = TemporaryDirectory.Create("vercel-worker-project"); + File.WriteAllText(Path.Combine(apiRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + File.WriteAllText(Path.Combine(workerRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + var vercel = builder.AddVercelEnvironment("vercel"); + var other = builder.AddVercelEnvironment("other"); + + builder.AddContainer("api", "api") + .WithDockerfile(apiRoot.Path, "Dockerfile") + .WithComputeEnvironment(vercel); + builder.AddContainer("worker", "worker") + .WithDockerfile(workerRoot.Path, "Dockerfile") + .WithComputeEnvironment(other); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var vercelEnvironment = Assert.Single(model.Resources.OfType(), resource => resource.Name == "vercel"); + var otherEnvironment = Assert.Single(model.Resources.OfType(), resource => resource.Name == "other"); + + var vercelEntry = Assert.Single(VercelDeploymentModel.GetEntries(model, vercelEnvironment)); + var otherEntry = Assert.Single(VercelDeploymentModel.GetEntries(model, otherEnvironment)); + + Assert.Equal("api", vercelEntry.Resource.Name); + Assert.Equal("worker", otherEntry.Resource.Name); + } + + [Fact] + public void UntargetedResourcesAreNotImplicitlySelectedWhenMultipleComputeEnvironmentsExist() + { + using var sourceRoot = TemporaryDirectory.Create("vercel-api-project"); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.AddVercelEnvironment("vercel"); + builder.AddVercelEnvironment("other"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environments = model.Resources.OfType().ToArray(); + + Assert.All(environments, environment => Assert.Empty(VercelDeploymentModel.GetEntries(model, environment))); + } + + [Fact] + public async Task WriteDeploymentPlanWritesExpectedJson() + { + using var sourceRoot = TemporaryDirectory.Create(); + using var outputRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", outputRoot.Path]); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + + string planPath = await VercelDeploymentStep.WriteDeploymentPlanAsync(model, environment, outputRoot.Path, TestContext.Current.CancellationToken); + + Assert.Equal(Path.Combine(outputRoot.Path, VercelConstants.DeploymentPlanFileName), planPath); + using var document = JsonDocument.Parse(File.ReadAllText(planPath)); + var root = document.RootElement; + Assert.Equal("vercel", root.GetProperty("environment").GetString()); + var deployment = Assert.Single(root.GetProperty("deployments").EnumerateArray()); + Assert.Equal("api", deployment.GetProperty("resourceName").GetString()); + Assert.Equal("Dockerfile", deployment.GetProperty("dockerfilePath").GetString()); + string deployCommand = deployment.GetProperty("deployCommand").GetString()!; + Assert.Contains("vercel pull --cwd --yes --environment preview", deployCommand); + Assert.Contains("aspire build/push api -> vcr.vercel.com///api:", deployCommand); + Assert.Contains("docker buildx imagetools inspect --format {{json .Manifest}} vcr.vercel.com///api:", deployCommand); + Assert.Contains("vercel deploy --cwd --project --prebuilt --yes", deployCommand); + } + + [Fact] + public async Task WriteDeploymentPlanPipelineStepWritesSummary() + { + using var sourceRoot = TemporaryDirectory.Create(); + using var outputRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + var outputService = new FakePipelineOutputService(outputRoot.Path); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(outputService); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + await VercelDeploymentStep.WriteDeploymentPlanAsync(context, environment); + + var summary = Assert.Single(context.Summary.Items); + Assert.Equal("Vercel deployment plan", summary.Key); + Assert.Equal(Path.Combine(outputRoot.Path, VercelConstants.DeploymentPlanFileName), summary.Value); + } + + [Fact] + public async Task WriteDeploymentPlanProcessesEnvironmentVariables() + { + using var sourceRoot = TemporaryDirectory.Create(); + using var outputRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", outputRoot.Path]); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile") + .WithEnvironment("GREETING", "hello"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + + string planPath = await VercelDeploymentStep.WriteDeploymentPlanAsync( + builder.ExecutionContext, + NullLogger.Instance, + model, + environment, + outputRoot.Path, + TestContext.Current.CancellationToken); + + using var document = JsonDocument.Parse(File.ReadAllText(planPath)); + var deployment = Assert.Single(document.RootElement.GetProperty("deployments").EnumerateArray()); + + string deployCommand = deployment.GetProperty("deployCommand").GetString()!; + Assert.Contains("aspire build/push api -> vcr.vercel.com///api:", deployCommand); + Assert.Contains("vercel deploy --cwd --project --prebuilt --yes", deployCommand); + Assert.Equal("GREETING", Assert.Single(deployment.GetProperty("environmentVariables").EnumerateArray()).GetString()); + Assert.DoesNotContain("hello", File.ReadAllText(planPath)); + } + + [Fact] + public async Task WriteDeploymentPlanDoesNotResolveSecretEnvironmentVariableValues() + { + using var sourceRoot = TemporaryDirectory.Create(); + using var outputRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", outputRoot.Path]); + var secret = builder.AddParameter("api-key", secret: true); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile") + .WithEnvironment("API_KEY", secret); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + + string planPath = await VercelDeploymentStep.WriteDeploymentPlanAsync( + builder.ExecutionContext, + NullLogger.Instance, + model, + environment, + outputRoot.Path, + TestContext.Current.CancellationToken); + + using var document = JsonDocument.Parse(File.ReadAllText(planPath)); + var deployment = Assert.Single(document.RootElement.GetProperty("deployments").EnumerateArray()); + + Assert.DoesNotContain("--env API_KEY", deployment.GetProperty("deployCommand").GetString(), StringComparison.Ordinal); + Assert.Equal("API_KEY", Assert.Single(deployment.GetProperty("environmentVariables").EnumerateArray()).GetString()); + } + + [Fact] + public async Task WriteDeploymentPlanThrowsWhenDockerfileIsMissing() + { + using var sourceRoot = TemporaryDirectory.Create(); + using var outputRoot = TemporaryDirectory.Create(); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", outputRoot.Path]); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.WriteDeploymentPlanAsync(model, environment, outputRoot.Path, TestContext.Current.CancellationToken)); + + Assert.Contains("Dockerfile", exception.Message); + } + + [Fact] + public async Task WriteDeploymentPlanAllowsGeneratedDockerfileFactoryBeforeBuild() + { + using var sourceRoot = TemporaryDirectory.Create(); + using var outputRoot = TemporaryDirectory.Create(); + Assert.False(File.Exists(Path.Combine(sourceRoot.Path, "Dockerfile"))); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", outputRoot.Path]); + builder.AddVercelEnvironment("vercel"); + builder.AddDockerfileFactory("api", sourceRoot.Path, _ => Task.FromResult(""" + FROM nginx:alpine + COPY index.html /usr/share/nginx/html/index.html + """)); + File.WriteAllText(Path.Combine(sourceRoot.Path, "index.html"), "hello"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + + string planPath = await VercelDeploymentStep.WriteDeploymentPlanAsync(model, environment, outputRoot.Path, TestContext.Current.CancellationToken); + + Assert.True(File.Exists(planPath)); + using var document = JsonDocument.Parse(File.ReadAllText(planPath)); + var deployment = Assert.Single(document.RootElement.GetProperty("deployments").EnumerateArray()); + Assert.Equal("", deployment.GetProperty("dockerfilePath").GetString()); + } + + [Fact] + public async Task WriteDeploymentPlanThrowsWhenSourceRootIsMissing() + { + using var outputRoot = TemporaryDirectory.Create(); + string sourceRoot = Path.Combine(Path.GetTempPath(), $"missing-vercel-tests-{Guid.NewGuid():N}"); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", outputRoot.Path]); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.WriteDeploymentPlanAsync(model, environment, outputRoot.Path, TestContext.Current.CancellationToken)); + + Assert.Contains(sourceRoot, exception.Message); + Assert.Contains("does not exist", exception.Message); + } + + [Fact] + public async Task WriteDeploymentPlanThrowsWhenContainerHasNoSourceRoot() + { + using var outputRoot = TemporaryDirectory.Create(); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", outputRoot.Path]); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.WriteDeploymentPlanAsync(model, environment, outputRoot.Path, TestContext.Current.CancellationToken)); + + Assert.Contains("is not an Aspire image build resource", exception.Message); + Assert.Contains("WithDockerfile", exception.Message); + } + + [Fact] + public async Task WriteDeploymentPlanThrowsForContainerMounts() + { + var exception = await AssertWriteDeploymentPlanThrowsAsync(static api => + api.WithVolume("/data")); + + Assert.Contains("volumes or bind mounts", exception.Message); + } + + [Fact] + public async Task WriteDeploymentPlanThrowsForContainerFiles() + { + var exception = await AssertWriteDeploymentPlanThrowsAsync(static api => + api.Resource.Annotations.Add(new ContainerFilesSourceAnnotation { SourcePath = "." })); + + Assert.Contains("container file mounts", exception.Message); + } + + [Fact] + public async Task WriteDeploymentPlanThrowsForProbes() + { + var exception = await AssertWriteDeploymentPlanThrowsAsync(static api => + api.WithEndpoint(targetPort: 8080, scheme: "http", name: "http", isExternal: true) + .WithHttpProbe(ProbeType.Readiness, path: "/health")); + + Assert.Contains("container probes", exception.Message); + } + + [Fact] + public async Task WriteDeploymentPlanIgnoresHealthChecks() + { + await AssertWriteDeploymentPlanSucceedsAsync(static api => + api.WithHealthCheck("api-health")); + } + + [Fact] + public async Task WriteDeploymentPlanIgnoresReplicas() + { + await AssertWriteDeploymentPlanSucceedsAsync(static api => + api.Resource.Annotations.Add(new ReplicaAnnotation(2))); + } + + [Fact] + public async Task WriteDeploymentPlanIgnoresWaitDependencies() + { + using var sourceRoot = TemporaryDirectory.Create(); + using var outputRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", outputRoot.Path]); + builder.AddVercelEnvironment("vercel"); + var backend = builder.AddContainer("backend", "backend") + .WithDockerfile(sourceRoot.Path, "Dockerfile") + .WithEndpoint(targetPort: 8080, scheme: "http", name: "http", isExternal: true) + .WithVercelProjectName("backend"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile") + .WithEndpoint(targetPort: 8080, scheme: "http", name: "http", isExternal: true) + .WithVercelProjectName("api") + .WaitFor(backend); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + + string planPath = await VercelDeploymentStep.WriteDeploymentPlanAsync(model, environment, outputRoot.Path, TestContext.Current.CancellationToken); + + using var document = JsonDocument.Parse(File.ReadAllText(planPath)); + var deployments = document.RootElement.GetProperty("deployments") + .EnumerateArray() + .Select(static deployment => deployment.GetProperty("resourceName").GetString()!) + .Order(StringComparer.Ordinal) + .ToArray(); + + Assert.Equal(["api", "backend"], deployments); + } + + [Fact] + public async Task WriteDeploymentPlanThrowsForGenericContainerRegistry() + { + using var sourceRoot = TemporaryDirectory.Create(); + using var outputRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", outputRoot.Path]); + builder.AddVercelEnvironment("vercel"); + var registry = builder.AddContainerRegistry("registry", "registry.example.com"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile") + .WithContainerRegistry(registry); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.WriteDeploymentPlanAsync(model, environment, outputRoot.Path, TestContext.Current.CancellationToken)); + + Assert.Contains("generic container registry/image push metadata", exception.Message); + Assert.Contains("WithContainerRegistry", exception.Message); + } + + [Fact] + public async Task WriteDeploymentPlanThrowsForMultipleEndpointTargetPorts() + { + var exception = await AssertWriteDeploymentPlanThrowsAsync(static api => + api.WithEndpoint(targetPort: 8080, scheme: "http", name: "http", isExternal: true) + .WithEndpoint(targetPort: 8081, scheme: "http", name: "admin", isExternal: true)); + + Assert.Contains("multiple Aspire endpoint target ports", exception.Message); + } + + [Fact] + public async Task WriteDeploymentPlanThrowsForNonHttpEndpoints() + { + var exception = await AssertWriteDeploymentPlanThrowsAsync(static api => + api.WithEndpoint(targetPort: 6379, scheme: "tcp", name: "tcp", isExternal: true)); + + Assert.Contains("support only HTTP or HTTPS endpoints", exception.Message); + } + + [Theory] + [InlineData("http")] + [InlineData("https")] + public async Task WriteDeploymentPlanAllowsExternalHttpEndpoints(string scheme) + { + await AssertWriteDeploymentPlanSucceedsAsync(api => + api.WithEndpoint(targetPort: 8080, scheme: scheme, name: scheme, isExternal: true)); + } + + [Fact] + public async Task WriteDeploymentPlanAllowsSingleResourceInternalHttpEndpoint() + { + await AssertWriteDeploymentPlanSucceedsAsync(static api => + api.WithEndpoint(targetPort: 8080, scheme: "http", name: "http", isExternal: false)); + } + + [Fact] + public void ProjectGrouperKeepsMultiplePublicResourcesInSeparateProjects() + { + var api = CreateGroupingEntry("api", sourceRoot: "/tmp/api", isExternal: true); + var admin = CreateGroupingEntry("admin", sourceRoot: "/tmp/admin", isExternal: true); + + var projectMap = VercelDeploymentProjectGrouper.CreateMap( + [api, admin], + new Dictionary(StringComparer.Ordinal) + { + ["api"] = [], + ["admin"] = [] + }); + + Assert.Collection( + projectMap.Groups.OrderBy(static group => group.Root.ServiceName), + group => + { + Assert.Equal("admin", group.Root.ServiceName); + Assert.Equal(["admin"], group.Services.Select(static service => service.ServiceName).ToArray()); + }, + group => + { + Assert.Equal("api", group.Root.ServiceName); + Assert.Equal(["api"], group.Services.Select(static service => service.ServiceName).ToArray()); + }); + } + + [Fact] + public void ProjectGrouperPlacesReferencedInternalResourceInPublicProject() + { + var api = CreateGroupingEntry("api", sourceRoot: "/tmp/api", isExternal: true); + var backend = CreateGroupingEntry("backend", sourceRoot: "/tmp/backend", isExternal: false); + + var projectMap = VercelDeploymentProjectGrouper.CreateMap( + [api, backend], + new Dictionary(StringComparer.Ordinal) + { + ["api"] = ["backend"], + ["backend"] = [] + }); + + var group = Assert.Single(projectMap.Groups); + Assert.Equal("api", group.Root.ServiceName); + Assert.Collection( + group.Services, + service => + { + Assert.Equal("api", service.ServiceName); + Assert.True(service.IsPublicRoot); + }, + service => + { + Assert.Equal("backend", service.ServiceName); + Assert.False(service.IsPublicRoot); + }); + } + + [Fact] + public void ProjectGrouperThrowsForSharedInternalResource() + { + var api = CreateGroupingEntry("api", sourceRoot: "/tmp/api", isExternal: true); + var admin = CreateGroupingEntry("admin", sourceRoot: "/tmp/admin", isExternal: true); + var backend = CreateGroupingEntry("backend", sourceRoot: "/tmp/backend", isExternal: false); + + var exception = Assert.Throws(() => + VercelDeploymentProjectGrouper.CreateMap( + [api, admin, backend], + new Dictionary(StringComparer.Ordinal) + { + ["api"] = ["backend"], + ["admin"] = ["backend"], + ["backend"] = [] + })); + + Assert.Contains("referenced by multiple public Vercel project roots", exception.Message); + Assert.Contains("'api'", exception.Message); + Assert.Contains("'admin'", exception.Message); + } + + [Fact] + public void ProjectGrouperThrowsForInternalResourceWithoutPublicOwner() + { + var api = CreateGroupingEntry("api", sourceRoot: "/tmp/api", isExternal: true); + var backend = CreateGroupingEntry("backend", sourceRoot: "/tmp/backend", isExternal: false); + + var exception = Assert.Throws(() => + VercelDeploymentProjectGrouper.CreateMap( + [api, backend], + new Dictionary(StringComparer.Ordinal) + { + ["api"] = [], + ["backend"] = [] + })); + + Assert.Contains("is not public and is not referenced by any public Vercel project root", exception.Message); + } + + [Fact] + public void ProjectGrouperThrowsForInternalProjectNameOverride() + { + var api = CreateGroupingEntry("api", sourceRoot: "/tmp/api", isExternal: true); + var backend = CreateGroupingEntry("backend", sourceRoot: "/tmp/backend", isExternal: false); + backend.Resource.Annotations.Add(new VercelProjectOptionsAnnotation("backend-project")); + + var exception = Assert.Throws(() => + VercelDeploymentProjectGrouper.CreateMap( + [api, backend], + new Dictionary(StringComparer.Ordinal) + { + ["api"] = ["backend"], + ["backend"] = [] + })); + + Assert.Contains("WithVercelProjectName can only be applied to public Vercel project roots", exception.Message); + } + + [Fact] + public void ProjectGrouperThrowsForDuplicateServiceNames() + { + var first = CreateGroupingEntry("my.api", sourceRoot: "/tmp/first", isExternal: true); + var second = CreateGroupingEntry("my_api", sourceRoot: "/tmp/second", isExternal: true); + + var exception = Assert.Throws(() => + VercelDeploymentProjectGrouper.CreateMap( + [first, second], + new Dictionary(StringComparer.Ordinal) + { + ["my.api"] = [], + ["my_api"] = [] + })); + + Assert.Contains("service name 'my-api'", exception.Message); + } + + [Fact] + public async Task WriteDeploymentPlanThrowsForNonHttpEndpointTransports() + { + var exception = await AssertWriteDeploymentPlanThrowsAsync(static api => + api.Resource.Annotations.Add(new EndpointAnnotation( + ProtocolType.Tcp, + uriScheme: "http", + transport: "tcp", + name: "tcp-http", + targetPort: 8080, + isExternal: true))); + + Assert.Contains("transport 'tcp'", exception.Message); + Assert.Contains("HTTP transports", exception.Message); + } + + [Fact] + public async Task WriteDeploymentPlanThrowsForManagedProjectNameCollisions() + { + using var parent = TemporaryDirectory.Create(); + using var outputRoot = TemporaryDirectory.Create(); + string firstRoot = Path.Combine(parent.Path, "my.api"); + string secondRoot = Path.Combine(parent.Path, "my_api"); + Directory.CreateDirectory(firstRoot); + Directory.CreateDirectory(secondRoot); + File.WriteAllText(Path.Combine(firstRoot, "Dockerfile"), "FROM nginx:alpine"); + File.WriteAllText(Path.Combine(secondRoot, "Dockerfile"), "FROM nginx:alpine"); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", outputRoot.Path]); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(firstRoot, "Dockerfile") + .WithEndpoint(targetPort: 8080, scheme: "http", name: "http", isExternal: true); + builder.AddContainer("worker", "worker") + .WithDockerfile(secondRoot, "Dockerfile") + .WithEndpoint(targetPort: 8080, scheme: "http", name: "http", isExternal: true); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.WriteDeploymentPlanAsync(model, environment, outputRoot.Path, TestContext.Current.CancellationToken)); + + Assert.Contains("project name 'my-api'", exception.Message); + Assert.Contains("'api'", exception.Message); + Assert.Contains("'worker'", exception.Message); + } + + [Fact] + public async Task WriteDeploymentPlanThrowsForLinkedProjectNameCollisions() + { + using var parent = TemporaryDirectory.Create(); + using var outputRoot = TemporaryDirectory.Create(); + string firstRoot = Path.Combine(parent.Path, "api"); + string secondRoot = Path.Combine(parent.Path, "worker"); + Directory.CreateDirectory(firstRoot); + Directory.CreateDirectory(secondRoot); + File.WriteAllText(Path.Combine(firstRoot, "Dockerfile"), "FROM nginx:alpine"); + File.WriteAllText(Path.Combine(secondRoot, "Dockerfile"), "FROM nginx:alpine"); + WriteVercelProjectLink(firstRoot, "shared-project", "prj_shared_a"); + WriteVercelProjectLink(secondRoot, "shared-project", "prj_shared_b"); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", outputRoot.Path]); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(firstRoot, "Dockerfile") + .WithEndpoint(targetPort: 8080, scheme: "http", name: "http", isExternal: true); + builder.AddContainer("worker", "worker") + .WithDockerfile(secondRoot, "Dockerfile") + .WithEndpoint(targetPort: 8080, scheme: "http", name: "http", isExternal: true); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.WriteDeploymentPlanAsync(model, environment, outputRoot.Path, TestContext.Current.CancellationToken)); + + Assert.Contains("project name 'shared-project'", exception.Message); + Assert.Contains("'api'", exception.Message); + Assert.Contains("'worker'", exception.Message); + } + + [Fact] + public async Task WriteDeploymentPlanThrowsForConfiguredProjectNameCollisions() + { + using var parent = TemporaryDirectory.Create(); + using var outputRoot = TemporaryDirectory.Create(); + string firstRoot = Path.Combine(parent.Path, "api"); + string secondRoot = Path.Combine(parent.Path, "worker"); + Directory.CreateDirectory(firstRoot); + Directory.CreateDirectory(secondRoot); + File.WriteAllText(Path.Combine(firstRoot, "Dockerfile"), "FROM nginx:alpine"); + File.WriteAllText(Path.Combine(secondRoot, "Dockerfile"), "FROM nginx:alpine"); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", outputRoot.Path]); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(firstRoot, "Dockerfile") + .WithEndpoint(targetPort: 8080, scheme: "http", name: "http", isExternal: true) + .WithVercelProjectName("shared-project"); + builder.AddContainer("worker", "worker") + .WithDockerfile(secondRoot, "Dockerfile") + .WithEndpoint(targetPort: 8080, scheme: "http", name: "http", isExternal: true) + .WithVercelProjectName("shared-project"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.WriteDeploymentPlanAsync(model, environment, outputRoot.Path, TestContext.Current.CancellationToken)); + + Assert.Contains("project name 'shared-project'", exception.Message); + Assert.Contains("WithVercelProjectName", exception.Message); + Assert.Contains("'api'", exception.Message); + Assert.Contains("'worker'", exception.Message); + } + + [Fact] + public async Task DeployAsyncUsesOriginalSourceRootWithSlugifiedProjectName() + { + using var sourceRoot = TemporaryDirectory.Create("Invalid_Project"); + using var outputRoot = TemporaryDirectory.Create(); + using var tempRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + var runner = new FakeVercelCliRunner( + new VercelCliResult(0, "https://invalid-project.vercel.app", ""), + ReadyInspectResult()); + var stateManager = new FakeDeploymentStateManager(); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); + builder.Services.AddSingleton(stateManager); + builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile"); + + using var app = builder.Build(); + var environment = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + await VercelDeploymentStep.DeployAsync(context, environment); + + var digestInvocation = Assert.Single(runner.Invocations, invocation => invocation.FileName == "docker" && invocation.Arguments.Contains("imagetools")); + Assert.Null(digestInvocation.WorkingDirectory); + Assert.StartsWith("vcr.vercel.com/test-team/test-project/api:", digestInvocation.Arguments[^1], StringComparison.Ordinal); + var deployInvocation = Assert.Single(runner.Invocations, invocation => invocation.Arguments.Contains("deploy")); + Assert.Contains("--project", deployInvocation.Arguments); + Assert.Contains("invalid-project", deployInvocation.Arguments); + + var deployment = Assert.Single(ReadSavedState(stateManager.SavedSections[^1]).Deployments); + Assert.Equal("invalid-project", deployment.ProjectName); + } + + [Fact] + public async Task DeployAsyncUsesGeneratedLocalConfigWithConfiguredProjectName() + { + using var sourceRoot = TemporaryDirectory.Create("source-folder"); + using var outputRoot = TemporaryDirectory.Create(); + using var tempRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + var runner = new FakeVercelCliRunner( + new VercelCliResult(0, "https://configured-project.vercel.app", ""), + ReadyInspectResult()); + var stateManager = new FakeDeploymentStateManager(); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); + builder.Services.AddSingleton(stateManager); + builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile") + .WithVercelProjectName("configured-project"); + + using var app = builder.Build(); + var environment = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + await VercelDeploymentStep.DeployAsync(context, environment); + + string expectedDeployDirectory = Path.Combine(tempRoot.Path, "api", "vercel-build-output"); + AssertGeneratedBuildOutput(expectedDeployDirectory); + Assert.False(File.Exists(Path.Combine(sourceRoot.Path, "vercel.json"))); + Assert.Contains(runner.Invocations, invocation => invocation.Arguments.Contains("link")); + var deployInvocation = Assert.Single(runner.Invocations, invocation => invocation.Arguments.Contains("deploy")); + Assert.Equal(expectedDeployDirectory, deployInvocation.WorkingDirectory); + Assert.Contains("--project", deployInvocation.Arguments); + Assert.Contains("configured-project", deployInvocation.Arguments); + Assert.DoesNotContain("--local-config", deployInvocation.Arguments); + + var state = ReadSavedState(stateManager.SavedSections[^1]); + var deployment = Assert.Single(state.Deployments); + Assert.Equal("configured-project", deployment.ProjectName); + Assert.True(deployment.ManagedByAspire); + } + + [Fact] + public async Task DeployPrebuiltUsesConfiguredOptions() + { + var options = new VercelEnvironmentOptionsAnnotation + { + Production = true, + Scope = "team" + }; + var runner = new FakeVercelCliRunner(); + + await runner.DeployPrebuiltAsync(options, "/repo/src/api", "api", [], TestContext.Current.CancellationToken); + + var invocation = Assert.Single(runner.Invocations); + Assert.Equal("vercel", invocation.FileName); + Assert.Equal("/repo/src/api", invocation.WorkingDirectory); + Assert.Equal(["deploy", "--scope", "team", "--cwd", "/repo/src/api", "--project", "api", "--prebuilt", "--yes", "--prod"], invocation.Arguments); + } + + [Fact] + public async Task InspectDockerImageDigestUsesBuildxImagetools() + { + var runner = new FakeVercelCliRunner(); + + await runner.InspectDockerImageDigestAsync("vcr.vercel.com/team/project/app:tag", TestContext.Current.CancellationToken); + + var invocation = Assert.Single(runner.Invocations); + Assert.Equal("docker", invocation.FileName); + Assert.Equal(["buildx", "imagetools", "inspect", "--format", "{{json .Manifest}}", "vcr.vercel.com/team/project/app:tag"], invocation.Arguments); + } + + [Fact] + public void GetDockerImageDigestParsesJsonString() + { + string digest = VercelDockerImageDigestParser.GetDigest($"\"{FakeVercelCliRunner.FakeImageDigest}\""); + + Assert.Equal(FakeVercelCliRunner.FakeImageDigest, digest); + } + + [Fact] + public void GetDockerImageDigestSelectsLinuxAmd64ManifestDigest() + { + string digest = VercelDockerImageDigestParser.GetDigest(FakeVercelCliRunner.FakeImageManifestJson); + + Assert.Equal(FakeVercelCliRunner.FakeImageDigest, digest); + } + + [Fact] + public void GetDockerImageDigestThrowsWhenManifestDoesNotContainLinuxAmd64Digest() + { + const string manifestJson = """ + { + "schemaVersion": 2, + "manifests": [ + { + "digest": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "platform": { + "architecture": "arm64", + "os": "linux" + } + } + ] + } + """; + + var exception = Assert.Throws(() => + VercelDockerImageDigestParser.GetDigest(manifestJson)); + + Assert.Contains("linux/amd64 manifest digest", exception.Message); + } + + [Fact] + public void DecodeUnvalidatedOidcClaimsParsesCompactJwtPayload() + { + string token = CreateTestJwt(""" + { + "owner_id": "team_123", + "owner": "test-team", + "project": "test-project", + "project_id": "prj_123" + } + """); + + var claims = VercelOidcToken.DecodeUnvalidatedClaims(token); + + Assert.Equal("team_123", claims.OwnerId); + Assert.Equal("test-team", claims.Owner); + Assert.Equal("test-project", claims.Project); + Assert.Equal("prj_123", claims.ProjectId); + } + + [Fact] + public void DecodeUnvalidatedOidcClaimsRejectsNonCompactJwt() + { + var exception = Assert.Throws(() => + VercelOidcToken.DecodeUnvalidatedClaims("not-a-jwt")); + + Assert.Contains("compact JWT", exception.Message); + } + + [Fact] + public void ParseDotEnvFileParsesVercelPullOutputSubset() + { + var values = VercelDotEnvParser.Parse( + [ + "", + "# comment", + "VERCEL_OIDC_TOKEN=\"header.payload.signature\"", + "MULTILINE=\"line1\\nline2\"", + "QUOTED='single quoted'", + "ESCAPED=\"quote\\\"slash\\\\\"", + "MALFORMED" + ]); + + Assert.Equal("header.payload.signature", values["VERCEL_OIDC_TOKEN"]); + Assert.Equal("line1\nline2", values["MULTILINE"]); + Assert.Equal("single quoted", values["QUOTED"]); + Assert.Equal("quote\"slash\\", values["ESCAPED"]); + Assert.DoesNotContain("MALFORMED", values.Keys); + } + + [Fact] + public void ProjectListContainsProjectUsesExactJsonProjectName() + { + Assert.True(VercelCliOutputParser.ProjectListContainsProject(ProjectListJson("api", "worker"), "api")); + Assert.False(VercelCliOutputParser.ProjectListContainsProject(ProjectListJson("api-preview"), "api")); + } + + [Fact] + public void EnvironmentVariableListContainsNameUsesExactJsonKeyAndSkipsBranchScopedVariables() + { + const string output = """ + { + "envs": [ + { + "key": "API_KEY", + "target": ["preview"], + "gitBranch": "feature" + }, + { + "key": "OLD_KEY", + "target": ["preview"] + } + ] + } + """; + + Assert.False(VercelCliOutputParser.EnvironmentVariableListContainsName(output, "API_KEY")); + Assert.True(VercelCliOutputParser.EnvironmentVariableListContainsName(output, "OLD_KEY")); + Assert.False(VercelCliOutputParser.EnvironmentVariableListContainsName(output, "KEY")); + } + + [Fact] + public async Task ProjectEnvironmentVariableOperationsUseLinkedScratchDirectory() + { + var options = new VercelEnvironmentOptionsAnnotation + { + Scope = "team" + }; + var runner = new FakeVercelCliRunner(); + + await runner.LinkProjectAsync(options, "/tmp/vercel-link", "api-project", TestContext.Current.CancellationToken); + await runner.AddProjectEnvironmentVariableAsync(options, "/tmp/vercel-link", "API_KEY", "production", "secret", TestContext.Current.CancellationToken); + await runner.RemoveProjectEnvironmentVariableAsync(options, "/tmp/vercel-link", "API_KEY", "production", TestContext.Current.CancellationToken); + await runner.ListProjectEnvironmentVariablesAsync(options, "/tmp/vercel-link", "production", TestContext.Current.CancellationToken); + + Assert.Equal(["link", "--scope", "team", "--cwd", "/tmp/vercel-link", "--yes", "--project", "api-project"], runner.Invocations[0].Arguments); + Assert.Equal(["env", "add", "API_KEY", "production", "--scope", "team", "--cwd", "/tmp/vercel-link", "--yes", "--force", "--sensitive"], runner.Invocations[1].Arguments); + Assert.Equal("secret", runner.Invocations[1].StandardInput); + Assert.Equal(["env", "rm", "API_KEY", "production", "--scope", "team", "--cwd", "/tmp/vercel-link", "--yes"], runner.Invocations[2].Arguments); + Assert.Equal(["env", "ls", "production", "--scope", "team", "--cwd", "/tmp/vercel-link", "--format=json"], runner.Invocations[3].Arguments); + Assert.DoesNotContain("--project", runner.Invocations[1].Arguments); + } + + [Fact] + public async Task DeployPrebuiltIncludesTarget() + { + var options = new VercelEnvironmentOptionsAnnotation + { + Target = "preview" + }; + var runner = new FakeVercelCliRunner(); + + await runner.DeployPrebuiltAsync(options, "/repo/src/api", "api", [], TestContext.Current.CancellationToken); + + var invocation = Assert.Single(runner.Invocations); + Assert.Equal(["deploy", "--cwd", "/repo/src/api", "--project", "api", "--prebuilt", "--yes", "--target", "preview"], invocation.Arguments); + } + + [Fact] + public async Task RemoveProjectIncludesConfiguredOptions() + { + var options = new VercelEnvironmentOptionsAnnotation + { + Scope = "team" + }; + var runner = new FakeVercelCliRunner(); + + await runner.RemoveProjectAsync(options, "api", TestContext.Current.CancellationToken); + + var invocation = Assert.Single(runner.Invocations); + Assert.Equal(["project", "remove", "api", "--scope", "team"], invocation.Arguments); + Assert.Equal("y\n", invocation.StandardInput); + } + + [Fact] + public async Task ListProjectsWithoutFilterIncludesConfiguredOptions() + { + var options = new VercelEnvironmentOptionsAnnotation + { + Scope = "team" + }; + var runner = new FakeVercelCliRunner(); + + await runner.ListProjectsAsync(options, filter: null, TestContext.Current.CancellationToken); + + var invocation = Assert.Single(runner.Invocations); + Assert.Equal(["project", "ls", "--scope", "team", "--format=json"], invocation.Arguments); + } + + [Fact] + public async Task ListProjectsIncludesFilter() + { + var options = new VercelEnvironmentOptionsAnnotation + { + Scope = "team" + }; + var runner = new FakeVercelCliRunner(); + + await runner.ListProjectsAsync(options, "api", TestContext.Current.CancellationToken); + + var invocation = Assert.Single(runner.Invocations); + Assert.Equal(["project", "ls", "--scope", "team", "--filter", "api", "--format=json"], invocation.Arguments); + } + + [Fact] + public async Task InspectDeploymentIncludesConfiguredOptions() + { + var options = new VercelEnvironmentOptionsAnnotation + { + Scope = "team" + }; + var runner = new FakeVercelCliRunner(); + + await runner.InspectDeploymentAsync(options, "https://api.vercel.app", TestContext.Current.CancellationToken); + + var invocation = Assert.Single(runner.Invocations); + Assert.Equal(["inspect", "https://api.vercel.app", "--scope", "team", "--wait", "--timeout", "120s", "--format=json"], invocation.Arguments); + } + + [Fact] + public async Task BuildDeployArgumentsProcessesEnvironmentVariables() + { + using var sourceRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile") + .WithEnvironment("GREETING", "hello"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var entry = Assert.Single(VercelDeploymentModel.GetEntries(model, environment)); + + string[] arguments = await VercelDeploymentPlanWriter.BuildDeployArgumentsAsync( + builder.ExecutionContext, + NullLogger.Instance, + environment.GetVercelOptions(), + entry, + TestContext.Current.CancellationToken); + + Assert.Contains("--env", arguments); + Assert.Contains("GREETING=hello", arguments); + } + + [Fact] + public async Task BuildDeployArgumentsAllowsNonSecretParameterEnvironmentVariables() + { + using var sourceRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); + var region = builder.AddParameter("region", "iad"); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile") + .WithEnvironment("REGION", region); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var entry = Assert.Single(VercelDeploymentModel.GetEntries(model, environment)); + + string[] arguments = await VercelDeploymentPlanWriter.BuildDeployArgumentsAsync( + builder.ExecutionContext, + NullLogger.Instance, + environment.GetVercelOptions(), + entry, + TestContext.Current.CancellationToken); + + Assert.Contains("--env", arguments); + Assert.Contains(arguments, argument => argument.StartsWith("REGION=", StringComparison.Ordinal)); + } + + [Fact] + public async Task BuildDeployArgumentsThrowsForContainerEntrypoint() + { + using var sourceRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); + builder.AddVercelEnvironment("vercel"); + var api = builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile"); + api.Resource.Entrypoint = "node"; + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var entry = Assert.Single(VercelDeploymentModel.GetEntries(model, environment)); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentPlanWriter.BuildDeployArgumentsAsync( + builder.ExecutionContext, + NullLogger.Instance, + environment.GetVercelOptions(), + entry, + TestContext.Current.CancellationToken)); + + Assert.Contains("entrypoint", exception.Message); + } + + [Fact] + public async Task BuildDeployArgumentsDoesNotPassSecretEnvironmentVariablesOnCommandLine() + { + using var sourceRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); + var secret = builder.AddParameter("api-key", "secret-value", secret: true); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile") + .WithEnvironment("API_KEY", secret); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var entry = Assert.Single(VercelDeploymentModel.GetEntries(model, environment)); + + string[] arguments = await VercelDeploymentPlanWriter.BuildDeployArgumentsAsync( + builder.ExecutionContext, + NullLogger.Instance, + environment.GetVercelOptions(), + entry, + TestContext.Current.CancellationToken); + + Assert.DoesNotContain(arguments, argument => argument.Contains("API_KEY", StringComparison.Ordinal)); + } + + [Fact] + public async Task DeployAsyncConfiguresSecretEnvironmentVariablesBeforeDeploy() + { + using var sourceRoot = TemporaryDirectory.Create(); + using var outputRoot = TemporaryDirectory.Create(); + using var tempRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + var runner = new FakeVercelCliRunner( + new VercelCliResult(0, """ + { + "deployment": { + "id": "dpl_secret", + "url": "https://secret-project.vercel.app" + } + } + """, ""), + ReadyInspectResult()); + var stateManager = new FakeDeploymentStateManager(); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); + var secret = builder.AddParameter("api-key", "secret-value", secret: true); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); + builder.Services.AddSingleton(stateManager); + builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); + builder.AddVercelEnvironment("vercel") + .WithVercelProductionDeployments(); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile") + .WithEnvironment("AUTH_HEADER", ReferenceExpression.Create($"Bearer {secret.Resource}")) + .WithEnvironment("API_KEY", secret) + .WithEnvironment("GREETING", "hello"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var entry = Assert.Single(VercelDeploymentModel.GetEntries(model, environment)); + string projectName = VercelProjectNameResolver.GetProjectName(entry); + var context = CreatePipelineStepContext(builder, app); + + await VercelDeploymentStep.DeployAsync(context, environment); + + string expectedProjectLinkDirectory = Path.Combine(tempRoot.Path, "api", ".vercel-project"); + string expectedDeployDirectory = Path.Combine(tempRoot.Path, "api", "vercel-build-output"); + Assert.Contains(runner.Invocations, invocation => invocation.Arguments.SequenceEqual(["project", "add", projectName])); + var linkInvocation = Assert.Single(runner.Invocations, invocation => invocation.Arguments.Contains("link")); + Assert.Equal(["link", "--cwd", expectedProjectLinkDirectory, "--yes", "--project", projectName], linkInvocation.Arguments); + Assert.Equal(expectedProjectLinkDirectory, linkInvocation.WorkingDirectory); + var apiKeyInvocation = Assert.Single(runner.Invocations, invocation => invocation.Arguments.Contains("API_KEY")); + Assert.Equal(["env", "add", "API_KEY", "production", "--cwd", expectedProjectLinkDirectory, "--yes", "--force", "--sensitive"], apiKeyInvocation.Arguments); + Assert.Equal("secret-value", apiKeyInvocation.StandardInput); + Assert.Equal(expectedProjectLinkDirectory, apiKeyInvocation.WorkingDirectory); + var authHeaderInvocation = Assert.Single(runner.Invocations, invocation => invocation.Arguments.Contains("AUTH_HEADER")); + Assert.Equal(["env", "add", "AUTH_HEADER", "production", "--cwd", expectedProjectLinkDirectory, "--yes", "--force", "--sensitive"], authHeaderInvocation.Arguments); + Assert.Equal("Bearer secret-value", authHeaderInvocation.StandardInput); + Assert.Equal(expectedProjectLinkDirectory, authHeaderInvocation.WorkingDirectory); + Assert.Contains(runner.Invocations, invocation => invocation.Arguments.SequenceEqual(["pull", "--cwd", expectedProjectLinkDirectory, "--yes", "--environment", "production"])); + Assert.Contains(runner.Invocations, invocation => invocation.FileName == "docker" && invocation.Arguments.SequenceEqual(["login", "vcr.vercel.com", "--username", "team_test", "--password-stdin"])); + Assert.Contains(runner.Invocations, invocation => invocation.FileName == "docker" && invocation.Arguments.Contains("imagetools")); + AssertGeneratedBuildOutput(expectedDeployDirectory, expectedEnvironmentVariables: [new("GREETING", "hello")]); + var deployInvocation = Assert.Single(runner.Invocations, invocation => invocation.Arguments.Contains("deploy")); + Assert.DoesNotContain("--env", deployInvocation.Arguments); + Assert.DoesNotContain(deployInvocation.Arguments, argument => argument.Contains("API_KEY", StringComparison.Ordinal)); + Assert.DoesNotContain(deployInvocation.Arguments, argument => argument.Contains("AUTH_HEADER", StringComparison.Ordinal)); + + var deployment = Assert.Single(ReadSavedState(stateManager.SavedSections[^1]).Deployments); + Assert.Equal(["API_KEY", "AUTH_HEADER"], deployment.ProjectEnvironmentVariables); + Assert.DoesNotContain("secret-value", JsonSerializer.Serialize(ReadSavedState(stateManager.SavedSections[^1])), StringComparison.Ordinal); + } + + [Fact] + public async Task DeployAsyncRemovesStaleProjectEnvironmentVariablesFromPreviousState() + { + using var sourceRoot = TemporaryDirectory.Create("stale-env-project"); + using var outputRoot = TemporaryDirectory.Create(); + using var tempRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + var runner = new FakeVercelCliRunner( + new VercelCliResult(0, "https://stale-env-project.vercel.app", ""), + ReadyInspectResult()); + var stateManager = new FakeDeploymentStateManager(); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); + var secret = builder.AddParameter("api-key", "secret-value", secret: true); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); + builder.Services.AddSingleton(stateManager); + builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile") + .WithEnvironment("API_KEY", secret); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var entry = Assert.Single(VercelDeploymentModel.GetEntries(model, environment)); + string projectName = VercelProjectNameResolver.GetProjectName(entry); + stateManager.SetSection("communitytoolkit.vercel.vercel", JsonSerializer.Serialize(new VercelDeploymentState( + SchemaVersion: 1, + Environment: "vercel", + Scope: null, + Target: null, + Production: false, + Deployments: + [ + new( + ResourceName: "api", + ProjectName: projectName, + ProjectId: null, + DeploymentId: "dpl_previous", + DeploymentUrl: "https://previous.vercel.app", + SourceRoot: sourceRoot.Path, + ManagedByAspire: true) + { + ProjectEnvironmentVariables = ["API_KEY", "OLD_KEY"] + } + ]), new JsonSerializerOptions(JsonSerializerDefaults.Web))); + var context = CreatePipelineStepContext(builder, app); + + await VercelDeploymentStep.DeployAsync(context, environment); + + string expectedProjectLinkDirectory = Path.Combine(tempRoot.Path, "api", ".vercel-project"); + Assert.Contains(runner.Invocations, invocation => invocation.Arguments.SequenceEqual(["env", "rm", "OLD_KEY", "preview", "--cwd", expectedProjectLinkDirectory, "--yes"])); + Assert.DoesNotContain(runner.Invocations, invocation => invocation.Arguments.SequenceEqual(["env", "rm", "API_KEY", "preview", "--cwd", expectedProjectLinkDirectory, "--yes"])); + var deployment = Assert.Single(ReadSavedState(stateManager.SavedSections[^1]).Deployments); + Assert.Equal(["API_KEY"], deployment.ProjectEnvironmentVariables); + } + + [Fact] + public async Task ValidatePrerequisitesPreservesPreviousProjectEnvironmentVariablesUntilDeploySucceeds() + { + using var sourceRoot = TemporaryDirectory.Create("retry-safe-env-project"); + using var outputRoot = TemporaryDirectory.Create(); + using var tempRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + var runner = new FakeVercelCliRunner(); + var stateManager = new FakeDeploymentStateManager(); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); + var secret = builder.AddParameter("api-key", "secret-value", secret: true); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); + builder.Services.AddSingleton(stateManager); + builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile") + .WithEnvironment("API_KEY", secret); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var entry = Assert.Single(VercelDeploymentModel.GetEntries(model, environment)); + string projectName = VercelProjectNameResolver.GetProjectName(entry); + stateManager.SetSection("communitytoolkit.vercel.vercel", JsonSerializer.Serialize(new VercelDeploymentState( + SchemaVersion: 1, + Environment: "vercel", + Scope: null, + Target: null, + Production: false, + Deployments: + [ + new( + ResourceName: "api", + ProjectName: projectName, + ProjectId: null, + DeploymentId: "dpl_previous", + DeploymentUrl: "https://previous.vercel.app", + SourceRoot: sourceRoot.Path, + ManagedByAspire: true) + { + ProjectEnvironmentVariables = ["API_KEY", "OLD_KEY"] + } + ]), new JsonSerializerOptions(JsonSerializerDefaults.Web))); + var context = CreatePipelineStepContext(builder, app); + + await VercelDeploymentStep.ValidatePrerequisitesAsync(context, environment); + + string expectedProjectLinkDirectory = Path.Combine(tempRoot.Path, "api", ".vercel-project"); + Assert.Contains(runner.Invocations, invocation => invocation.Arguments.SequenceEqual(["env", "rm", "OLD_KEY", "preview", "--cwd", expectedProjectLinkDirectory, "--yes"])); + var initialState = ReadSavedState(Assert.Single(stateManager.SavedSections)); + var deployment = Assert.Single(initialState.Deployments); + Assert.Equal(["API_KEY", "OLD_KEY"], deployment.ProjectEnvironmentVariables); + } + + [Fact] + public async Task BuildDeployArgumentsDoesNotPassConnectionStringEnvironmentVariablesOnCommandLine() + { + using var sourceRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); + builder.Configuration["ConnectionStrings:db"] = "Host=example.com;Username=app;Password=secret"; + var connectionString = builder.AddConnectionString("db"); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile") + .WithEnvironment("DATABASE_URL", connectionString); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var entry = Assert.Single(VercelDeploymentModel.GetEntries(model, environment)); + + string[] arguments = await VercelDeploymentPlanWriter.BuildDeployArgumentsAsync( + builder.ExecutionContext, + NullLogger.Instance, + environment.GetVercelOptions(), + entry, + TestContext.Current.CancellationToken); + + Assert.DoesNotContain(arguments, argument => argument.Contains("DATABASE_URL", StringComparison.Ordinal)); + } + + [Fact] + public async Task BuildDeployArgumentsThrowsForInvalidEnvironmentVariableNames() + { + using var sourceRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile") + .WithEnvironment("INVALID-NAME", "value"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var entry = Assert.Single(VercelDeploymentModel.GetEntries(model, environment)); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentPlanWriter.BuildDeployArgumentsAsync( + builder.ExecutionContext, + NullLogger.Instance, + environment.GetVercelOptions(), + entry, + TestContext.Current.CancellationToken)); + + Assert.Contains("INVALID-NAME", exception.Message); + Assert.Contains("invalid Vercel environment variable name", exception.Message); + } + + [Fact] + public async Task BuildDeployArgumentsUsesProductionUrlForEndpointEnvironmentVariables() + { + using var sourceRoot = TemporaryDirectory.Create(); + string apiRoot = Path.Combine(sourceRoot.Path, "api-app"); + string backendRoot = Path.Combine(sourceRoot.Path, "backend-app"); + Directory.CreateDirectory(apiRoot); + Directory.CreateDirectory(backendRoot); + File.WriteAllText(Path.Combine(apiRoot, "Dockerfile"), "FROM nginx:alpine"); + File.WriteAllText(Path.Combine(backendRoot, "Dockerfile"), "FROM nginx:alpine"); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); + var vercel = builder.AddVercelEnvironment("vercel") + .WithVercelProductionDeployments(); + var api = builder.AddContainer("api", "api") + .WithDockerfile(apiRoot, "Dockerfile") + .WithEndpoint(port: 8081, targetPort: 8080, scheme: "http", name: "http", isExternal: true) + .WithComputeEnvironment(vercel); + var backend = builder.AddContainer("backend", "backend") + .WithDockerfile(backendRoot, "Dockerfile") + .WithEndpoint(port: 8080, targetPort: 8080, scheme: "http", name: "http", isExternal: true) + .WithComputeEnvironment(vercel); + api.WithEnvironment("BACKEND_URL", backend.GetEndpoint("http")); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var entries = VercelDeploymentModel.GetEntries(model, environment).ToArray(); + var entry = Assert.Single(entries, entry => entry.Resource.Name == "api"); + + string[] arguments = await VercelDeploymentPlanWriter.BuildDeployArgumentsAsync( + builder.ExecutionContext, + NullLogger.Instance, + environment.GetVercelOptions(), + entry, + entries, + TestContext.Current.CancellationToken); + + Assert.Contains("BACKEND_URL=https://backend-app.vercel.app", arguments); + } + + [Fact] + public async Task BuildDeployArgumentsUsesProductionUrlForServiceDiscoveryReferences() + { + using var sourceRoot = TemporaryDirectory.Create(); + string apiRoot = Path.Combine(sourceRoot.Path, "api-app"); + string backendRoot = Path.Combine(sourceRoot.Path, "backend-app"); + Directory.CreateDirectory(apiRoot); + Directory.CreateDirectory(backendRoot); + File.WriteAllText(Path.Combine(apiRoot, "Dockerfile"), "FROM nginx:alpine"); + File.WriteAllText(Path.Combine(backendRoot, "Dockerfile"), "FROM nginx:alpine"); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); + var vercel = builder.AddVercelEnvironment("vercel") + .WithVercelProductionDeployments(); + var backend = builder.AddContainer("backend", "backend") + .WithDockerfile(backendRoot, "Dockerfile") + .WithEndpoint(port: 8080, targetPort: 8080, scheme: "http", name: "http", isExternal: true) + .WithComputeEnvironment(vercel); + builder.AddContainer("api", "api") + .WithDockerfile(apiRoot, "Dockerfile") + .WithEndpoint(port: 8081, targetPort: 8080, scheme: "http", name: "http", isExternal: true) + .WithReference(backend.GetEndpoint("http")) + .WithComputeEnvironment(vercel); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var entries = VercelDeploymentModel.GetEntries(model, environment).ToArray(); + var entry = Assert.Single(entries, entry => entry.Resource.Name == "api"); + + string[] arguments = await VercelDeploymentPlanWriter.BuildDeployArgumentsAsync( + builder.ExecutionContext, + NullLogger.Instance, + environment.GetVercelOptions(), + entry, + entries, + TestContext.Current.CancellationToken); + + Assert.Contains("BACKEND_HTTP=https://backend-app.vercel.app", arguments); + } + + [Fact] + public async Task BuildDeployArgumentsUsesConfiguredProjectNameForEndpointReferences() + { + using var sourceRoot = TemporaryDirectory.Create(); + string apiRoot = Path.Combine(sourceRoot.Path, "api-app"); + string backendRoot = Path.Combine(sourceRoot.Path, "backend-source"); + Directory.CreateDirectory(apiRoot); + Directory.CreateDirectory(backendRoot); + File.WriteAllText(Path.Combine(apiRoot, "Dockerfile"), "FROM nginx:alpine"); + File.WriteAllText(Path.Combine(backendRoot, "Dockerfile"), "FROM nginx:alpine"); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); + var vercel = builder.AddVercelEnvironment("vercel") + .WithVercelProductionDeployments(); + var api = builder.AddContainer("api", "api") + .WithDockerfile(apiRoot, "Dockerfile") + .WithEndpoint(port: 8081, targetPort: 8080, scheme: "http", name: "http", isExternal: true) + .WithComputeEnvironment(vercel); + var backend = builder.AddContainer("backend", "backend") + .WithDockerfile(backendRoot, "Dockerfile") + .WithEndpoint(port: 8080, targetPort: 8080, scheme: "http", name: "http", isExternal: true) + .WithVercelProjectName("configured-backend") + .WithComputeEnvironment(vercel); + api.WithEnvironment("BACKEND_URL", backend.GetEndpoint("http")); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var entries = VercelDeploymentModel.GetEntries(model, environment).ToArray(); + var entry = Assert.Single(entries, entry => entry.Resource.Name == "api"); + + string[] arguments = await VercelDeploymentPlanWriter.BuildDeployArgumentsAsync( + builder.ExecutionContext, + NullLogger.Instance, + environment.GetVercelOptions(), + entry, + entries, + TestContext.Current.CancellationToken); + + Assert.Contains("BACKEND_URL=https://configured-backend.vercel.app", arguments); + } + + [Fact] + public async Task BuildDeployArgumentsThrowsForEndpointReferencesWithoutProductionUrls() + { + using var sourceRoot = TemporaryDirectory.Create(); + string apiRoot = Path.Combine(sourceRoot.Path, "api-app"); + string backendRoot = Path.Combine(sourceRoot.Path, "backend-app"); + Directory.CreateDirectory(apiRoot); + Directory.CreateDirectory(backendRoot); + File.WriteAllText(Path.Combine(apiRoot, "Dockerfile"), "FROM nginx:alpine"); + File.WriteAllText(Path.Combine(backendRoot, "Dockerfile"), "FROM nginx:alpine"); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); + var vercel = builder.AddVercelEnvironment("vercel") + .WithVercelTarget("preview"); + var api = builder.AddContainer("api", "api") + .WithDockerfile(apiRoot, "Dockerfile") + .WithEndpoint(port: 8081, targetPort: 8080, scheme: "http", name: "http", isExternal: true) + .WithComputeEnvironment(vercel); + var backend = builder.AddContainer("backend", "backend") + .WithDockerfile(backendRoot, "Dockerfile") + .WithEndpoint(port: 8080, targetPort: 8080, scheme: "http", name: "http", isExternal: true) + .WithComputeEnvironment(vercel); + api.WithEnvironment("BACKEND_URL", backend.GetEndpoint("http")); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var entries = VercelDeploymentModel.GetEntries(model, environment).ToArray(); + var entry = Assert.Single(entries, entry => entry.Resource.Name == "api"); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentPlanWriter.BuildDeployArgumentsAsync( + builder.ExecutionContext, + NullLogger.Instance, + environment.GetVercelOptions(), + entry, + entries, + TestContext.Current.CancellationToken)); + + Assert.Contains("Vercel endpoint references require production deployments", exception.ToString()); + } + + [Fact] + public async Task BuildDeployArgumentsThrowsForEndpointReferencesToDifferentVercelEnvironment() + { + using var sourceRoot = TemporaryDirectory.Create(); + string apiRoot = Path.Combine(sourceRoot.Path, "api-app"); + string backendRoot = Path.Combine(sourceRoot.Path, "backend-app"); + Directory.CreateDirectory(apiRoot); + Directory.CreateDirectory(backendRoot); + File.WriteAllText(Path.Combine(apiRoot, "Dockerfile"), "FROM nginx:alpine"); + File.WriteAllText(Path.Combine(backendRoot, "Dockerfile"), "FROM nginx:alpine"); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); + var vercel = builder.AddVercelEnvironment("vercel") + .WithVercelProductionDeployments(); + var otherVercel = builder.AddVercelEnvironment("other-vercel") + .WithVercelProductionDeployments(); + var api = builder.AddContainer("api", "api") + .WithDockerfile(apiRoot, "Dockerfile") + .WithComputeEnvironment(vercel); + var backend = builder.AddContainer("backend", "backend") + .WithDockerfile(backendRoot, "Dockerfile") + .WithEndpoint(port: 8080, targetPort: 8080, scheme: "http", name: "http", isExternal: true) + .WithComputeEnvironment(otherVercel); + api.WithEnvironment("BACKEND_URL", backend.GetEndpoint("http")); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType(), environment => environment.Name == "vercel"); + var entries = VercelDeploymentModel.GetEntries(model, environment).ToArray(); + var entry = Assert.Single(entries, entry => entry.Resource.Name == "api"); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentPlanWriter.BuildDeployArgumentsAsync( + builder.ExecutionContext, + NullLogger.Instance, + environment.GetVercelOptions(), + entry, + entries, + TestContext.Current.CancellationToken)); + + Assert.Contains("does not target this Vercel environment", exception.Message); + } + + [Fact] + public async Task BuildDeployArgumentsUsesServiceBindingForSameProjectInternalEndpointReferences() + { + using var sourceRoot = TemporaryDirectory.Create(); + string apiRoot = Path.Combine(sourceRoot.Path, "api-app"); + string backendRoot = Path.Combine(sourceRoot.Path, "backend-app"); + Directory.CreateDirectory(apiRoot); + Directory.CreateDirectory(backendRoot); + File.WriteAllText(Path.Combine(apiRoot, "Dockerfile"), "FROM nginx:alpine"); + File.WriteAllText(Path.Combine(backendRoot, "Dockerfile"), "FROM nginx:alpine"); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); + var vercel = builder.AddVercelEnvironment("vercel") + .WithVercelProductionDeployments(); + var api = builder.AddContainer("api", "api") + .WithDockerfile(apiRoot, "Dockerfile") + .WithEndpoint(port: 8081, targetPort: 8080, scheme: "http", name: "http", isExternal: true) + .WithComputeEnvironment(vercel); + var backend = builder.AddContainer("backend", "backend") + .WithDockerfile(backendRoot, "Dockerfile") + .WithEndpoint(port: 8080, targetPort: 8080, scheme: "http", name: "http", isExternal: false) + .WithComputeEnvironment(vercel); + api.WithEnvironment("BACKEND_URL", backend.GetEndpoint("http")); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var entries = VercelDeploymentModel.GetEntries(model, environment).ToArray(); + var entry = Assert.Single(entries, entry => entry.Resource.Name == "api"); + var entriesByResourceName = VercelDeploymentModel.GetEntriesByResourceName(entries); + var projectMap = await VercelDeploymentProjectGrouper.CreateMapAsync(builder.ExecutionContext, NullLogger.Instance, entries, TestContext.Current.CancellationToken); + + var environmentConfiguration = await VercelDeploymentPlanWriter.GetEnvironmentConfigurationAsync( + builder.ExecutionContext, + NullLogger.Instance, + environment.GetVercelOptions(), + entry, + entriesByResourceName, + projectMap, + resolveProjectEnvironmentVariableValues: false, + TestContext.Current.CancellationToken); + + Assert.Empty(environmentConfiguration.DeploymentEnvironmentVariables); + Assert.Empty(environmentConfiguration.ProjectEnvironmentVariables); + var binding = Assert.Single(environmentConfiguration.ServiceBindings); + Assert.Equal("BACKEND_URL", binding.EnvironmentVariableName); + Assert.Equal("backend", binding.ServiceName); + } + + [Fact] + public async Task BuildDeployArgumentsThrowsForTargetPortEndpointReferenceWithoutExplicitTargetPort() + { + using var sourceRoot = TemporaryDirectory.Create(); + string apiRoot = Path.Combine(sourceRoot.Path, "api-app"); + string backendRoot = Path.Combine(sourceRoot.Path, "backend-app"); + Directory.CreateDirectory(apiRoot); + Directory.CreateDirectory(backendRoot); + File.WriteAllText(Path.Combine(apiRoot, "Dockerfile"), "FROM nginx:alpine"); + File.WriteAllText(Path.Combine(backendRoot, "Dockerfile"), "FROM nginx:alpine"); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); + var vercel = builder.AddVercelEnvironment("vercel") + .WithVercelProductionDeployments(); + var api = builder.AddContainer("api", "api") + .WithDockerfile(apiRoot, "Dockerfile") + .WithEndpoint(port: 8081, targetPort: 8080, scheme: "http", name: "http", isExternal: true) + .WithComputeEnvironment(vercel); + var backend = builder.AddContainer("backend", "backend") + .WithDockerfile(backendRoot, "Dockerfile") + .WithEndpoint(port: 8080, scheme: "http", name: "http", isExternal: true) + .WithComputeEnvironment(vercel); + api.WithEnvironment("BACKEND_TARGET_PORT", backend.GetEndpoint("http").Property(EndpointProperty.TargetPort)); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var entries = VercelDeploymentModel.GetEntries(model, environment).ToArray(); + var entry = Assert.Single(entries, entry => entry.Resource.Name == "api"); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentPlanWriter.BuildDeployArgumentsAsync( + builder.ExecutionContext, + NullLogger.Instance, + environment.GetVercelOptions(), + entry, + entries, + TestContext.Current.CancellationToken)); + + Assert.Contains("does not define an explicit target port", exception.Message); + } + + [Fact] + public async Task BuildDeployArgumentsThrowsForCommandLineArguments() + { + using var sourceRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile") + .WithArgs("--verbose"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var entry = Assert.Single(VercelDeploymentModel.GetEntries(model, environment)); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentPlanWriter.BuildDeployArgumentsAsync( + builder.ExecutionContext, + NullLogger.Instance, + environment.GetVercelOptions(), + entry, + TestContext.Current.CancellationToken)); + + Assert.Contains("command-line arguments", exception.Message); + } + + [Fact] + public async Task BuildDeployArgumentsAllowsDockerBuildArguments() + { + using var sourceRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", Path.Combine(sourceRoot.Path, "out")]); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile") + .WithBuildArg("FOO", "bar"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var entry = Assert.Single(VercelDeploymentModel.GetEntries(model, environment)); + + string[] arguments = await VercelDeploymentPlanWriter.BuildDeployArgumentsAsync( + builder.ExecutionContext, + NullLogger.Instance, + environment.GetVercelOptions(), + entry, + TestContext.Current.CancellationToken); + + Assert.Contains("--prebuilt", arguments); + } + + [Fact] + public async Task DeployAsyncDoesNotBuildOrPushImagesItself() + { + using var sourceRoot = TemporaryDirectory.Create("registry-free-project"); + using var outputRoot = TemporaryDirectory.Create(); + using var tempRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + + var runner = new FakeVercelCliRunner( + new VercelCliResult(0, "https://registry-free-project.vercel.app", ""), + ReadyInspectResult()); + var stateManager = new FakeDeploymentStateManager(); + var imageManager = new ThrowingResourceContainerImageManager(); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); + builder.Services.AddSingleton(stateManager); + builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); + builder.Services.AddSingleton(imageManager); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile"); + + using var app = builder.Build(); + var environment = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + await VercelDeploymentStep.DeployAsync(context, environment); + + Assert.Equal(0, imageManager.CallCount); + Assert.Equal(11, runner.Invocations.Count); + Assert.Equal(2, stateManager.SavedSections.Count); + } + + [Fact] + public async Task DeployAsyncRunsVercelCliAndSavesDeploymentState() + { + using var sourceRoot = TemporaryDirectory.Create("vercel-state-project"); + using var outputRoot = TemporaryDirectory.Create(); + using var tempRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + var runner = new FakeVercelCliRunner(new VercelCliResult(0, """ + { + "deployment": { + "id": "dpl_123", + "url": "https://api.vercel.app" + } + } + """, ""), + ReadyInspectResult()); + var stateManager = new FakeDeploymentStateManager(); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); + builder.Services.AddSingleton(stateManager); + builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); + var vercel = builder.AddVercelEnvironment("vercel") + .WithVercelScope("team") + .WithVercelProductionDeployments(); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile") + .WithEnvironment("GREETING", "hello"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + await VercelDeploymentStep.DeployAsync(context, environment); + + var digestInvocation = Assert.Single(runner.Invocations, invocation => invocation.FileName == "docker" && invocation.Arguments.Contains("imagetools")); + var invocation = Assert.Single(runner.Invocations, invocation => invocation.Arguments.Contains("deploy")); + string expectedDeployDirectory = Path.Combine(tempRoot.Path, "api", "vercel-build-output"); + Assert.Equal(["buildx", "imagetools", "inspect", "--format", "{{json .Manifest}}", digestInvocation.Arguments[^1]], digestInvocation.Arguments); + Assert.StartsWith("vcr.vercel.com/test-team/test-project/api:", digestInvocation.Arguments[^1], StringComparison.Ordinal); + Assert.Equal("vercel", invocation.FileName); + Assert.Equal(expectedDeployDirectory, invocation.WorkingDirectory); + Assert.Equal(["deploy", "--scope", "team", "--cwd", expectedDeployDirectory, "--project", "vercel-state-project", "--prebuilt", "--yes", "--prod"], invocation.Arguments); + Assert.Null(invocation.StandardInput); + AssertGeneratedBuildOutput(expectedDeployDirectory, expectedEnvironmentVariables: [new("GREETING", "hello")]); + Assert.False(File.Exists(Path.Combine(sourceRoot.Path, "vercel.json"))); + var inspectInvocation = Assert.Single(runner.Invocations, invocation => invocation.FileName == "vercel" && invocation.Arguments.Contains("inspect")); + Assert.Equal(["inspect", "https://api.vercel.app", "--scope", "team", "--wait", "--timeout", "120s", "--format=json"], inspectInvocation.Arguments); + + Assert.Collection( + context.Summary.Items, + summary => + { + Assert.Equal("api Vercel deployment", summary.Key); + Assert.Equal("https://api.vercel.app", summary.Value); + }, + summary => + { + Assert.Equal("api Vercel production URL", summary.Key); + Assert.Equal("https://vercel-state-project.vercel.app", summary.Value); + }); + + Assert.Equal(2, stateManager.SavedSections.Count); + var savedSection = stateManager.SavedSections[^1]; + Assert.Equal("communitytoolkit.vercel.vercel", savedSection.SectionName); + string stateJson = savedSection.Data.First().Value!.GetValue(); + Assert.Contains("schemaVersion", stateJson); + Assert.Contains("\"scope\": \"team\"", stateJson); + Assert.Contains("vercel-state-project", stateJson); + Assert.Contains("dpl_123", stateJson); + Assert.Contains("managedByAspire", stateJson); + var state = ReadSavedState(savedSection); + var deployment = Assert.Single(state.Deployments); + Assert.True(state.Production); + Assert.Equal("https://vercel-state-project.vercel.app", deployment.ProductionUrl); + Assert.Equal(FakeVercelCliRunner.FakeImageDigest, deployment.VcrImageDigest); + Assert.Equal(3, deployment.BuildOutputApiVersion); + } + + [Fact] + public async Task DeployAsyncSavesVerifiedResourcesBeforeLaterResourceFails() + { + using var parent = TemporaryDirectory.Create(); + using var outputRoot = TemporaryDirectory.Create(); + using var tempRoot = TemporaryDirectory.Create(); + string apiRoot = Path.Combine(parent.Path, "partial-api"); + string workerRoot = Path.Combine(parent.Path, "partial-worker"); + Directory.CreateDirectory(apiRoot); + Directory.CreateDirectory(workerRoot); + File.WriteAllText(Path.Combine(apiRoot, "Dockerfile"), "FROM nginx:alpine"); + File.WriteAllText(Path.Combine(workerRoot, "Dockerfile"), "FROM nginx:alpine"); + var runner = new CoordinatedVercelDeployRunner( + expectedDeployments: 2, + waitForAllDeployments: false, + deployResults: new Dictionary(StringComparer.Ordinal) + { + ["partial-api"] = new(0, "https://partial-api.vercel.app", ""), + ["partial-worker"] = new(1, "", "worker deploy failed") + }); + var stateManager = new FakeDeploymentStateManager(); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); + builder.Services.AddSingleton(stateManager); + builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(apiRoot, "Dockerfile") + .WithEndpoint(targetPort: 8080, scheme: "http", name: "http", isExternal: true); + builder.AddContainer("worker", "worker") + .WithDockerfile(workerRoot, "Dockerfile") + .WithEndpoint(targetPort: 8080, scheme: "http", name: "http", isExternal: true); + + using var app = builder.Build(); + var environment = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.DeployAsync(context, environment)); + + Assert.Contains("worker deploy failed", exception.Message); + var state = ReadSavedState(stateManager.SavedSections[^1]); + Assert.Collection( + state.Deployments.OrderBy(static deployment => deployment.ResourceName), + deployment => + { + Assert.Equal("api", deployment.ResourceName); + Assert.Equal("partial-api", deployment.ProjectName); + Assert.Equal("https://partial-api.vercel.app", deployment.DeploymentUrl); + }, + deployment => + { + Assert.Equal("worker", deployment.ResourceName); + Assert.Equal("partial-worker", deployment.ProjectName); + Assert.Null(deployment.DeploymentUrl); + }); + } + + [Fact] + public async Task DeployAsyncRunsPrebuiltDeploysInParallelAfterResolvingImageDigests() + { + using var parent = TemporaryDirectory.Create(); + using var outputRoot = TemporaryDirectory.Create(); + using var tempRoot = TemporaryDirectory.Create(); + string apiRoot = Path.Combine(parent.Path, "parallel-api"); + string workerRoot = Path.Combine(parent.Path, "parallel-worker"); + Directory.CreateDirectory(apiRoot); + Directory.CreateDirectory(workerRoot); + File.WriteAllText(Path.Combine(apiRoot, "Dockerfile"), "FROM nginx:alpine"); + File.WriteAllText(Path.Combine(workerRoot, "Dockerfile"), "FROM nginx:alpine"); + var runner = new CoordinatedVercelDeployRunner(expectedDeployments: 2, waitForAllDeployments: true); + var stateManager = new FakeDeploymentStateManager(); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); + builder.Services.AddSingleton(stateManager); + builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(apiRoot, "Dockerfile") + .WithEndpoint(targetPort: 8080, scheme: "http", name: "http", isExternal: true); + builder.AddContainer("worker", "worker") + .WithDockerfile(workerRoot, "Dockerfile") + .WithEndpoint(targetPort: 8080, scheme: "http", name: "http", isExternal: true); + + using var app = builder.Build(); + var environment = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + await VercelDeploymentStep.DeployAsync(context, environment); + + Assert.Equal(1, runner.MaxConcurrentDigestInspections); + Assert.Equal(2, runner.MaxConcurrentDeployments); + var state = ReadSavedState(stateManager.SavedSections[^1]); + Assert.Collection( + state.Deployments.OrderBy(static deployment => deployment.ResourceName), + deployment => + { + Assert.Equal("api", deployment.ResourceName); + Assert.Equal("https://parallel-api.vercel.app", deployment.DeploymentUrl); + }, + deployment => + { + Assert.Equal("worker", deployment.ResourceName); + Assert.Equal("https://parallel-worker.vercel.app", deployment.DeploymentUrl); + }); + } + + [Fact] + public async Task DeployAsyncPreservesPreviousManagedStateForResourcesRemovedFromModel() + { + using var sourceRoot = TemporaryDirectory.Create("current-api"); + using var outputRoot = TemporaryDirectory.Create(); + using var tempRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + var runner = new FakeVercelCliRunner( + new(0, "https://current-api.vercel.app", ""), + ReadyInspectResult()); + var stateManager = new FakeDeploymentStateManager(); + stateManager.SetSection("communitytoolkit.vercel.vercel", JsonSerializer.Serialize(new VercelDeploymentState( + 1, + "vercel", + null, + null, + false, + [ + new("worker", "removed-worker", "prj_worker", "dpl_worker", "https://removed-worker.vercel.app", "/src/worker", true) + ]))); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); + builder.Services.AddSingleton(stateManager); + builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile"); + + using var app = builder.Build(); + var environment = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + await VercelDeploymentStep.DeployAsync(context, environment); + + var state = ReadSavedState(stateManager.SavedSections[^1]); + Assert.Collection( + state.Deployments.OrderBy(static deployment => deployment.ResourceName), + deployment => + { + Assert.Equal("api", deployment.ResourceName); + Assert.Equal("current-api", deployment.ProjectName); + }, + deployment => + { + Assert.Equal("worker", deployment.ResourceName); + Assert.Equal("removed-worker", deployment.ProjectName); + }); + } + + [Fact] + public async Task DeployAsyncMarksLinkedVercelProjectsAsUnmanaged() + { + using var sourceRoot = TemporaryDirectory.Create("linked-project-source"); + using var outputRoot = TemporaryDirectory.Create(); + using var tempRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + Directory.CreateDirectory(Path.Combine(sourceRoot.Path, ".vercel")); + File.WriteAllText(Path.Combine(sourceRoot.Path, ".vercel", "cache.json"), "{}"); + File.WriteAllText(Path.Combine(sourceRoot.Path, ".vercel", "project.json"), """ + { + "projectId": "prj_linked", + "projectName": "linked-project" + } + """); + string linkedProjectJson = File.ReadAllText(Path.Combine(sourceRoot.Path, ".vercel", "project.json")); + var runner = new FakeVercelCliRunner( + new VercelCliResult(0, "https://linked-project.vercel.app", ""), + ReadyInspectResult()); + var stateManager = new FakeDeploymentStateManager(); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); + builder.Services.AddSingleton(stateManager); + builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile"); + + using var app = builder.Build(); + var environment = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + await VercelDeploymentStep.DeployAsync(context, environment); + + var state = ReadSavedState(stateManager.SavedSections[^1]); + var deployment = Assert.Single(state.Deployments); + Assert.Equal("linked-project", deployment.ProjectName); + Assert.Equal("prj_linked", deployment.ProjectId); + Assert.False(deployment.ManagedByAspire); + + var deployInvocation = Assert.Single(runner.Invocations, invocation => invocation.Arguments.Contains("deploy")); + Assert.Equal(Path.Combine(tempRoot.Path, "api", "vercel-build-output"), deployInvocation.WorkingDirectory); + Assert.Contains("--project", deployInvocation.Arguments); + Assert.Contains("prj_linked", deployInvocation.Arguments); + Assert.False(Directory.Exists(Path.Combine(tempRoot.Path, "api", ".vercel"))); + Assert.Equal(linkedProjectJson, File.ReadAllText(Path.Combine(sourceRoot.Path, ".vercel", "project.json"))); + Assert.True(File.Exists(Path.Combine(sourceRoot.Path, ".vercel", "cache.json"))); + } + + [Fact] + public async Task DeployAsyncDoesNotCopySourceRoot() + { + using var sourceRoot = TemporaryDirectory.Create("uncopied-source-project"); + using var outputRoot = TemporaryDirectory.Create(); + using var tempRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + File.WriteAllText(Path.Combine(sourceRoot.Path, ".dockerignore"), "node_modules"); + Directory.CreateDirectory(Path.Combine(sourceRoot.Path, ".git")); + File.WriteAllText(Path.Combine(sourceRoot.Path, ".git", "config"), "ignored"); + Directory.CreateDirectory(Path.Combine(sourceRoot.Path, "node_modules", "pkg")); + File.WriteAllText(Path.Combine(sourceRoot.Path, "node_modules", "pkg", "index.js"), "ignored"); + Directory.CreateDirectory(Path.Combine(sourceRoot.Path, ".vercel")); + File.WriteAllText(Path.Combine(sourceRoot.Path, ".vercel", "cache.json"), "{}"); + var runner = new FakeVercelCliRunner( + new VercelCliResult(0, "https://ignored-staging-project.vercel.app", ""), + ReadyInspectResult()); + var stateManager = new FakeDeploymentStateManager(); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); + builder.Services.AddSingleton(stateManager); + builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile"); + + using var app = builder.Build(); + var environment = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + await VercelDeploymentStep.DeployAsync(context, environment); + + string tempResourceRoot = Path.Combine(tempRoot.Path, "api"); + AssertGeneratedBuildOutput(Path.Combine(tempResourceRoot, "vercel-build-output")); + Assert.False(File.Exists(Path.Combine(tempResourceRoot, "vercel.json"))); + Assert.False(File.Exists(Path.Combine(tempResourceRoot, "Dockerfile"))); + Assert.False(File.Exists(Path.Combine(tempResourceRoot, ".dockerignore"))); + Assert.False(Directory.Exists(Path.Combine(tempResourceRoot, ".git"))); + Assert.False(Directory.Exists(Path.Combine(tempResourceRoot, "node_modules"))); + Assert.False(Directory.Exists(Path.Combine(tempResourceRoot, ".vercel"))); + Assert.True(Directory.Exists(Path.Combine(tempResourceRoot, "vercel-build-output", ".vercel", "output"))); + } + + [Fact] + public async Task DeployAsyncAllowsSourceRootSymbolicLinks() + { + if (OperatingSystem.IsWindows()) + { + return; + } + + using var sourceRoot = TemporaryDirectory.Create(); + using var outsideRoot = TemporaryDirectory.Create(); + using var outputRoot = TemporaryDirectory.Create(); + using var tempRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + File.WriteAllText(Path.Combine(outsideRoot.Path, "outside.txt"), "outside"); + File.CreateSymbolicLink(Path.Combine(sourceRoot.Path, "outside-link.txt"), Path.Combine(outsideRoot.Path, "outside.txt")); + var runner = new FakeVercelCliRunner( + new VercelCliResult(0, "https://symlink.vercel.app", ""), + ReadyInspectResult()); + var stateManager = new FakeDeploymentStateManager(); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); + builder.Services.AddSingleton(stateManager); + builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile"); + + using var app = builder.Build(); + var environment = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + await VercelDeploymentStep.DeployAsync(context, environment); + + Assert.Contains(runner.Invocations, invocation => invocation.FileName == "docker" && invocation.Arguments.Contains("imagetools")); + } + + [Fact] + public async Task DeployAsyncThrowsWhenVercelJsonAlreadyConfiguresServices() + { + using var sourceRoot = TemporaryDirectory.Create(); + using var outputRoot = TemporaryDirectory.Create(); + using var tempRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + File.WriteAllText(Path.Combine(sourceRoot.Path, "vercel.json"), """{"services":{"api":{"root":"."}}}"""); + var runner = new FakeVercelCliRunner(); + var stateManager = new FakeDeploymentStateManager(); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); + builder.Services.AddSingleton(stateManager); + builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile"); + + using var app = builder.Build(); + var environment = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.DeployAsync(context, environment)); + + Assert.Contains("top-level 'services'", exception.Message); + Assert.Empty(runner.Invocations); + } + + [Theory] + [InlineData("build", "{\"build\":{\"env\":{\"SECRET\":\"value\"}}}")] + [InlineData("builds", "{\"builds\":[]}")] + [InlineData("env", "{\"env\":{\"SECRET\":\"value\"}}")] + [InlineData("routes", "{\"routes\":[]}")] + public async Task DeployAsyncThrowsWhenVercelJsonContainsUnsupportedServicesModeTopLevelKey(string key, string vercelJson) + { + using var sourceRoot = TemporaryDirectory.Create(); + using var outputRoot = TemporaryDirectory.Create(); + using var tempRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + File.WriteAllText(Path.Combine(sourceRoot.Path, "vercel.json"), vercelJson); + var runner = new FakeVercelCliRunner(); + var stateManager = new FakeDeploymentStateManager(); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); + builder.Services.AddSingleton(stateManager); + builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile"); + + using var app = builder.Build(); + var environment = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.DeployAsync(context, environment)); + + Assert.Contains($"top-level '{key}'", exception.Message); + Assert.Empty(runner.Invocations); + } + + [Fact] + public async Task DeployAsyncAllowsGeneratedDockerfile() + { + using var sourceRoot = TemporaryDirectory.Create("generated-vercel-project"); + using var outputRoot = TemporaryDirectory.Create(); + using var tempRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "server.mjs"), "console.log('hello');"); + var runner = new FakeVercelCliRunner( + new VercelCliResult(0, "https://generated.vercel.app", ""), + ReadyInspectResult()); + var stateManager = new FakeDeploymentStateManager(); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); + builder.Services.AddSingleton(stateManager); + builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); + builder.AddVercelEnvironment("vercel"); + var api = builder.AddContainer("api", "api") + .WithDockerfileFactory(sourceRoot.Path, _ => Task.FromResult(""" + FROM node:22-alpine + WORKDIR /app + COPY server.mjs . + CMD ["node", "server.mjs"] + """)); + Assert.True(api.Resource.TryGetLastAnnotation(out var dockerfile)); + dockerfile.BuildContextIgnoreContent = "node_modules\n.env*\n"; + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + await VercelDeploymentStep.DeployAsync(context, environment); + + Assert.Contains(runner.Invocations, invocation => invocation.FileName == "docker" && invocation.Arguments.Contains("imagetools")); + Assert.False(File.Exists(Path.Combine(sourceRoot.Path, "Dockerfile"))); + Assert.False(File.Exists(Path.Combine(sourceRoot.Path, "Dockerfile.dockerignore"))); + Assert.False(File.Exists(Path.Combine(sourceRoot.Path, "Dockerfile.generated"))); + Assert.False(File.Exists(Path.Combine(sourceRoot.Path, "Dockerfile.generated.dockerignore"))); + Assert.NotEmpty(stateManager.SavedSections); + } + + [Fact] + public async Task DeployAsyncAllowsCustomDockerfileName() + { + using var sourceRoot = TemporaryDirectory.Create("custom-vercel-project"); + using var outputRoot = TemporaryDirectory.Create(); + using var tempRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile.custom"), "FROM nginx:alpine"); + File.WriteAllText(Path.Combine(sourceRoot.Path, "index.html"), "hello"); + var runner = new FakeVercelCliRunner( + new VercelCliResult(0, "https://custom.vercel.app", ""), + ReadyInspectResult()); + var stateManager = new FakeDeploymentStateManager(); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); + builder.Services.AddSingleton(stateManager); + builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile.custom"); + + using var app = builder.Build(); + var environment = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + await VercelDeploymentStep.DeployAsync(context, environment); + + Assert.Contains(runner.Invocations, invocation => invocation.FileName == "docker" && invocation.Arguments.Contains("imagetools")); + Assert.NotEmpty(stateManager.SavedSections); + } + + [Fact] + public async Task DeployAsyncThrowsWhenVercelCliFails() + { + using var sourceRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + var runner = new FakeVercelCliRunner( + new VercelCliResult(0, "54.18.6", ""), + new VercelCliResult(0, "test-user", ""), + new VercelCliResult(1, "ignored stdout", "deploy failed")); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); + builder.Services.AddSingleton(new FakeDeploymentStateManager()); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.DeployAsync(context, environment)); + + Assert.Contains("Failed to deploy prebuilt project root 'api' to Vercel using 'vercel' (exit code 1). deploy failed", exception.Message); + } + + [Fact] + public async Task DeployAsyncThrowsWhenVercelDeployOutputHasNoDeploymentUrl() + { + using var sourceRoot = TemporaryDirectory.Create(); + using var outputRoot = TemporaryDirectory.Create(); + using var tempRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + var runner = new FakeVercelCliRunner(new VercelCliResult(0, """{"deployment":{"id":"dpl_no_url"}}""", "")); + var stateManager = new FakeDeploymentStateManager(); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); + builder.Services.AddSingleton(stateManager); + builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile"); + + using var app = builder.Build(); + var environment = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.DeployAsync(context, environment)); + + Assert.Contains("did not contain an HTTP or HTTPS deployment URL", exception.Message); + Assert.Equal(10, runner.Invocations.Count); + var state = ReadSavedState(Assert.Single(stateManager.SavedSections)); + var deployment = Assert.Single(state.Deployments); + Assert.Null(deployment.DeploymentUrl); + } + + [Fact] + public async Task DeployAsyncThrowsWhenVercelInspectFails() + { + using var sourceRoot = TemporaryDirectory.Create(); + using var outputRoot = TemporaryDirectory.Create(); + using var tempRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + var runner = new FakeVercelCliRunner( + new(0, "https://api.vercel.app", ""), + new(1, "", "inspect failed")); + var stateManager = new FakeDeploymentStateManager(); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); + builder.Services.AddSingleton(stateManager); + builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.DeployAsync(context, environment)); + + Assert.Contains("Failed to verify Vercel deployment for resource 'api' using 'vercel' (exit code 1). inspect failed", exception.Message); + var state = ReadSavedState(Assert.Single(stateManager.SavedSections)); + var deployment = Assert.Single(state.Deployments); + Assert.Null(deployment.DeploymentUrl); + } + + [Fact] + public async Task DeployAsyncThrowsWhenVercelInspectOmitsReadyState() + { + using var sourceRoot = TemporaryDirectory.Create(); + using var outputRoot = TemporaryDirectory.Create(); + using var tempRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + var runner = new FakeVercelCliRunner( + new(0, "https://api.vercel.app", ""), + new(0, """{"deployment":{"url":"https://api.vercel.app"}}""", "")); + var stateManager = new FakeDeploymentStateManager(); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); + builder.Services.AddSingleton(stateManager); + builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile"); + + using var app = builder.Build(); + var environment = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.DeployAsync(context, environment)); + + Assert.Contains("did not include a deployment ready state", exception.Message); + var state = ReadSavedState(Assert.Single(stateManager.SavedSections)); + var deployment = Assert.Single(state.Deployments); + Assert.Null(deployment.DeploymentUrl); + } + + [Fact] + public async Task DeployAsyncThrowsWhenVercelInspectReportsNonReadyState() + { + using var sourceRoot = TemporaryDirectory.Create(); + using var outputRoot = TemporaryDirectory.Create(); + using var tempRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + var runner = new FakeVercelCliRunner( + new(0, "https://api.vercel.app", ""), + new(0, """{"readyState":"ERROR"}""", "")); + var stateManager = new FakeDeploymentStateManager(); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); + builder.Services.AddSingleton(stateManager); + builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.DeployAsync(context, environment)); + + Assert.Contains("finished with state 'ERROR' instead of 'READY'", exception.Message); + var state = ReadSavedState(Assert.Single(stateManager.SavedSections)); + var deployment = Assert.Single(state.Deployments); + Assert.Null(deployment.DeploymentUrl); + } + + [Fact] + public async Task ValidateCliPrerequisitesRunsVersionAndWhoami() + { + var runner = new FakeVercelCliRunner( + new(0, "Vercel CLI 54.18.6", ""), + new(0, "davidfowl-6717", "")); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); + builder.AddVercelEnvironment("vercel"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + await VercelDeploymentStep.ValidateCliPrerequisitesAsync(context, environment); + + Assert.Collection( + runner.Invocations, + invocation => Assert.Equal(["--version"], invocation.Arguments), + invocation => Assert.Equal(["whoami"], invocation.Arguments)); + } + + [Fact] + public async Task ValidateCliPrerequisitesValidatesConfiguredScope() + { + var runner = new FakeVercelCliRunner( + new(0, "54.18.6", ""), + new(0, "davidfowl-6717", ""), + new(0, "project-list", "")); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); + builder.AddVercelEnvironment("vercel") + .WithVercelScope("team"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + await VercelDeploymentStep.ValidateCliPrerequisitesAsync(context, environment); + + Assert.Collection( + runner.Invocations, + invocation => Assert.Equal(["--version"], invocation.Arguments), + invocation => Assert.Equal(["whoami"], invocation.Arguments), + invocation => Assert.Equal(["project", "ls", "--scope", "team", "--format=json"], invocation.Arguments)); + } + + [Fact] + public async Task ValidateCliPrerequisitesThrowsWhenScopeValidationFails() + { + var runner = new FakeVercelCliRunner( + new(0, "54.18.6", ""), + new(0, "davidfowl-6717", ""), + new(1, "", "scope not found")); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); + builder.AddVercelEnvironment("vercel") + .WithVercelScope("missing-team"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.ValidateCliPrerequisitesAsync(context, environment)); + + Assert.Contains("Failed to validate Vercel scope 'missing-team' using 'vercel' (exit code 1). scope not found", exception.Message); + } + + [Fact] + public async Task ValidateCliPrerequisitesThrowsWhenWhoamiFails() + { + var runner = new FakeVercelCliRunner( + new(0, "54.18.6", ""), + new(1, "", "not logged in")); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); + builder.AddVercelEnvironment("vercel"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.ValidateCliPrerequisitesAsync(context, environment)); + + Assert.Contains("Failed to validate Vercel authentication using 'vercel' (exit code 1). not logged in", exception.Message); + } + + [Fact] + public async Task ValidateCliPrerequisitesThrowsWhenVersionFailsWithStandardOutput() + { + var runner = new FakeVercelCliRunner(new VercelCliResult(1, "missing vercel", "")); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); + builder.AddVercelEnvironment("vercel"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.ValidateCliPrerequisitesAsync(context, environment)); + + Assert.Contains("Failed to validate Vercel CLI installation using 'vercel' (exit code 1). missing vercel", exception.Message); + } + + [Fact] + public async Task ValidateCliPrerequisitesThrowsWhenVersionIsTooOld() + { + var runner = new FakeVercelCliRunner(new VercelCliResult(0, "54.18.5", "")); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); + builder.AddVercelEnvironment("vercel"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.ValidateCliPrerequisitesAsync(context, environment)); + + Assert.Contains("Vercel CLI version '54.18.5' is not supported", exception.Message); + Assert.Single(runner.Invocations); + } + + [Fact] + public async Task ValidateCliPrerequisitesThrowsWhenVersionCannotBeParsed() + { + var runner = new FakeVercelCliRunner(new VercelCliResult(0, "vercel dev build", "")); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); + builder.AddVercelEnvironment("vercel"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.ValidateCliPrerequisitesAsync(context, environment)); + + Assert.Contains("Failed to determine Vercel CLI version", exception.Message); + Assert.Single(runner.Invocations); + } + + [Fact] + public async Task ValidatePrerequisitesThrowsWhenNoResourcesArePublished() + { + var runner = new FakeVercelCliRunner( + new(0, "54.18.6", ""), + new(0, "davidfowl-6717", "")); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); + var vercel = builder.AddVercelEnvironment("vercel"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.ValidatePrerequisitesAsync(context, environment)); + + Assert.Contains("No image-build compute resources target Vercel", exception.Message); + } + + [Fact] + public async Task DestroyAsyncDeletesProjectsFromSavedDeploymentState() + { + var runner = new FakeVercelCliRunner( + new(0, "54.18.6", ""), + new(0, "davidfowl-6717", ""), + new(0, ProjectListJson("a-project", "z-project"), ""), + new(0, ProjectListJson("a-project"), ""), + new(0, "", ""), + new(0, ProjectListJson("z-project"), ""), + new(0, "", "")); + var stateManager = new FakeDeploymentStateManager(); + stateManager.SetSection("communitytoolkit.vercel.vercel", JsonSerializer.Serialize(new VercelDeploymentState( + 1, + "vercel", + "team", + null, + false, + [ + new("api", "z-project", "prj_z", "dpl_1", "https://z-project.vercel.app", "/src/api", true), + new("worker", "a-project", null, null, null, "/src/worker", true), + new("api2", "z-project", null, null, null, "/src/api2", true) + ]))); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); + builder.Services.AddSingleton(stateManager); + var vercel = builder.AddVercelEnvironment("vercel") + .WithVercelScope("team"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + await VercelDeploymentStep.DestroyAsync(context, environment); + + Assert.Collection( + runner.Invocations, + invocation => Assert.Equal(["--version"], invocation.Arguments), + invocation => Assert.Equal(["whoami"], invocation.Arguments), + invocation => Assert.Equal(["project", "ls", "--scope", "team", "--format=json"], invocation.Arguments), + invocation => Assert.Equal(["project", "ls", "--scope", "team", "--filter", "a-project", "--format=json"], invocation.Arguments), + invocation => + { + Assert.Equal(["project", "remove", "a-project", "--scope", "team"], invocation.Arguments); + Assert.Equal("y\n", invocation.StandardInput); + }, + invocation => Assert.Equal(["project", "ls", "--scope", "team", "--filter", "z-project", "--format=json"], invocation.Arguments), + invocation => + { + Assert.Equal(["project", "remove", "z-project", "--scope", "team"], invocation.Arguments); + Assert.Equal("y\n", invocation.StandardInput); + }); + Assert.Single(stateManager.DeletedSections); + } + + [Fact] + public async Task DestroyAsyncDoesNotFallBackToConfiguredDeploymentsWhenStateIsMissing() + { + using var sourceRoot = TemporaryDirectory.Create("fallback-project"); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + var runner = new FakeVercelCliRunner(); + var stateManager = new FakeDeploymentStateManager(); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); + builder.Services.AddSingleton(stateManager); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + await VercelDeploymentStep.DestroyAsync(context, environment); + + Assert.Empty(runner.Invocations); + Assert.Empty(stateManager.DeletedSections); + var summary = Assert.Single(context.Summary.Items); + Assert.Equal("Vercel destroy", summary.Key); + Assert.Contains("No Vercel deployment state", summary.Value); + } + + [Fact] + public async Task DestroyAsyncAddsSummaryWhenNoStateExists() + { + var runner = new FakeVercelCliRunner(); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); + builder.Services.AddSingleton(new FakeDeploymentStateManager()); + var vercel = builder.AddVercelEnvironment("vercel"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + await VercelDeploymentStep.DestroyAsync(context, environment); + + Assert.Empty(runner.Invocations); + var summary = Assert.Single(context.Summary.Items); + Assert.Equal("Vercel destroy", summary.Key); + Assert.Contains("No Vercel deployment state", summary.Value); + } + + [Fact] + public async Task DestroyAsyncRejectsDeploymentStateFromDifferentScope() + { + var runner = new FakeVercelCliRunner(); + var stateManager = new FakeDeploymentStateManager(); + stateManager.SetSection("communitytoolkit.vercel.vercel", JsonSerializer.Serialize(new VercelDeploymentState( + 1, + "vercel", + "team-a", + null, + false, + [ + new("api", "api-project", "prj_1", "dpl_1", "https://api-project.vercel.app", "/src/api", true) + ]))); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); + builder.Services.AddSingleton(stateManager); + builder.AddVercelEnvironment("vercel") + .WithVercelScope("team-b"); + + using var app = builder.Build(); + var environment = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.DestroyAsync(context, environment)); + + Assert.Contains("created for scope 'team-a'", exception.Message); + Assert.Contains("configured for scope 'team-b'", exception.Message); + Assert.Empty(runner.Invocations); + Assert.Empty(stateManager.DeletedSections); + } + + [Fact] + public async Task DestroyAsyncSkipsUnmanagedProjectsAndClearsState() + { + var runner = new FakeVercelCliRunner( + new(0, "54.18.6", ""), + new(0, "davidfowl-6717", ""), + new(0, ProjectListJson("managed-project"), ""), + new(0, "", "")); + var stateManager = new FakeDeploymentStateManager(); + stateManager.SetSection("communitytoolkit.vercel.vercel", JsonSerializer.Serialize(new VercelDeploymentState( + 1, + "vercel", + null, + null, + false, + [ + new("api", "managed-project", "prj_managed", "dpl_1", "https://managed-project.vercel.app", "/src/api", true), + new("docs", "preexisting-project", "prj_existing", "dpl_2", "https://preexisting-project.vercel.app", "/src/docs", false) + ]))); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); + builder.Services.AddSingleton(stateManager); + builder.AddVercelEnvironment("vercel"); + + using var app = builder.Build(); + var environment = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + await VercelDeploymentStep.DestroyAsync(context, environment); + + Assert.Collection( + runner.Invocations, + invocation => Assert.Equal(["--version"], invocation.Arguments), + invocation => Assert.Equal(["whoami"], invocation.Arguments), + invocation => Assert.Equal(["project", "ls", "--filter", "managed-project", "--format=json"], invocation.Arguments), + invocation => Assert.Equal(["project", "remove", "managed-project"], invocation.Arguments)); + Assert.Single(stateManager.DeletedSections); + } + + [Fact] + public async Task DestroyAsyncSkipsCliWhenStateHasOnlyUnmanagedProjects() + { + var runner = new FakeVercelCliRunner(new VercelCliResult(1, "", "not logged in")); + var stateManager = new FakeDeploymentStateManager(); + stateManager.SetSection("communitytoolkit.vercel.vercel", JsonSerializer.Serialize(new VercelDeploymentState( + 1, + "vercel", + null, + null, + false, + [ + new("docs", "preexisting-project", "prj_existing", "dpl_2", "https://preexisting-project.vercel.app", "/src/docs", false) + ]))); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); + builder.Services.AddSingleton(stateManager); + builder.AddVercelEnvironment("vercel"); + + using var app = builder.Build(); + var environment = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + await VercelDeploymentStep.DestroyAsync(context, environment); + + Assert.Empty(runner.Invocations); + Assert.Single(stateManager.DeletedSections); + } + + [Fact] + public async Task DestroyAsyncRemovesTrackedEnvironmentVariablesFromUnmanagedProjects() + { + using var outputRoot = TemporaryDirectory.Create(); + using var tempRoot = TemporaryDirectory.Create(); + var runner = new FakeVercelCliRunner(); + var stateManager = new FakeDeploymentStateManager(); + stateManager.SetSection("communitytoolkit.vercel.vercel", JsonSerializer.Serialize(new VercelDeploymentState( + 1, + "vercel", + null, + null, + false, + [ + new("docs", "preexisting-project", "prj_existing", "dpl_2", "https://preexisting-project.vercel.app", "/src/docs", false) + { + ProjectEnvironmentVariables = ["API_KEY"] + } + ]))); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); + builder.Services.AddSingleton(stateManager); + builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); + builder.AddVercelEnvironment("vercel"); + + using var app = builder.Build(); + var environment = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + await VercelDeploymentStep.DestroyAsync(context, environment); + + string expectedProjectLinkDirectory = Path.Combine(tempRoot.Path, "vercel", ".vercel-projects", "preexisting-project"); + Assert.Contains(runner.Invocations, invocation => invocation.Arguments.SequenceEqual(["link", "--cwd", expectedProjectLinkDirectory, "--yes", "--project", "prj_existing"])); + Assert.Contains(runner.Invocations, invocation => invocation.Arguments.SequenceEqual(["env", "rm", "API_KEY", "preview", "--cwd", expectedProjectLinkDirectory, "--yes"])); + Assert.Single(stateManager.DeletedSections); + } + + [Fact] + public async Task DestroyAsyncTreatsMissingManagedProjectAsConverged() + { + var runner = new FakeVercelCliRunner( + new(0, "54.18.6", ""), + new(0, "davidfowl-6717", ""), + new(0, ProjectListJson(), "")); + var stateManager = new FakeDeploymentStateManager(); + stateManager.SetSection("communitytoolkit.vercel.vercel", JsonSerializer.Serialize(new VercelDeploymentState( + 1, + "vercel", + null, + null, + false, + [ + new("api", "managed-project", "prj_managed", "dpl_1", "https://managed-project.vercel.app", "/src/api", true) + ]))); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); + builder.Services.AddSingleton(stateManager); + builder.AddVercelEnvironment("vercel"); + + using var app = builder.Build(); + var environment = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + await VercelDeploymentStep.DestroyAsync(context, environment); + + Assert.Collection( + runner.Invocations, + invocation => Assert.Equal(["--version"], invocation.Arguments), + invocation => Assert.Equal(["whoami"], invocation.Arguments), + invocation => Assert.Equal(["project", "ls", "--filter", "managed-project", "--format=json"], invocation.Arguments)); + var summary = Assert.Single(context.Summary.Items, item => item.Key == "Vercel project already absent"); + Assert.Equal("managed-project", summary.Value); + Assert.Single(stateManager.SavedSections); + Assert.Single(stateManager.DeletedSections); + } + + [Fact] + public async Task DestroyAsyncPreservesStateWhenProjectRemovalFails() + { + var runner = new FakeVercelCliRunner( + new(0, "54.18.6", ""), + new(0, "davidfowl-6717", ""), + new(0, ProjectListJson("a-project"), ""), + new(0, "", ""), + new(0, ProjectListJson("b-project"), ""), + new(1, "", "remove failed"), + new(0, ProjectListJson("b-project"), "")); + var stateManager = new FakeDeploymentStateManager(); + stateManager.SetSection("communitytoolkit.vercel.vercel", JsonSerializer.Serialize(new VercelDeploymentState( + 1, + "vercel", + null, + null, + false, + [ + new("api", "a-project", "prj_a", "dpl_a", "https://a-project.vercel.app", "/src/api", true), + new("worker", "b-project", "prj_b", "dpl_b", "https://b-project.vercel.app", "/src/worker", true) + ]))); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakeVercelContainerRegistryClient()); + builder.Services.AddSingleton(stateManager); + builder.AddVercelEnvironment("vercel"); + + using var app = builder.Build(); + var environment = Assert.Single(app.Services.GetRequiredService().Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + + var exception = await Assert.ThrowsAsync(() => + VercelDeploymentStep.DestroyAsync(context, environment)); + + Assert.Contains("destroy Vercel project 'b-project'", exception.Message); + Assert.Equal(7, runner.Invocations.Count); + Assert.Empty(stateManager.DeletedSections); + var savedSection = Assert.Single(stateManager.SavedSections); + string savedState = savedSection.Data.First().Value!.GetValue(); + Assert.DoesNotContain("\"projectName\": \"a-project\"", savedState); + Assert.Contains("\"projectName\": \"b-project\"", savedState); + } + + [Fact] + public async Task VercelCliRunnerInvokesTypedProcessOperation() + { + var runner = new VercelCliRunner((fileName, arguments, workingDirectory, cancellationToken, standardInput) => + Task.FromResult(new VercelCliResult(0, $"{fileName} {string.Join(" ", arguments)}", ""))); + + var result = await runner.GetVersionAsync(TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + Assert.Equal(0, result.ExitCode); + Assert.Equal("vercel --version", result.StandardOutput); + } + + [Fact] + public async Task VercelCliRunnerSurfacesTypedProcessFailures() + { + var runner = new VercelCliRunner((_, _, _, _, _) => + Task.FromResult(new VercelCliResult(1, "", "failed"))); + + var result = await runner.GetVersionAsync(TestContext.Current.CancellationToken); + + Assert.False(result.Succeeded); + Assert.Equal("failed", result.StandardError); + } + + [Fact] + public void GetDeploymentUrlReturnsPlainUrl() + { + string url = VercelCliOutputParser.GetDeploymentUrl(""" + Vercel CLI 54.18.6 + https://example.vercel.app + """); + + Assert.Equal("https://example.vercel.app", url); + } + + [Fact] + public void GetDeploymentUrlReturnsJsonDeploymentUrl() + { + string url = VercelCliOutputParser.GetDeploymentUrl(""" + { + "status": "ok", + "deployment": { + "id": "dpl_123", + "url": "https://example-json.vercel.app", + "readyState": "READY" + } + } + """); + + Assert.Equal("https://example-json.vercel.app", url); + } + + [Fact] + public void GetDeploymentResultReturnsRootJsonDeploymentUrl() + { + var result = VercelCliOutputParser.GetDeploymentResult(""" + { + "id": "dpl_root", + "url": "https://root-json.vercel.app" + } + """); + + Assert.Equal("dpl_root", result.DeploymentId); + Assert.Equal("https://root-json.vercel.app", result.DeploymentUrl); + } + + [Fact] + public void GetDeploymentResultFallsBackWhenJsonIsInvalid() + { + var result = VercelCliOutputParser.GetDeploymentResult(""" + { + "deployment": + } + https://fallback.vercel.app + """); + + Assert.Null(result.DeploymentId); + Assert.Equal("https://fallback.vercel.app", result.DeploymentUrl); + } + + [Fact] + public void GetDeploymentResultThrowsWhenOutputHasNoUrl() + { + var exception = Assert.Throws(() => + VercelCliOutputParser.GetDeploymentResult(""" + { + "deployment": { + "id": "dpl_no_url" + } + } + """)); + + Assert.Contains("did not contain an HTTP or HTTPS deployment URL", exception.Message); + } + + [Fact] + public void GetDeploymentResultReturnsJsonDeploymentId() + { + var result = VercelCliOutputParser.GetDeploymentResult(""" + { + "status": "ok", + "deployment": { + "id": "dpl_123", + "url": "https://example-json.vercel.app", + "readyState": "READY" + } + } + """); + + Assert.Equal("dpl_123", result.DeploymentId); + Assert.Equal("https://example-json.vercel.app", result.DeploymentUrl); + } + + [Fact] + public void GetDeploymentInspectionParsesObservedJsonShapes() + { + Assert.Equal("READY", VercelCliOutputParser.GetDeploymentInspection("""{ "readyState": "READY" }""").ReadyState); + Assert.Equal("READY", VercelCliOutputParser.GetDeploymentInspection("""{ "state": "READY" }""").ReadyState); + Assert.Equal("READY", VercelCliOutputParser.GetDeploymentInspection("""{ "deployment": { "readyState": "READY" } }""").ReadyState); + Assert.Equal("READY", VercelCliOutputParser.GetDeploymentInspection("""{ "deployment": { "state": "READY" } }""").ReadyState); + } + + [Fact] + public void GetDeploymentInspectionThrowsForInvalidJson() + { + var exception = Assert.Throws(() => + VercelCliOutputParser.GetDeploymentInspection("""{ "deployment": """)); + + Assert.Contains("vercel inspect", exception.Message); + } + + [Fact] + public void GetVercelProjectNameReadsVercelProjectLink() + { + using var sourceRoot = TemporaryDirectory.Create(); + string vercelDirectory = Path.Combine(sourceRoot.Path, ".vercel"); + Directory.CreateDirectory(vercelDirectory); + File.WriteAllText(Path.Combine(vercelDirectory, "project.json"), """{"projectName":"linked-project"}"""); + var entry = CreateDeploymentEntry(sourceRoot.Path); + entry.Resource.Annotations.Add(new VercelProjectOptionsAnnotation("configured-project")); + + string projectName = VercelProjectNameResolver.GetProjectName(entry); + + Assert.Equal("linked-project", projectName); + } + + [Fact] + public void GetVercelProjectNameFallsBackToSourceRootName() + { + using var sourceRoot = TemporaryDirectory.Create("fallback-project"); + var entry = CreateDeploymentEntry(sourceRoot.Path); + + string projectName = VercelProjectNameResolver.GetProjectName(entry); + + Assert.Equal("fallback-project", projectName); + } + + [Fact] + public void GetVercelOptionsReturnsDefaultOptionsWithoutAnnotation() + { + var resource = new VercelEnvironmentResource("vercel"); + + var options = resource.GetVercelOptions(); + + Assert.Null(options.Scope); + Assert.Null(options.Target); + Assert.False(options.Production); + } + + private static PipelineStepContext CreatePipelineStepContext( + IDistributedApplicationBuilder builder, + DistributedApplication app, + ILogger? logger = null) + { + var model = app.Services.GetRequiredService(); + var pipelineContext = new PipelineContext( + model, + builder.ExecutionContext, + app.Services, + logger ?? NullLogger.Instance, + TestContext.Current.CancellationToken); + + return new() + { + PipelineContext = pipelineContext, + ReportingStep = NoopReportingStep.Instance + }; + } + + private static async Task> CreateConfiguredPipelineStepsAsync( + IDistributedApplicationBuilder builder, + DistributedApplication app) + { + var model = app.Services.GetRequiredService(); + var stepContext = CreatePipelineStepContext(builder, app); + List steps = []; + + foreach (var resource in model.Resources) + { + foreach (var annotation in resource.Annotations.OfType()) + { + var createdSteps = await annotation.CreateStepsAsync(new() + { + PipelineContext = stepContext.PipelineContext, + Resource = resource + }); + steps.AddRange(createdSteps); + } + } + + if (!steps.Any(static step => step.Name == "push-prereq")) + { + steps.Add(new PipelineStep + { + Name = "push-prereq", + Action = _ => Task.CompletedTask + }); + } + + foreach (var resource in model.Resources) + { + foreach (var annotation in resource.Annotations.OfType()) + { + await annotation.Callback(new() + { + Model = model, + Services = app.Services, + Steps = steps + }); + } + } + + return steps; + } + + private static void AssertPipelineDependenciesAreDistinct(IEnumerable steps) + { + foreach (var step in steps) + { + Assert.Equal(step.DependsOnSteps.Distinct(StringComparer.Ordinal), step.DependsOnSteps); + Assert.Equal(step.RequiredBySteps.Distinct(StringComparer.Ordinal), step.RequiredBySteps); + } + } + + private static VercelDeploymentEntry CreateDeploymentEntry(string sourceRoot) + { + string dockerfilePath = Path.Combine(sourceRoot, "Dockerfile"); + return new( + new ContainerResource("api"), + sourceRoot, + dockerfilePath, + new DockerfileBuildAnnotation(sourceRoot, dockerfilePath, stage: null)); + } + + private static VercelDeploymentEntry CreateGroupingEntry(string resourceName, string sourceRoot, bool isExternal) + { + var resource = new ContainerResource(resourceName); + resource.Annotations.Add(new EndpointAnnotation( + ProtocolType.Tcp, + uriScheme: "http", + transport: "http", + name: "http", + targetPort: 8080, + isExternal: isExternal)); + + return new(resource, sourceRoot); + } + + private static VercelPreparedDeploymentAnnotation CreatePreparedDeployment( + IResource resource, + string oidcToken, + string projectName) + { + var entry = new VercelDeploymentEntry(resource, Path.GetTempPath()); + var project = new VercelPulledProject( + projectName, + $"prj_{projectName}", + $"team_{projectName}", + "{}", + oidcToken); + var claims = new VercelOidcClaims( + $"team_{projectName}", + "test-team", + projectName, + $"prj_{projectName}"); + var projectContext = new VercelPulledProjectContext(project, claims); + + return new( + entry, + VercelProjectNameResolver.GetServiceName(resource), + new(projectName, project.ProjectId), + projectContext, + VercelEnvironmentConfiguration.Empty, + ManagedByAspire: true, + RemoteImageName: VercelProjectNameResolver.GetServiceName(resource), + RemoteImageTag: "aspire-test", + TaggedImageReference: $"vcr.vercel.com/test-team/{projectName}/{VercelProjectNameResolver.GetServiceName(resource)}:aspire-test"); + } + + private static void WriteVercelProjectLink(string sourceRoot, string projectName, string projectId) + { + string vercelDirectory = Path.Combine(sourceRoot, ".vercel"); + Directory.CreateDirectory(vercelDirectory); + File.WriteAllText(Path.Combine(vercelDirectory, "project.json"), $$""" + { + "projectId": "{{projectId}}", + "projectName": "{{projectName}}" + } + """); + } + + private static VercelCliResult ReadyInspectResult() + => new(0, """{"readyState":"READY"}""", ""); + + private static string ProjectListJson(params string[] projectNames) + => JsonSerializer.Serialize(new + { + projects = projectNames.Select(static projectName => new + { + name = projectName, + id = $"prj_{projectName.Replace('-', '_')}" + }) + }); + + private static string EnvListJson(params string[] names) + => JsonSerializer.Serialize(new + { + envs = names.Select(static name => new + { + key = name, + target = new[] { "preview" } + }) + }); + + private static async Task AssertWriteDeploymentPlanThrowsAsync( + Action> configureApi, + string? directoryName = null) + => await Assert.ThrowsAsync(() => + WriteDeploymentPlanForConfiguredContainerAsync(configureApi, directoryName)); + + private static async Task AssertWriteDeploymentPlanSucceedsAsync( + Action> configureApi, + string? directoryName = null) + => await WriteDeploymentPlanForConfiguredContainerAsync(configureApi, directoryName); + + private static async Task WriteDeploymentPlanForConfiguredContainerAsync( + Action> configureApi, + string? directoryName = null) + { + using var sourceRoot = TemporaryDirectory.Create(directoryName); + using var outputRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", outputRoot.Path]); + builder.AddVercelEnvironment("vercel"); + var api = builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile"); + configureApi(api); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + + await VercelDeploymentStep.WriteDeploymentPlanAsync(model, environment, outputRoot.Path, TestContext.Current.CancellationToken); + } + + private static VercelDeploymentState ReadSavedState(DeploymentStateSection section) + => JsonSerializer.Deserialize( + section.Data.First().Value!.GetValue(), + new JsonSerializerOptions(JsonSerializerDefaults.Web))!; + + private static string CreateTestJwt(string payloadJson) + => $"{Base64Url("{}")}.{Base64Url(payloadJson)}.signature"; + + private static string Base64Url(string value) + => Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(value)) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + + [Fact] + public async Task PreparePulledProjectContextDeletesScratchLinkDirectory() + { + using var sourceRoot = TemporaryDirectory.Create("pulled-project-context"); + using var outputRoot = TemporaryDirectory.Create(); + using var tempRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + var runner = new FakeVercelCliRunner(); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.Services.AddSingleton(runner); + builder.Services.AddSingleton(new FakePipelineOutputService(outputRoot.Path, tempRoot.Path)); + builder.AddVercelEnvironment("vercel") + .WithVercelProductionDeployments(); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var context = CreatePipelineStepContext(builder, app); + var entries = VercelDeploymentModel.GetEntries(model, environment).ToArray(); + var entry = Assert.Single(entries); + var entriesByResourceName = entries + .ToDictionary(static deployment => deployment.Resource.Name, StringComparer.Ordinal); + var projectMap = await VercelDeploymentProjectGrouper.CreateMapAsync(builder.ExecutionContext, NullLogger.Instance, entries, TestContext.Current.CancellationToken); + var group = Assert.Single(projectMap.Groups); + + var (projectContext, environmentConfigurations) = await VercelDeploymentStep.PreparePulledProjectContextAsync( + context, + runner, + environment.GetVercelOptions(), + group, + entriesByResourceName, + projectMap); + + string expectedProjectLinkDirectory = Path.Combine(tempRoot.Path, "api", ".vercel-project"); + string expectedProjectName = VercelProjectNameResolver.GetProjectName(entry); + Assert.False(Directory.Exists(expectedProjectLinkDirectory)); + Assert.Equal(expectedProjectName, projectContext.PulledProject.ProjectName); + Assert.Equal("prj_test", projectContext.PulledProject.ProjectId); + Assert.Equal("team_test", projectContext.OidcClaims.OwnerId); + Assert.Equal("test-team", projectContext.OidcClaims.Owner); + Assert.Equal("test-project", projectContext.OidcClaims.Project); + Assert.Contains("\"projectId\": \"prj_test\"", projectContext.PulledProject.ProjectJsonContent, StringComparison.Ordinal); + var environmentConfiguration = environmentConfigurations[entry.Resource.Name]; + Assert.Empty(environmentConfiguration.DeploymentEnvironmentVariables); + Assert.Empty(environmentConfiguration.ProjectEnvironmentVariables); + + Assert.Collection( + runner.Invocations, + invocation => Assert.Equal(["link", "--cwd", expectedProjectLinkDirectory, "--yes", "--project", expectedProjectName], invocation.Arguments), + invocation => Assert.Equal(["pull", "--cwd", expectedProjectLinkDirectory, "--yes", "--environment", "production"], invocation.Arguments)); + } + + [Fact] + public async Task WriteBuildOutputAsyncWritesProviderArtifactShape() + { + using var sourceRoot = TemporaryDirectory.Create(); + using var deployRoot = TemporaryDirectory.Create(); + File.WriteAllText(Path.Combine(sourceRoot.Path, "Dockerfile"), "FROM nginx:alpine"); + + var builder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]); + builder.AddVercelEnvironment("vercel"); + builder.AddContainer("api", "api") + .WithDockerfile(sourceRoot.Path, "Dockerfile"); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + var entry = Assert.Single(VercelDeploymentModel.GetEntries(model, environment)) with + { + DeployDirectory = deployRoot.Path + }; + var project = new VercelPulledProject( + "api", + "prj_test", + "team_test", + """ + { + "projectId": "prj_test", + "orgId": "team_test", + "projectName": "api" + } + """, + OidcToken: "unused"); + var service = new VercelDeploymentService(entry, "api", IsPublicRoot: true); + var group = new VercelDeploymentProjectGroup(service, [service]); + var projectContext = new VercelPulledProjectContext( + project, + new VercelOidcClaims("team_test", "test-team", "test-project", "prj_test")); + var preparedDeployment = new VercelPreparedDeploymentAnnotation( + entry, + "api", + new("api", "prj_test"), + projectContext, + VercelEnvironmentConfiguration.Empty, + ManagedByAspire: true, + RemoteImageName: "api", + RemoteImageTag: "aspire-test", + TaggedImageReference: "vcr.vercel.com/test-team/test-project/api:aspire-test"); + var resolvedDeployments = new[] + { + new VercelResolvedDeployment( + preparedDeployment, + new VercelImageReference( + $"vcr.vercel.com/test-team/test-project/api@{FakeVercelCliRunner.FakeImageDigest}", + FakeVercelCliRunner.FakeImageDigest)) + }; + + await VercelBuildOutputWriter.WriteAsync( + group, + project, + resolvedDeployments, + TestContext.Current.CancellationToken); + + AssertGeneratedBuildOutput(deployRoot.Path, expectedProjectName: "api"); + } + + [Fact] + public async Task WriteBuildOutputAsyncWritesServicesAndBindings() + { + using var deployRoot = TemporaryDirectory.Create(); + var apiEntry = CreateGroupingEntry("api", sourceRoot: "/tmp/api", isExternal: true) with + { + DeployDirectory = deployRoot.Path + }; + var backendEntry = CreateGroupingEntry("backend", sourceRoot: "/tmp/backend", isExternal: false); + var apiService = new VercelDeploymentService(apiEntry, "api", IsPublicRoot: true); + var backendService = new VercelDeploymentService(backendEntry, "backend", IsPublicRoot: false); + var group = new VercelDeploymentProjectGroup(apiService, [apiService, backendService]); + var project = new VercelPulledProject( + "api", + "prj_test", + "team_test", + """ + { + "projectId": "prj_test", + "orgId": "team_test", + "projectName": "api" + } + """, + OidcToken: "unused"); + var projectContext = new VercelPulledProjectContext( + project, + new VercelOidcClaims("team_test", "test-team", "test-project", "prj_test")); + + VercelResolvedDeployment CreateResolved( + VercelDeploymentEntry entry, + string serviceName, + VercelEnvironmentConfiguration environmentConfiguration) + { + var preparedDeployment = new VercelPreparedDeploymentAnnotation( + entry, + serviceName, + new("api", "prj_test"), + projectContext, + environmentConfiguration, + ManagedByAspire: true, + RemoteImageName: serviceName, + RemoteImageTag: "aspire-test", + TaggedImageReference: $"vcr.vercel.com/test-team/test-project/{serviceName}:aspire-test"); + + return new( + preparedDeployment, + new VercelImageReference( + $"vcr.vercel.com/test-team/test-project/{serviceName}@{FakeVercelCliRunner.FakeImageDigest}", + FakeVercelCliRunner.FakeImageDigest)); + } + + await VercelBuildOutputWriter.WriteAsync( + group, + project, + [ + CreateResolved(apiEntry, "api", new([], [], [new("BACKEND_URL", "backend")])), + CreateResolved(backendEntry, "backend", VercelEnvironmentConfiguration.Empty) + ], + TestContext.Current.CancellationToken); + + string outputDirectory = Path.Combine(deployRoot.Path, ".vercel", "output"); + Assert.True(File.Exists(Path.Combine(outputDirectory, "services", "api", "functions", "index.func", ".vc-config.json"))); + Assert.True(File.Exists(Path.Combine(outputDirectory, "services", "backend", "functions", "index.func", ".vc-config.json"))); + + using var configDocument = JsonDocument.Parse(File.ReadAllText(Path.Combine(outputDirectory, "config.json"))); + var root = configDocument.RootElement; + var experimentalServices = root.GetProperty("experimentalServicesV2"); + Assert.Equal("api/", experimentalServices.GetProperty("api").GetProperty("root").GetString()); + var binding = Assert.Single(experimentalServices.GetProperty("api").GetProperty("bindings").EnumerateArray()); + Assert.Equal("service", binding.GetProperty("type").GetString()); + Assert.Equal("backend", binding.GetProperty("service").GetString()); + Assert.Equal("url", binding.GetProperty("format").GetString()); + Assert.Equal("BACKEND_URL", binding.GetProperty("env").GetString()); + Assert.Equal("backend/", experimentalServices.GetProperty("backend").GetProperty("root").GetString()); + + var services = root.GetProperty("services").EnumerateArray().OrderBy(static service => service.GetProperty("name").GetString()).ToArray(); + Assert.Equal(["api", "backend"], services.Select(static service => service.GetProperty("name").GetString()!).ToArray()); + var serviceManifestBinding = Assert.Single(services[0].GetProperty("bindings").EnumerateArray()); + Assert.Equal("backend", serviceManifestBinding.GetProperty("service").GetString()); + } + + private static void AssertGeneratedBuildOutput( + string deployDirectory, + string? expectedProjectName = null, + IReadOnlyList>? expectedEnvironmentVariables = null) + { + string outputDirectory = Path.Combine(deployDirectory, ".vercel", "output"); + string projectJsonPath = Path.Combine(deployDirectory, ".vercel", "project.json"); + string configJsonPath = Path.Combine(outputDirectory, "config.json"); + string serviceConfigPath = Path.Combine(outputDirectory, "services", "api", "config.json"); + string functionConfigPath = Path.Combine(outputDirectory, "services", "api", "functions", "index.func", ".vc-config.json"); + var generatedFiles = Directory.EnumerateFiles(deployDirectory, "*", SearchOption.AllDirectories) + .Select(path => Path.GetRelativePath(deployDirectory, path).Replace(Path.DirectorySeparatorChar, '/')) + .Order(StringComparer.Ordinal) + .ToArray(); + + Assert.Equal( + [ + ".vercel/output/config.json", + ".vercel/output/services/api/config.json", + ".vercel/output/services/api/functions/index.func/.vc-config.json", + ".vercel/project.json" + ], + generatedFiles); + + using var projectDocument = JsonDocument.Parse(File.ReadAllText(projectJsonPath)); + var project = projectDocument.RootElement; + Assert.Equal("prj_test", project.GetProperty("projectId").GetString()); + Assert.Equal("team_test", project.GetProperty("orgId").GetString()); + string? projectName = project.GetProperty("projectName").GetString(); + if (expectedProjectName is null) + { + Assert.False(string.IsNullOrWhiteSpace(projectName)); + } + else + { + Assert.Equal(expectedProjectName, projectName); + } + + using var configDocument = JsonDocument.Parse(File.ReadAllText(configJsonPath)); + var root = configDocument.RootElement; + Assert.Equal(["version", "routes", "crons", "experimentalServicesV2", "services"], root.EnumerateObject().Select(static property => property.Name).ToArray()); + Assert.Equal(3, root.GetProperty("version").GetInt32()); + var routes = root.GetProperty("routes").EnumerateArray().ToArray(); + Assert.Equal(2, routes.Length); + Assert.Equal(["handle"], routes[0].EnumerateObject().Select(static property => property.Name).ToArray()); + Assert.Equal("filesystem", routes[0].GetProperty("handle").GetString()); + Assert.Equal(["src", "destination"], routes[1].EnumerateObject().Select(static property => property.Name).ToArray()); + Assert.Equal("^(?:/(.*))$", routes[1].GetProperty("src").GetString()); + var destination = routes[1].GetProperty("destination"); + Assert.Equal("api", destination.GetProperty("service").GetString()); + Assert.Equal("service", destination.GetProperty("type").GetString()); + Assert.Empty(root.GetProperty("crons").EnumerateArray()); + var experimentalServices = root.GetProperty("experimentalServicesV2"); + Assert.Equal("api/", experimentalServices.GetProperty("api").GetProperty("root").GetString()); + var serviceManifest = Assert.Single(root.GetProperty("services").EnumerateArray()); + Assert.Equal("experimentalServicesV2", serviceManifest.GetProperty("schema").GetString()); + Assert.Equal("api", serviceManifest.GetProperty("name").GetString()); + Assert.Equal("api", serviceManifest.GetProperty("root").GetString()); + + using var serviceDocument = JsonDocument.Parse(File.ReadAllText(serviceConfigPath)); + var serviceConfig = serviceDocument.RootElement; + Assert.Equal(["version", "routes", "crons"], serviceConfig.EnumerateObject().Select(static property => property.Name).ToArray()); + Assert.Equal(3, serviceConfig.GetProperty("version").GetInt32()); + var serviceRoutes = serviceConfig.GetProperty("routes").EnumerateArray().ToArray(); + Assert.Equal(2, serviceRoutes.Length); + Assert.Equal("filesystem", serviceRoutes[0].GetProperty("handle").GetString()); + Assert.Equal("/(.*)", serviceRoutes[1].GetProperty("src").GetString()); + Assert.Equal("/index", serviceRoutes[1].GetProperty("dest").GetString()); + Assert.Empty(serviceConfig.GetProperty("crons").EnumerateArray()); + + using var functionDocument = JsonDocument.Parse(File.ReadAllText(functionConfigPath)); + var functionConfig = functionDocument.RootElement; + Assert.Equal(["handler", "runtime", "environment"], functionConfig.EnumerateObject().Select(static property => property.Name).ToArray()); + Assert.Equal("container", functionConfig.GetProperty("runtime").GetString()); + Assert.Equal($"vcr.vercel.com/test-team/test-project/api@{FakeVercelCliRunner.FakeImageDigest}", functionConfig.GetProperty("handler").GetString()); + expectedEnvironmentVariables ??= []; + var environmentVariables = functionConfig.GetProperty("environment").EnumerateObject().OrderBy(static property => property.Name).ToArray(); + Assert.Equal(expectedEnvironmentVariables.Select(static variable => variable.Key).Order(StringComparer.Ordinal), environmentVariables.Select(static variable => variable.Name)); + foreach (var expectedEnvironmentVariable in expectedEnvironmentVariables) + { + Assert.Equal(expectedEnvironmentVariable.Value, functionConfig.GetProperty("environment").GetProperty(expectedEnvironmentVariable.Key).GetString()); + } + } + + private sealed class FakeProjectMetadata(string projectPath) : IProjectMetadata + { + public bool IsFileBasedApp => false; + + public LaunchSettings? LaunchSettings => null; + + public string ProjectPath => projectPath; + + public bool SuppressBuild => false; + } + + private sealed class TestLanguageAppResource(string name, string command, string workingDirectory) + : ExecutableResource(name, command, workingDirectory); + + private sealed class TemporaryDirectory : IDisposable + { + private TemporaryDirectory(string path) + { + Path = path; + } + + public string Path { get; } + + public static TemporaryDirectory Create(string? directoryName = null) + { + string path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), directoryName ?? $"vercel-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(path); + + return new(path); + } + + public void Dispose() + { + if (Directory.Exists(Path)) + { + Directory.Delete(Path, recursive: true); + } + } + } + + private sealed class CoordinatedVercelDeployRunner( + int expectedDeployments, + bool waitForAllDeployments, + IReadOnlyDictionary? deployResults = null) : IVercelCliRunner + { + private readonly Dictionary _linkedProjects = new(StringComparer.Ordinal); + private readonly HashSet _projects = new(StringComparer.Ordinal); + private readonly TaskCompletionSource _allDeploymentsStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _activeDeployments; + private int _activeDigestInspections; + private int _startedDeployments; + private int _maxConcurrentDeployments; + private int _maxConcurrentDigestInspections; + + public int MaxConcurrentDeployments => Volatile.Read(ref _maxConcurrentDeployments); + + public int MaxConcurrentDigestInspections => Volatile.Read(ref _maxConcurrentDigestInspections); + + public Task GetVersionAsync(CancellationToken cancellationToken) + => Task.FromResult(new VercelCliResult(0, "54.18.6", "")); + + public Task GetCurrentUserAsync(CancellationToken cancellationToken) + => Task.FromResult(new VercelCliResult(0, "test-user", "")); + + public Task ListProjectsAsync( + VercelEnvironmentOptionsAnnotation options, + string? filter, + CancellationToken cancellationToken) + { + string[] projects = _projects + .Where(project => string.IsNullOrWhiteSpace(filter) || project.Contains(filter, StringComparison.Ordinal)) + .Order(StringComparer.Ordinal) + .ToArray(); + + return Task.FromResult(new VercelCliResult(0, ProjectListJson(projects), "")); + } + + public Task AddProjectAsync( + VercelEnvironmentOptionsAnnotation options, + string projectName, + CancellationToken cancellationToken) + { + _projects.Add(projectName); + return Task.FromResult(new VercelCliResult(0, "", "")); + } + + public Task RemoveProjectAsync( + VercelEnvironmentOptionsAnnotation options, + string projectName, + CancellationToken cancellationToken) + { + _projects.Remove(projectName); + return Task.FromResult(new VercelCliResult(0, "", "")); + } + + public Task LinkProjectAsync( + VercelEnvironmentOptionsAnnotation options, + string projectLinkDirectory, + string projectNameOrId, + CancellationToken cancellationToken) + { + _linkedProjects[projectLinkDirectory] = projectNameOrId; + return Task.FromResult(new VercelCliResult(0, "", "")); + } + + public Task PullProjectSettingsAsync( + VercelEnvironmentOptionsAnnotation options, + string projectLinkDirectory, + string targetEnvironment, + CancellationToken cancellationToken) + { + string project = _linkedProjects.GetValueOrDefault(projectLinkDirectory, "api"); + string vercelDirectory = Path.Combine(projectLinkDirectory, ".vercel"); + Directory.CreateDirectory(vercelDirectory); + File.WriteAllText(Path.Combine(vercelDirectory, "project.json"), $$""" + { + "projectId": "prj_{{project}}", + "orgId": "team_{{project}}", + "projectName": "{{project}}", + "settings": { + "createdAt": 0, + "framework": null, + "devCommand": null, + "installCommand": null, + "buildCommand": null, + "outputDirectory": null, + "rootDirectory": null, + "directoryListing": false, + "nodeVersion": "22.x" + } + } + """); + File.WriteAllText(Path.Combine(vercelDirectory, $".env.{targetEnvironment}.local"), $"VERCEL_OIDC_TOKEN=\"{CreateFakeOidcToken(project)}\"{Environment.NewLine}"); + + return Task.FromResult(new VercelCliResult(0, "", "")); + } + + public Task ListProjectEnvironmentVariablesAsync( + VercelEnvironmentOptionsAnnotation options, + string projectLinkDirectory, + string targetEnvironment, + CancellationToken cancellationToken) + => Task.FromResult(new VercelCliResult(0, EnvListJson(), "")); + + public Task AddProjectEnvironmentVariableAsync( + VercelEnvironmentOptionsAnnotation options, + string projectLinkDirectory, + string name, + string targetEnvironment, + string value, + CancellationToken cancellationToken) + => Task.FromResult(new VercelCliResult(0, "", "")); + + public Task RemoveProjectEnvironmentVariableAsync( + VercelEnvironmentOptionsAnnotation options, + string projectLinkDirectory, + string name, + string targetEnvironment, + CancellationToken cancellationToken) + => Task.FromResult(new VercelCliResult(0, "", "")); + + public async Task DeployPrebuiltAsync( + VercelEnvironmentOptionsAnnotation options, + string deployDirectory, + string? projectNameOrId, + IReadOnlyList> environmentVariables, + CancellationToken cancellationToken) + { + int activeDeployments = Interlocked.Increment(ref _activeDeployments); + UpdateMax(ref _maxConcurrentDeployments, activeDeployments); + + try + { + if (Interlocked.Increment(ref _startedDeployments) == expectedDeployments) + { + _allDeploymentsStarted.TrySetResult(); + } + + if (waitForAllDeployments) + { + await _allDeploymentsStarted.Task.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken).ConfigureAwait(false); + } + + string project = projectNameOrId ?? "api"; + if (deployResults is not null && deployResults.TryGetValue(project, out var configuredResult)) + { + return configuredResult; + } + + return new(0, $"https://{project}.vercel.app", ""); + } + finally + { + Interlocked.Decrement(ref _activeDeployments); + } + } + + public Task InspectDeploymentAsync( + VercelEnvironmentOptionsAnnotation options, + string deploymentUrl, + CancellationToken cancellationToken) + => Task.FromResult(ReadyInspectResult()); + + public Task ValidateDockerBuildxAsync(CancellationToken cancellationToken) + => Task.FromResult(new VercelCliResult(0, "", "")); + + public Task LoginToVcrAsync(string ownerId, string oidcToken, CancellationToken cancellationToken) + => Task.FromResult(new VercelCliResult(0, "", "")); + + public async Task InspectDockerImageDigestAsync(string imageReference, CancellationToken cancellationToken) + { + int activeDigestInspections = Interlocked.Increment(ref _activeDigestInspections); + UpdateMax(ref _maxConcurrentDigestInspections, activeDigestInspections); + + try + { + await Task.Delay(25, cancellationToken).ConfigureAwait(false); + return new(0, FakeVercelCliRunner.FakeImageManifestJson, ""); + } + finally + { + Interlocked.Decrement(ref _activeDigestInspections); + } + } + + private static void UpdateMax(ref int max, int value) + { + while (true) + { + int current = Volatile.Read(ref max); + if (value <= current) + { + return; + } + + if (Interlocked.CompareExchange(ref max, value, current) == current) + { + return; + } + } + } + + private static string CreateFakeOidcToken(string project) + { + string header = Base64UrlEncode("""{"alg":"none"}"""); + string payload = Base64UrlEncode($$"""{"owner_id":"team_{{project}}","owner":"test-team","project":"{{project}}","project_id":"prj_{{project}}"}"""); + return $"{header}.{payload}."; + } + + private static string Base64UrlEncode(string value) + => Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(value)) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } + + private sealed class FakeVercelCliRunner : IVercelCliRunner + { + private readonly VercelCliRunner _runner; + private readonly Queue _results; + private readonly HashSet _projects = new(StringComparer.Ordinal); + private readonly Dictionary _linkedProjects = new(StringComparer.Ordinal); + private readonly HashSet _projectEnvironmentVariables = new(StringComparer.Ordinal) + { + "API_KEY", + "AUTH_HEADER", + "OLD_KEY" + }; + public const string FakeImageDigest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + public const string FakeImageIndexDigest = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + public const string FakeImageManifestJson = """ + { + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.index.v1+json", + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "manifests": [ + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "platform": { + "architecture": "amd64", + "os": "linux" + } + }, + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "platform": { + "architecture": "unknown", + "os": "unknown" + } + } + ] + } + """; + + public List Invocations { get; } = []; + + public FakeVercelCliRunner(params VercelCliResult[] results) + { + _results = new(results); + _runner = new(RunProcessAsync); + } + + public Task GetVersionAsync(CancellationToken cancellationToken) + => _runner.GetVersionAsync(cancellationToken); + + public Task GetCurrentUserAsync(CancellationToken cancellationToken) + => _runner.GetCurrentUserAsync(cancellationToken); + + public Task ListProjectsAsync( + VercelEnvironmentOptionsAnnotation options, + string? filter, + CancellationToken cancellationToken) + => _runner.ListProjectsAsync(options, filter, cancellationToken); + + public Task AddProjectAsync( + VercelEnvironmentOptionsAnnotation options, + string projectName, + CancellationToken cancellationToken) + => _runner.AddProjectAsync(options, projectName, cancellationToken); + + public Task RemoveProjectAsync( + VercelEnvironmentOptionsAnnotation options, + string projectName, + CancellationToken cancellationToken) + => _runner.RemoveProjectAsync(options, projectName, cancellationToken); + + public Task LinkProjectAsync( + VercelEnvironmentOptionsAnnotation options, + string projectLinkDirectory, + string projectNameOrId, + CancellationToken cancellationToken) + => _runner.LinkProjectAsync(options, projectLinkDirectory, projectNameOrId, cancellationToken); + + public Task PullProjectSettingsAsync( + VercelEnvironmentOptionsAnnotation options, + string projectLinkDirectory, + string targetEnvironment, + CancellationToken cancellationToken) + => _runner.PullProjectSettingsAsync(options, projectLinkDirectory, targetEnvironment, cancellationToken); + + public Task ListProjectEnvironmentVariablesAsync( + VercelEnvironmentOptionsAnnotation options, + string projectLinkDirectory, + string targetEnvironment, + CancellationToken cancellationToken) + => _runner.ListProjectEnvironmentVariablesAsync(options, projectLinkDirectory, targetEnvironment, cancellationToken); + + public Task AddProjectEnvironmentVariableAsync( + VercelEnvironmentOptionsAnnotation options, + string projectLinkDirectory, + string name, + string targetEnvironment, + string value, + CancellationToken cancellationToken) + => _runner.AddProjectEnvironmentVariableAsync(options, projectLinkDirectory, name, targetEnvironment, value, cancellationToken); + + public Task RemoveProjectEnvironmentVariableAsync( + VercelEnvironmentOptionsAnnotation options, + string projectLinkDirectory, + string name, + string targetEnvironment, + CancellationToken cancellationToken) + => _runner.RemoveProjectEnvironmentVariableAsync(options, projectLinkDirectory, name, targetEnvironment, cancellationToken); + + public Task DeployPrebuiltAsync( + VercelEnvironmentOptionsAnnotation options, + string deployDirectory, + string? projectNameOrId, + IReadOnlyList> environmentVariables, + CancellationToken cancellationToken) + => _runner.DeployPrebuiltAsync(options, deployDirectory, projectNameOrId, environmentVariables, cancellationToken); + + public Task InspectDeploymentAsync( + VercelEnvironmentOptionsAnnotation options, + string deploymentUrl, + CancellationToken cancellationToken) + => _runner.InspectDeploymentAsync(options, deploymentUrl, cancellationToken); + + public Task ValidateDockerBuildxAsync(CancellationToken cancellationToken) + => _runner.ValidateDockerBuildxAsync(cancellationToken); + + public Task LoginToVcrAsync(string ownerId, string oidcToken, CancellationToken cancellationToken) + => _runner.LoginToVcrAsync(ownerId, oidcToken, cancellationToken); + + public Task InspectDockerImageDigestAsync(string imageReference, CancellationToken cancellationToken) + => _runner.InspectDockerImageDigestAsync(imageReference, cancellationToken); + + private Task RunProcessAsync( + string fileName, + IReadOnlyList arguments, + string? workingDirectory, + CancellationToken cancellationToken, + string? standardInput = null) + { + Invocations.Add(new(fileName, [.. arguments], workingDirectory, standardInput)); + + if (TryGetAutoResult(fileName, arguments, workingDirectory, out var autoResult)) + { + return Task.FromResult(autoResult); + } + + var result = _results.Count > 0 + ? _results.Dequeue() + : new VercelCliResult(0, "", ""); + + return Task.FromResult(result); + } + + private bool TryGetAutoResult( + string fileName, + IReadOnlyList arguments, + string? workingDirectory, + out VercelCliResult result) + { + result = new VercelCliResult(0, "", ""); + + if (string.Equals(fileName, "docker", StringComparison.Ordinal)) + { + if (arguments.Count >= 2 + && string.Equals(arguments[0], "buildx", StringComparison.Ordinal) + && string.Equals(arguments[1], "version", StringComparison.Ordinal)) + { + return true; + } + + if (arguments.Count >= 2 + && string.Equals(arguments[0], "buildx", StringComparison.Ordinal) + && string.Equals(arguments[1], "imagetools", StringComparison.Ordinal)) + { + result = new VercelCliResult(0, FakeImageManifestJson, ""); + return true; + } + + if (arguments.Count >= 1 + && string.Equals(arguments[0], "login", StringComparison.Ordinal)) + { + return true; + } + } + + if (string.Equals(fileName, "vercel", StringComparison.Ordinal)) + { + if (arguments is ["--version"] && ShouldAutoVercelVersion()) + { + result = new VercelCliResult(0, "54.18.6", ""); + return true; + } + + if (arguments is ["whoami"] && ShouldAutoVercelWhoami()) + { + result = new VercelCliResult(0, "test-user", ""); + return true; + } + + if (arguments.Count >= 2 + && string.Equals(arguments[0], "project", StringComparison.Ordinal) + && string.Equals(arguments[1], "ls", StringComparison.Ordinal) + && ShouldAutoVercelProjectList()) + { + string? filter = GetOptionValue(arguments, "--filter"); + string[] projects = _projects + .Where(project => string.IsNullOrWhiteSpace(filter) || project.Contains(filter, StringComparison.Ordinal)) + .Order(StringComparer.Ordinal) + .ToArray(); + result = new VercelCliResult(0, ProjectListJson(projects), ""); + return true; + } + } + + for (int i = 0; i < arguments.Count - 1; i++) + { + if (string.Equals(arguments[i], "project", StringComparison.Ordinal) + && string.Equals(arguments[i + 1], "add", StringComparison.Ordinal)) + { + if (i + 2 < arguments.Count) + { + _projects.Add(arguments[i + 2]); + } + + return true; + } + } + + if (arguments.Count >= 3 + && string.Equals(arguments[0], "project", StringComparison.Ordinal) + && string.Equals(arguments[1], "remove", StringComparison.Ordinal) + && ShouldAutoVercelProjectRemove()) + { + _projects.Remove(arguments[2]); + return true; + } + + if (arguments.Count > 0 + && string.Equals(arguments[0], "link", StringComparison.Ordinal)) + { + string? cwd = GetOptionValue(arguments, "--cwd") ?? workingDirectory; + string? project = GetOptionValue(arguments, "--project"); + if (cwd is not null && project is not null) + { + _linkedProjects[cwd] = project; + } + + return true; + } + + if (arguments.Count >= 2 + && string.Equals(arguments[0], "env", StringComparison.Ordinal) + && string.Equals(arguments[1], "add", StringComparison.Ordinal)) + { + if (arguments.Count >= 3) + { + _projectEnvironmentVariables.Add(arguments[2]); + } + + return true; + } + + if (arguments.Count >= 2 + && string.Equals(arguments[0], "env", StringComparison.Ordinal) + && string.Equals(arguments[1], "ls", StringComparison.Ordinal)) + { + result = new VercelCliResult(0, EnvListJson([.. _projectEnvironmentVariables.Order(StringComparer.Ordinal)]), ""); + return true; + } + + if (arguments.Count >= 2 + && string.Equals(arguments[0], "env", StringComparison.Ordinal) + && string.Equals(arguments[1], "rm", StringComparison.Ordinal)) + { + if (arguments.Count >= 3) + { + _projectEnvironmentVariables.Remove(arguments[2]); + } + + return true; + } + + if (arguments.Count > 0 + && string.Equals(arguments[0], "pull", StringComparison.Ordinal)) + { + string? cwd = GetOptionValue(arguments, "--cwd") ?? workingDirectory; + string targetEnvironment = GetOptionValue(arguments, "--environment") ?? "preview"; + if (cwd is not null) + { + WritePulledProjectFiles(cwd, targetEnvironment); + } + + return true; + } + + return false; + } + + private bool ShouldAutoVercelVersion() + { + if (!_results.TryPeek(out var next)) + { + return true; + } + + string output = $"{next.StandardOutput}{Environment.NewLine}{next.StandardError}"; + if (LooksLikeDeployResult(output)) + { + return true; + } + + return next.Succeeded + && !VercelCliOutputParser.TryGetCliVersion(output, out _) + && !output.TrimStart().StartsWith("vercel ", StringComparison.OrdinalIgnoreCase); + } + + private bool ShouldAutoVercelWhoami() + => !_results.TryPeek(out var next) + || (next.Succeeded && (IsEmptyResult(next) || LooksLikeDeployResult(next.StandardOutput))); + + private bool ShouldAutoVercelProjectList() + => !_results.TryPeek(out var next) + || (next.Succeeded && (IsEmptyResult(next) || (LooksLikeDeployResult(next.StandardOutput) && !LooksLikeProjectListResult(next.StandardOutput)))); + + private bool ShouldAutoVercelProjectRemove() + => !_results.TryPeek(out _); + + private static bool IsEmptyResult(VercelCliResult result) + => string.IsNullOrWhiteSpace(result.StandardOutput) + && string.IsNullOrWhiteSpace(result.StandardError); + + private static bool LooksLikeDeployResult(string output) + => output.Contains("https://", StringComparison.OrdinalIgnoreCase) + || output.TrimStart().StartsWith("{", StringComparison.Ordinal); + + private static bool LooksLikeProjectListResult(string output) + { + string trimmed = output.TrimStart(); + return trimmed.StartsWith("[", StringComparison.Ordinal) + || trimmed.StartsWith("{\"projects\"", StringComparison.Ordinal) + || trimmed.StartsWith("{ \"projects\"", StringComparison.Ordinal); + } + + private void WritePulledProjectFiles(string directory, string targetEnvironment) + { + string project = _linkedProjects.GetValueOrDefault(directory, "api"); + string vercelDirectory = Path.Combine(directory, ".vercel"); + Directory.CreateDirectory(vercelDirectory); + File.WriteAllText(Path.Combine(vercelDirectory, "project.json"), $$""" + { + "projectId": "prj_test", + "orgId": "team_test", + "projectName": "{{project}}", + "settings": { + "createdAt": 0, + "framework": null, + "devCommand": null, + "installCommand": null, + "buildCommand": null, + "outputDirectory": null, + "rootDirectory": null, + "directoryListing": false, + "nodeVersion": "22.x" + } + } + """); + File.WriteAllText(Path.Combine(vercelDirectory, $".env.{targetEnvironment}.local"), $"{VercelOidcTokenEnvironmentVariableForTests()}=\"{CreateFakeOidcToken()}\"{Environment.NewLine}"); + File.WriteAllText(Path.Combine(directory, ".env.local"), $"{VercelOidcTokenEnvironmentVariableForTests()}=\"{CreateFakeOidcToken()}\"{Environment.NewLine}"); + } + + private static string? GetOptionValue(IReadOnlyList arguments, string option) + { + for (int i = 0; i < arguments.Count - 1; i++) + { + if (string.Equals(arguments[i], option, StringComparison.Ordinal)) + { + return arguments[i + 1]; + } + } + + return null; + } + + private static string CreateFakeOidcToken() + { + string header = Base64UrlEncode("""{"alg":"none"}"""); + string payload = Base64UrlEncode("""{"owner_id":"team_test","owner":"test-team","project":"test-project","project_id":"prj_test"}"""); + return $"{header}.{payload}."; + } + + private static string Base64UrlEncode(string value) + => Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(value)) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + + private static string VercelOidcTokenEnvironmentVariableForTests() => "VERCEL_OIDC_TOKEN"; + } + + private sealed class FakeVercelContainerRegistryClient : IVercelContainerRegistryClient + { + public List Repositories { get; } = []; + + public Task EnsureRepositoryAsync(string token, VercelOidcClaims claims, string repository, CancellationToken cancellationToken) + { + Repositories.Add(repository); + return Task.CompletedTask; + } + } + + private sealed class RecordingLogger : ILogger + { + public List Entries { get; } = []; + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + Entries.Add(new(logLevel, formatter(state, exception))); + } + } + + private sealed record LogEntry(LogLevel Level, string Message); + + private sealed record VercelCliInvocation( + string FileName, + string[] Arguments, + string? WorkingDirectory, + string? StandardInput); + + private sealed class ThrowingResourceContainerImageManager : IResourceContainerImageManager + { + public int CallCount { get; private set; } + + public Task BuildImageAsync(IResource resource, CancellationToken cancellationToken) + { + CallCount++; + throw new InvalidOperationException("Vercel deployments must not build container images locally."); + } + + public Task BuildImagesAsync(IEnumerable resources, CancellationToken cancellationToken) + { + CallCount++; + throw new InvalidOperationException("Vercel deployments must not build container images locally."); + } + + public Task PushImageAsync(IResource resource, CancellationToken cancellationToken) + { + CallCount++; + throw new InvalidOperationException("Vercel deployments must not push container images."); + } + } + + private sealed class VerifyingResourceContainerImageManager(FakeVercelCliRunner runner) : IResourceContainerImageManager + { + private int _activePushes; + + public List PushedResources { get; } = []; + + public Task BuildImageAsync(IResource resource, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task BuildImagesAsync(IEnumerable resources, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public async Task PushImageAsync(IResource resource, CancellationToken cancellationToken) + { + Assert.Equal(1, Interlocked.Increment(ref _activePushes)); + try + { + await Task.Delay(25, cancellationToken); + + var preparedDeployment = Assert.Single(resource.Annotations.OfType()); + var loginInvocation = runner.Invocations.Last(invocation => invocation.FileName == "docker" && invocation.Arguments.Contains("login")); + Assert.Equal(preparedDeployment.ProjectContext.PulledProject.OidcToken, loginInvocation.StandardInput); + PushedResources.Add(resource.Name); + } + finally + { + Interlocked.Decrement(ref _activePushes); + } + } + } + + private sealed class FakeDeploymentStateManager : IDeploymentStateManager + { + private readonly Dictionary _sections = new(StringComparer.Ordinal); + + public string? StateFilePath => null; + + public List SavedSections { get; } = []; + + public List DeletedSections { get; } = []; + + public void SetSection(string sectionName, string value) + { + var section = new DeploymentStateSection(sectionName, new JsonObject(), 0); + section.SetValue(value); + _sections[sectionName] = section; + } + + public Task AcquireSectionAsync(string sectionName, CancellationToken cancellationToken) + { + if (!_sections.TryGetValue(sectionName, out var section)) + { + section = new(sectionName, new JsonObject(), 0); + _sections.Add(sectionName, section); + } + + return Task.FromResult(section); + } + + public Task SaveSectionAsync(DeploymentStateSection section, CancellationToken cancellationToken) + { + SavedSections.Add(section); + _sections[section.SectionName] = section; + return Task.CompletedTask; + } + + public Task DeleteSectionAsync(DeploymentStateSection section, CancellationToken cancellationToken) + { + DeletedSections.Add(section); + _sections.Remove(section.SectionName); + return Task.CompletedTask; + } + + public Task ClearAllStateAsync(CancellationToken cancellationToken) + { + _sections.Clear(); + return Task.CompletedTask; + } + } + + private sealed class FakePipelineOutputService(string outputDirectory, string? tempDirectory = null) : IPipelineOutputService + { + private readonly string _tempDirectory = tempDirectory ?? outputDirectory; + + public string GetOutputDirectory() => outputDirectory; + + public string GetOutputDirectory(IResource resource) => outputDirectory; + + public string GetTempDirectory() => _tempDirectory; + + public string GetTempDirectory(IResource resource) => Path.Combine(_tempDirectory, resource.Name); + } + + private sealed class NoopReportingStep : IReportingStep + { + public static NoopReportingStep Instance { get; } = new(); + + public Task CreateTaskAsync(MarkdownString statusText, CancellationToken cancellationToken = default) + => Task.FromResult(NoopReportingTask.Instance); + + public Task CreateTaskAsync(string statusText, CancellationToken cancellationToken = default) + => Task.FromResult(NoopReportingTask.Instance); + + public Task CompleteAsync(MarkdownString completionText, CompletionState completionState = CompletionState.Completed, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public Task CompleteAsync(string completionText, CompletionState completionState = CompletionState.Completed, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public void Log(LogLevel logLevel, MarkdownString message) + { + } + + public void Log(LogLevel logLevel, string message) + { + } + + [Obsolete("Use Log(LogLevel, string) or Log(LogLevel, MarkdownString) instead.")] + public void Log(LogLevel logLevel, string message, bool enableMarkdown) + { + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } + + private sealed class NoopReportingTask : IReportingTask + { + public static NoopReportingTask Instance { get; } = new(); + + public Task UpdateAsync(MarkdownString statusText, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public Task UpdateAsync(string statusText, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public Task CompleteAsync(MarkdownString completionMessage, CompletionState completionState = CompletionState.Completed, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public Task CompleteAsync(string? completionMessage = null, CompletionState completionState = CompletionState.Completed, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } +}