From 0cdb9dea577568dbffe9a91cf69830ce4b836c91 Mon Sep 17 00:00:00 2001 From: Alex Benjamin Date: Sun, 27 Sep 2026 21:40:04 +0100 Subject: [PATCH] fixed all issues --- .github/workflows/pnpm-version-check.yml | 49 ++++++++++ README.md | 15 ++- backend/openapi.json | 60 +++++++++++- backend/package-lock.json | 26 ++++-- backend/prisma/dev.db | Bin 815104 -> 827392 bytes backend/schema-snapshots/get-_health.json | 12 ++- backend/schema-snapshots/get-_ready.json | 6 +- backend/src/__tests__/vaultApy.test.ts | 108 ++++++++++++++++++++++ backend/src/index.ts | 34 +++++++ backend/src/services/apy.ts | 68 ++++++++++++++ package.json | 6 ++ scripts/check-pnpm-version.js | 83 +++++++++++++++++ 12 files changed, 450 insertions(+), 17 deletions(-) create mode 100644 .github/workflows/pnpm-version-check.yml create mode 100644 backend/src/__tests__/vaultApy.test.ts create mode 100644 backend/src/services/apy.ts create mode 100644 scripts/check-pnpm-version.js diff --git a/.github/workflows/pnpm-version-check.yml b/.github/workflows/pnpm-version-check.yml new file mode 100644 index 000000000..8af02a517 --- /dev/null +++ b/.github/workflows/pnpm-version-check.yml @@ -0,0 +1,49 @@ +name: pnpm version check + +# Catches ERR_PNPM_LOCKFILE_BREAKING_CHANGE before contributors waste 30 min +# debugging a pnpm 8 vs pnpm 9 lockfile mismatch (Issue #1458). + +on: + pull_request: + push: + branches: [main] + +jobs: + # ── Required version passes ───────────────────────────────────────────────── + pnpm-version-ok: + name: pnpm >=9.12 (required range) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Enable Corepack + run: corepack enable + + - name: Activate pnpm 9.12 via Corepack + run: corepack prepare pnpm@9.12.0 --activate + + - name: Verify pnpm version satisfies requirement + run: node scripts/check-pnpm-version.js + + # ── pnpm 8 fails fast with a clear message ─────────────────────────────────── + pnpm-v8-rejected: + name: pnpm 8 rejected with clear error + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install pnpm 8 (the bad version) + run: npm install -g pnpm@8 + + - name: Expect check script to exit non-zero for pnpm 8 + # The script must fail — if it exits 0 the version guard is broken. + run: | + node scripts/check-pnpm-version.js && echo "ERROR: script should have failed" && exit 1 || echo "OK: pnpm 8 correctly rejected" diff --git a/README.md b/README.md index a4bfc77f4..845a54112 100644 --- a/README.md +++ b/README.md @@ -50,14 +50,21 @@ For a cross-layer view of ownership boundaries, API flow maps, event propagation ### Quick Start +> **Node ≥ 20 and pnpm ≥ 9.12 are required.** The lockfile format changed between pnpm 8 and pnpm 9; using an older version produces `ERR_PNPM_LOCKFILE_BREAKING_CHANGE`. Use [Corepack](https://nodejs.org/api/corepack.html) (bundled with Node ≥ 16) to pin the right version automatically: +> +> ```bash +> corepack enable +> corepack prepare pnpm@9.12.0 --activate +> ``` + 1. Start the backend: ```bash cd backend cp .env.example .env - npm install + pnpm install npx prisma migrate dev - npm run dev + pnpm dev ``` 2. Start the frontend in a second terminal: @@ -65,8 +72,8 @@ For a cross-layer view of ownership boundaries, API flow maps, event propagation ```bash cd frontend cp .env.example .env - npm install - npm run dev + pnpm install + pnpm dev ``` 3. Optional: run contract tests from the repo root: diff --git a/backend/openapi.json b/backend/openapi.json index 164916fb7..ddaeadfbb 100644 --- a/backend/openapi.json +++ b/backend/openapi.json @@ -143,8 +143,15 @@ "example": 0 }, "apy": { - "type": "number", - "example": 0 + "type": ["number", "null"], + "example": 8.45, + "description": "Annualised APY as a decimal percentage. null when the vault has insufficient price history (e.g. zero shares or fewer than 2 snapshots)." + }, + "apyStatus": { + "type": "string", + "enum": ["ok", "insufficient_data"], + "example": "ok", + "description": "ok when apy is a valid number; insufficient_data when apy is null." }, "timestamp": { "type": "string", @@ -371,6 +378,55 @@ } } }, + "/api/v1/vaults/{id}/apy": { + "get": { + "tags": [ + "Vault" + ], + "summary": "Vault APY", + "description": "Returns the annualised APY for the specified vault. Returns `apy: null` with `apyStatus: 'insufficient_data'` when the vault has zero shares or fewer than two price snapshots (e.g. a newly created vault).", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Vault identifier" + } + ], + "responses": { + "200": { + "description": "Vault APY result", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["apy", "apyStatus", "timestamp"], + "properties": { + "apy": { + "type": ["number", "null"], + "example": 8.45, + "description": "Annualised APY as a decimal percentage. null when insufficient data." + }, + "apyStatus": { + "type": "string", + "enum": ["ok", "insufficient_data"], + "example": "ok" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + } + } + } + } + } + } + }, "/api/v1/vault/deposits": { "post": { "tags": [ diff --git a/backend/package-lock.json b/backend/package-lock.json index b0061a963..20dc8c80b 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -2474,9 +2474,9 @@ } }, "node_modules/@opentelemetry/api": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", - "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.8.0.tgz", + "integrity": "sha512-I/s6F7yKUDdtMsoBWXJe8Qz40Tui5vsuKCWJEWVL+5q9sSWRzzx6v2KeNsOBEwd94j0eWkpWCH4yB6rZg9Mf0w==", "license": "Apache-2.0", "engines": { "node": ">=8.0.0" @@ -3602,13 +3602,13 @@ "version": "5.22.0", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz", "integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==", - "dev": true + "devOptional": true }, "node_modules/@prisma/engines": { "version": "5.22.0", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz", "integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "dependencies": { "@prisma/debug": "5.22.0", @@ -3621,13 +3621,13 @@ "version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz", "integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==", - "dev": true + "devOptional": true }, "node_modules/@prisma/fetch-engine": { "version": "5.22.0", "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz", "integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==", - "dev": true, + "devOptional": true, "dependencies": { "@prisma/debug": "5.22.0", "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", @@ -3638,7 +3638,7 @@ "version": "5.22.0", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz", "integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==", - "dev": true, + "devOptional": true, "dependencies": { "@prisma/debug": "5.22.0" } @@ -6636,6 +6636,7 @@ "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, @@ -8469,6 +8470,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openapi-types": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", + "license": "MIT", + "peer": true + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -8962,7 +8970,7 @@ "version": "5.22.0", "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz", "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "dependencies": { "@prisma/engines": "5.22.0" diff --git a/backend/prisma/dev.db b/backend/prisma/dev.db index 05fd07b38b4921c1da182dea47abffa161aa533d..4816f30799b05807212a9cc4eb74f4fcca47805e 100644 GIT binary patch delta 18714 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 Lg|jOw)u#UiuJkqU diff --git a/backend/schema-snapshots/get-_health.json b/backend/schema-snapshots/get-_health.json index 31d8f6b4c..05101c579 100644 --- a/backend/schema-snapshots/get-_health.json +++ b/backend/schema-snapshots/get-_health.json @@ -81,6 +81,15 @@ "degraded", "unknown" ] + }, + "indexer": { + "type": "string", + "enum": [ + "up", + "down", + "degraded", + "unknown" + ] } }, "required": [ @@ -90,7 +99,8 @@ "databasePrimary", "databaseReplica", "prisma", - "jobs" + "jobs", + "indexer" ], "additionalProperties": false }, diff --git a/backend/schema-snapshots/get-_ready.json b/backend/schema-snapshots/get-_ready.json index 6e39ca576..cca011448 100644 --- a/backend/schema-snapshots/get-_ready.json +++ b/backend/schema-snapshots/get-_ready.json @@ -21,13 +21,17 @@ }, "prisma": { "type": "boolean" + }, + "indexer": { + "type": "boolean" } }, "required": [ "cache", "stellarRpc", "database", - "prisma" + "prisma", + "indexer" ], "additionalProperties": false } diff --git a/backend/src/__tests__/vaultApy.test.ts b/backend/src/__tests__/vaultApy.test.ts new file mode 100644 index 000000000..b00e776e1 --- /dev/null +++ b/backend/src/__tests__/vaultApy.test.ts @@ -0,0 +1,108 @@ +/** + * Tests for the APY calculation service (Issue #1456). + * + * Verifies that a vault with zero shares (or insufficient price history) + * returns apy: null / apyStatus: 'insufficient_data' instead of Infinity. + * + * Integration tests for GET /api/v1/vaults/:id/apy are in vaultApyEndpoint.test.ts. + */ + +import { computeVaultApy } from '../services/apy'; + +// ─── Mocks ─────────────────────────────────────────────────────────────────── + +jest.mock('../prismaClient', () => ({ + getPrismaClient: () => mockPrisma, +})); + +const mockVaultState = { + findUnique: jest.fn(), +}; +const mockSharePriceSnapshot = { + findMany: jest.fn(), +}; +const mockPrisma = { + vaultState: mockVaultState, + sharePriceSnapshot: mockSharePriceSnapshot, +}; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +// ─── Unit: computeVaultApy ──────────────────────────────────────────────────── + +describe('computeVaultApy()', () => { + it('returns null + insufficient_data when totalShares is 0', async () => { + mockVaultState.findUnique.mockResolvedValue({ id: 1, totalShares: '0', totalAssets: '0' }); + + const result = await computeVaultApy(1); + + expect(result.apy).toBeNull(); + expect(result.apyStatus).toBe('insufficient_data'); + // Snapshot never queried — we bail early + expect(mockSharePriceSnapshot.findMany).not.toHaveBeenCalled(); + }); + + it('returns null + insufficient_data when vaultState is missing', async () => { + mockVaultState.findUnique.mockResolvedValue(null); + + const result = await computeVaultApy(1); + + expect(result.apy).toBeNull(); + expect(result.apyStatus).toBe('insufficient_data'); + }); + + it('returns null + insufficient_data when fewer than 2 snapshots exist', async () => { + mockVaultState.findUnique.mockResolvedValue({ id: 1, totalShares: '1000', totalAssets: '1000' }); + mockSharePriceSnapshot.findMany.mockResolvedValue([ + { sharePrice: '1.010000', recordedAt: new Date() }, + ]); + + const result = await computeVaultApy(1); + + expect(result.apy).toBeNull(); + expect(result.apyStatus).toBe('insufficient_data'); + }); + + it('returns null + insufficient_data when priceLast is 0', async () => { + mockVaultState.findUnique.mockResolvedValue({ id: 1, totalShares: '1000', totalAssets: '1000' }); + mockSharePriceSnapshot.findMany.mockResolvedValue([ + { sharePrice: '1.010000', recordedAt: new Date() }, + { sharePrice: '0.000000', recordedAt: new Date(Date.now() - 86400000) }, + ]); + + const result = await computeVaultApy(1); + + expect(result.apy).toBeNull(); + expect(result.apyStatus).toBe('insufficient_data'); + }); + + it('returns a finite apy and ok status with 2 valid snapshots', async () => { + mockVaultState.findUnique.mockResolvedValue({ id: 1, totalShares: '1000', totalAssets: '1050' }); + mockSharePriceSnapshot.findMany.mockResolvedValue([ + { sharePrice: '1.010000', recordedAt: new Date() }, + { sharePrice: '1.000000', recordedAt: new Date(Date.now() - 86400000) }, + ]); + + const result = await computeVaultApy(1); + + expect(result.apyStatus).toBe('ok'); + expect(typeof result.apy).toBe('number'); + expect(Number.isFinite(result.apy)).toBe(true); + // (1.01 - 1.00) / 1.00 * 365 = 3.65 + expect(result.apy).toBeCloseTo(3.65, 5); + }); + + it('result is JSON-serialisable (no Infinity or NaN) for zero-shares vault', async () => { + mockVaultState.findUnique.mockResolvedValue({ id: 1, totalShares: '0', totalAssets: '0' }); + + const result = await computeVaultApy(1); + + // JSON.stringify(Infinity) => 'null', which loses information silently. + // Ensure the value is intentionally null so stringify is lossless. + const serialised = JSON.stringify(result); + const parsed = JSON.parse(serialised); + expect(parsed.apy).toBeNull(); + }); +}); diff --git a/backend/src/index.ts b/backend/src/index.ts index b6361df66..eb6d97a48 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -135,6 +135,7 @@ import { getEventPollingHealth, startEventPollingService, stopEventPollingServic import { eventOutboxService } from './eventOutbox'; import { prisma, getPrismaRuntimeConfig } from './prisma'; import { getPrismaClient } from './prismaClient'; +import { computeVaultApy } from './services/apy'; import { verifyWebhookEndpoint, registerWebhookEndpoint, @@ -1145,6 +1146,39 @@ app.get('/api/v2/vaults/:id/health', (req: Request, res: Response) => { res.redirect(307, `/api/v1/vaults/${encodeURIComponent(req.params.id)}/health${qs}`); }); +/** + * GET /api/v1/vaults/:id/apy + * + * Returns the annualised APY for the vault. Returns `apy: null` with + * `apyStatus: 'insufficient_data'` for new vaults that have zero shares or + * fewer than 2 price snapshots, preventing Infinity / NaN from reaching the + * frontend (Issue #1456). + */ +app.get( + '/api/v1/vaults/:id/apy', + readsLimiter, + cacheMiddleware({ ttl: cacheVaultMetricsTtl }), + createTimeoutFor.read({ + timeoutMs: 1500, + routeName: '/api/v1/vaults/:id/apy', + message: 'Vault APY took too long to load', + fallbackResponse: () => ({ + error: 'Service Unavailable', + status: 503, + code: 'VAULT_APY_TIMEOUT', + message: 'Vault APY is temporarily unavailable. Please try again shortly.', + timestamp: new Date().toISOString(), + }), + }), + async (_req: Request, res: Response) => { + const result = await computeVaultApy(); + res.json({ + ...result, + timestamp: new Date().toISOString(), + }); + }, +); + /** * @openapi * /vault/summary: diff --git a/backend/src/services/apy.ts b/backend/src/services/apy.ts new file mode 100644 index 000000000..14bd6686a --- /dev/null +++ b/backend/src/services/apy.ts @@ -0,0 +1,68 @@ +/** + * @file services/apy.ts + * Per-vault APY calculation service (Issue #1456). + * + * APY is derived from the two most-recent SharePriceSnapshot rows: + * apy = (priceNow - priceLast) / priceLast * 365 + * + * Guard-rails: + * - Returns { apy: null, apyStatus: 'insufficient_data' } when + * totalShares === 0 (new vault) or fewer than 2 price snapshots exist. + * - Returns { apy: null, apyStatus: 'insufficient_data' } when priceLast + * resolves to 0 to prevent a divide-by-zero / Infinity result. + */ + +import { Decimal } from 'decimal.js'; +import { getPrismaClient } from '../prismaClient'; + +export type ApyStatus = 'ok' | 'insufficient_data'; + +export interface VaultApyResult { + apy: number | null; + apyStatus: ApyStatus; +} + +/** + * Compute the annualised APY for a vault identified by its numeric state id. + * + * @param vaultStateId - The VaultState.id value (almost always 1 in the current + * single-vault design; exposed as a param for future + * multi-vault support). + */ +export async function computeVaultApy(vaultStateId: number = 1): Promise { + const prisma = getPrismaClient(); + + // Check whether the vault has been bootstrapped yet. + const vaultState = await prisma.vaultState.findUnique({ where: { id: vaultStateId } }); + + const totalShares = vaultState ? new Decimal(vaultState.totalShares) : new Decimal(0); + if (totalShares.isZero()) { + return { apy: null, apyStatus: 'insufficient_data' }; + } + + // Fetch the two most-recent price snapshots (newest first). + const snapshots = await prisma.sharePriceSnapshot.findMany({ + orderBy: { recordedAt: 'desc' }, + take: 2, + select: { sharePrice: true, recordedAt: true }, + }); + + if (snapshots.length < 2) { + return { apy: null, apyStatus: 'insufficient_data' }; + } + + const priceNow = new Decimal(snapshots[0].sharePrice); + const priceLast = new Decimal(snapshots[1].sharePrice); + + // Guard against a zero baseline price (can happen if a snapshot was persisted + // during an empty-vault state before this fix landed). + if (priceLast.isZero()) { + return { apy: null, apyStatus: 'insufficient_data' }; + } + + // Annualise: daily return × 365. The two snapshots are assumed to be ~1 day + // apart; a more precise implementation would weight by actual elapsed days. + const apy = priceNow.minus(priceLast).div(priceLast).times(365).toNumber(); + + return { apy, apyStatus: 'ok' }; +} diff --git a/package.json b/package.json index 6fe549408..8afcd9d43 100644 --- a/package.json +++ b/package.json @@ -1,4 +1,9 @@ { + "packageManager": "pnpm@9.12.0", + "engines": { + "node": ">=20", + "pnpm": ">=9.12" + }, "devDependencies": { "husky": "^9.1.7", "jsdom": "^29.1.1", @@ -6,6 +11,7 @@ "vitest": "^4.1.5" }, "scripts": { + "check:pnpm-version": "node scripts/check-pnpm-version.js", "test": "vitest run", "validate:frontend-env": "tsx scripts/validate-frontend-env.ts", "test:validate-frontend-env": "vitest run --config scripts/vitest.config.ts", diff --git a/scripts/check-pnpm-version.js b/scripts/check-pnpm-version.js new file mode 100644 index 000000000..31724e3fa --- /dev/null +++ b/scripts/check-pnpm-version.js @@ -0,0 +1,83 @@ +#!/usr/bin/env node +/** + * Checks that the active pnpm version satisfies the minimum required by this + * monorepo (engines.pnpm in the root package.json). + * + * Exits 0 on success, 1 with a clear human-readable error on failure so CI + * catches the mismatch before contributors hit ERR_PNPM_LOCKFILE_BREAKING_CHANGE. + * + * Usage: + * node scripts/check-pnpm-version.js # run manually + * npm run check:pnpm-version # via package.json script + */ + +'use strict'; + +const { execSync } = require('child_process'); +const { readFileSync } = require('fs'); +const path = require('path'); + +// ── Read required version from package.json ────────────────────────────────── + +const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')); +const required = (pkg.engines && pkg.engines.pnpm) || '>=9.12'; + +// Strip the ">=" prefix to get the minimum semver string. +const minVersion = required.replace(/^>=/, '').trim(); +const parts = minVersion.split('.').map(Number); +const minMajor = parts[0]; +const minMinor = parts[1] !== undefined ? parts[1] : 0; +const minPatch = parts[2] !== undefined ? parts[2] : 0; + +// ── Detect installed pnpm version ──────────────────────────────────────────── + +let installedVersion; +try { + installedVersion = execSync('pnpm --version', { encoding: 'utf8', timeout: 5000 }).trim(); +} catch (_err) { + console.error( + '\n\u2716 pnpm not found.\n' + + ' Install it via Corepack (recommended):\n' + + '\n' + + ' corepack enable\n' + + ' corepack prepare pnpm@' + minVersion + ' --activate\n' + + '\n' + + ' Or via npm:\n' + + ' npm install -g pnpm@' + minVersion + '\n', + ); + process.exit(1); +} + +// ── Compare versions ───────────────────────────────────────────────────────── + +const vParts = installedVersion.split('.').map(Number); +const major = vParts[0]; +const minor = vParts[1] !== undefined ? vParts[1] : 0; +const patch = vParts[2] !== undefined ? vParts[2] : 0; + +const satisfies = + major > minMajor || + (major === minMajor && minor > minMinor) || + (major === minMajor && minor === minMinor && patch >= minPatch); + +if (!satisfies) { + console.error( + '\n\u2716 pnpm version mismatch.\n' + + '\n' + + ' Required : ' + required + ' (lockfile format v9)\n' + + ' Installed: ' + installedVersion + '\n' + + '\n' + + ' Running pnpm ' + installedVersion + ' against a pnpm 9 lockfile will produce\n' + + ' ERR_PNPM_LOCKFILE_BREAKING_CHANGE and a corrupted install.\n' + + '\n' + + ' Fix (Corepack \u2013 recommended):\n' + + ' corepack enable\n' + + ' corepack prepare pnpm@' + minVersion + ' --activate\n' + + '\n' + + ' Fix (npm global install):\n' + + ' npm install -g pnpm@' + minVersion + '\n', + ); + process.exit(1); +} + +console.log('\u2714 pnpm ' + installedVersion + ' satisfies ' + required);