diff --git a/apps/web/src/routes/api/auth.$.ts b/apps/web/src/routes/api/auth.$.ts index 84ff5d507..acd1fc34e 100644 --- a/apps/web/src/routes/api/auth.$.ts +++ b/apps/web/src/routes/api/auth.$.ts @@ -1,7 +1,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { auth } from "@reactive-resume/auth/config"; import { env } from "@reactive-resume/env/server"; -import { isAllowedOAuthRedirectUri, parseAllowedHostList } from "@reactive-resume/utils/url-security"; +import { isAllowedOAuthRedirectUri, parseAllowedHostList } from "@reactive-resume/utils/url-security.node"; const oauthAuthorizeSanitizedParams = [ "prompt", diff --git a/packages/api/src/helpers/local-data-directory.ts b/packages/api/src/helpers/local-data-directory.ts deleted file mode 100644 index 871cd7619..000000000 --- a/packages/api/src/helpers/local-data-directory.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { basename, dirname, join } from "node:path"; - -export function getLocalDataDirectory(): string { - const cwd = process.cwd(); - const parentDirectory = dirname(cwd); - - if (basename(cwd) === "web" && basename(parentDirectory) === "apps") { - return join(dirname(parentDirectory), "data"); - } - - return join(cwd, "data"); -} diff --git a/packages/api/src/services/ai.ts b/packages/api/src/services/ai.ts index b5ca9704d..962795dcf 100644 --- a/packages/api/src/services/ai.ts +++ b/packages/api/src/services/ai.ts @@ -28,7 +28,7 @@ import { } from "@reactive-resume/ai/tools/patch-resume"; import { AI_PROVIDER_DEFAULT_BASE_URLS, aiProviderSchema } from "@reactive-resume/ai/types"; import { resumeAnalysisOutputSchema, resumeAnalysisSchema } from "@reactive-resume/schema/resume/analysis"; -import { isPrivateOrLoopbackHost, parseUrl } from "@reactive-resume/utils/url-security"; +import { isPrivateOrLoopbackHost, parseUrl } from "@reactive-resume/utils/url-security.node"; const aiExtractionTemplate = buildAiExtractionTemplate(); diff --git a/packages/api/src/services/statistics.ts b/packages/api/src/services/statistics.ts index 663079126..e4dfed23d 100644 --- a/packages/api/src/services/statistics.ts +++ b/packages/api/src/services/statistics.ts @@ -3,7 +3,7 @@ import { dirname, join } from "node:path"; import { count } from "drizzle-orm"; import { db } from "@reactive-resume/db/client"; import * as schema from "@reactive-resume/db/schema"; -import { getLocalDataDirectory } from "../helpers/local-data-directory"; +import { getLocalDataDirectory } from "@reactive-resume/utils/monorepo.node"; const CACHE_DURATION_MS = 6 * 60 * 60 * 1000; // 6 hours const GITHUB_API_URL = "https://api.github.com/repos/amruthpillai/reactive-resume"; diff --git a/packages/api/src/services/storage.ts b/packages/api/src/services/storage.ts index 7cc358fc9..d36c9ee6c 100644 --- a/packages/api/src/services/storage.ts +++ b/packages/api/src/services/storage.ts @@ -9,7 +9,7 @@ import { } from "@aws-sdk/client-s3"; import sharp from "sharp"; import { env } from "@reactive-resume/env/server"; -import { getLocalDataDirectory } from "../helpers/local-data-directory"; +import { getLocalDataDirectory } from "@reactive-resume/utils/monorepo.node"; interface StorageWriteInput { key: string; diff --git a/packages/auth/src/config.ts b/packages/auth/src/config.ts index 98b3c270e..2132a33ee 100644 --- a/packages/auth/src/config.ts +++ b/packages/auth/src/config.ts @@ -24,7 +24,7 @@ import { sendEmail } from "@reactive-resume/email/transport"; import { env } from "@reactive-resume/env/server"; import { rateLimitConfig, TRUSTED_IP_HEADERS } from "@reactive-resume/utils/rate-limit"; import { generateId, toUsername } from "@reactive-resume/utils/string"; -import { isAllowedOAuthRedirectUri, parseAllowedHostList } from "@reactive-resume/utils/url-security"; +import { isAllowedOAuthRedirectUri, parseAllowedHostList } from "@reactive-resume/utils/url-security.node"; const authBaseUrl = env.APP_URL; const isRateLimitEnabled = process.env.NODE_ENV === "production"; diff --git a/packages/env/package.json b/packages/env/package.json index 29e55614e..0895ad40c 100644 --- a/packages/env/package.json +++ b/packages/env/package.json @@ -14,7 +14,9 @@ "test:agent": "vitest run --reporter=agent --reporter=json --outputFile.json=reports/vitest-results.json --passWithNoTests" }, "dependencies": { + "@reactive-resume/utils": "workspace:*", "@t3-oss/env-core": "^0.13.11", + "dotenv": "^17.4.2", "zod": "^4.4.3" }, "devDependencies": { diff --git a/packages/env/src/server.ts b/packages/env/src/server.ts index b6ba62497..a45397426 100644 --- a/packages/env/src/server.ts +++ b/packages/env/src/server.ts @@ -1,5 +1,14 @@ +import { join } from "node:path"; import { createEnv } from "@t3-oss/env-core"; +import { config } from "dotenv"; import { z } from "zod"; +import { findWorkspaceRoot } from "@reactive-resume/utils/monorepo.node"; + +const workspaceRoot = findWorkspaceRoot(); + +if (workspaceRoot) { + config({ path: join(workspaceRoot, ".env"), quiet: true }); +} export const env = createEnv({ server: { diff --git a/packages/utils/package.json b/packages/utils/package.json index e72805646..f13bdf925 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -11,6 +11,7 @@ "./html": "./src/html.ts", "./level": "./src/level.ts", "./locale": "./src/locale.ts", + "./monorepo.node": "./src/monorepo.node.ts", "./network-icons": "./src/network-icons.ts", "./rate-limit": "./src/rate-limit.ts", "./resume/docx": "./src/resume/docx/index.ts", @@ -19,7 +20,7 @@ "./string": "./src/string.ts", "./style": "./src/style.ts", "./url": "./src/url.ts", - "./url-security": "./src/url-security.ts" + "./url-security.node": "./src/url-security.node.ts" }, "scripts": { "typecheck": "tsgo --noEmit", @@ -36,6 +37,7 @@ "docx": "^9.6.1", "dompurify": "^3.4.2", "fast-json-patch": "^3.1.1", + "find-up": "^8.0.0", "tailwind-merge": "^3.5.0", "unique-names-generator": "^4.7.1", "uuid": "^14.0.0", diff --git a/packages/utils/src/monorepo.node.test.ts b/packages/utils/src/monorepo.node.test.ts new file mode 100644 index 000000000..501691917 --- /dev/null +++ b/packages/utils/src/monorepo.node.test.ts @@ -0,0 +1,47 @@ +import { realpathSync } from "node:fs"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, expect, test } from "vitest"; +import { findWorkspaceRoot, getLocalDataDirectory } from "./monorepo.node"; + +let workspaceRoot: string; + +beforeEach(async () => { + workspaceRoot = await mkdtemp(join(tmpdir(), "reactive-resume-utils-")); + await mkdir(join(workspaceRoot, "apps", "web", ".output", "server"), { recursive: true }); +}); + +afterEach(async () => { + await rm(workspaceRoot, { recursive: true, force: true }); +}); + +test("finds the pnpm workspace root from a nested directory", async () => { + await writeFile(join(workspaceRoot, "pnpm-workspace.yaml"), "packages:\n - apps/*\n - packages/*\n"); + + const root = findWorkspaceRoot(join(workspaceRoot, "apps", "web", ".output", "server")); + + expect(root).toBe(realpathSync(workspaceRoot)); +}); + +test("rejects a pnpm workspace manifest with a .yml extension", async () => { + await writeFile(join(workspaceRoot, "pnpm-workspace.yml"), "packages:\n - apps/*\n - packages/*\n"); + + expect(() => findWorkspaceRoot(join(workspaceRoot, "apps", "web", ".output", "server"))).toThrow( + /The workspace manifest file should be named "pnpm-workspace\.yaml"\. File found: .*pnpm-workspace\.yml/, + ); +}); + +test("returns the workspace data directory from a nested directory", async () => { + await writeFile(join(workspaceRoot, "pnpm-workspace.yaml"), "packages:\n - apps/*\n - packages/*\n"); + + const dataDirectory = getLocalDataDirectory(join(workspaceRoot, "apps", "web", ".output", "server")); + + expect(dataDirectory).toBe(join(realpathSync(workspaceRoot), "data")); +}); + +test("falls back to the cwd data directory outside a workspace", () => { + const dataDirectory = getLocalDataDirectory(workspaceRoot); + + expect(dataDirectory).toBe(join(realpathSync(workspaceRoot), "data")); +}); diff --git a/packages/utils/src/monorepo.node.ts b/packages/utils/src/monorepo.node.ts new file mode 100644 index 000000000..d0d38696a --- /dev/null +++ b/packages/utils/src/monorepo.node.ts @@ -0,0 +1,15 @@ +import { realpathSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { findUpSync } from "find-up"; + +export const findWorkspaceRoot = (cwd = process.cwd()) => { + const workspaceManifestPath = findUpSync("pnpm-workspace.yaml", { cwd: realpathSync(cwd) }); + + return workspaceManifestPath ? dirname(workspaceManifestPath) : null; +}; + +export const getLocalDataDirectory = (cwd = process.cwd()) => { + const workspaceRoot = findWorkspaceRoot(cwd); + + return join(workspaceRoot ?? realpathSync(cwd), "data"); +}; diff --git a/packages/utils/src/url-security.ts b/packages/utils/src/url-security.node.ts similarity index 100% rename from packages/utils/src/url-security.ts rename to packages/utils/src/url-security.node.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b88cf0f0a..6dce77645 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -601,9 +601,15 @@ importers: packages/env: dependencies: + '@reactive-resume/utils': + specifier: workspace:* + version: link:../utils '@t3-oss/env-core': specifier: ^0.13.11 version: 0.13.11(typescript@6.0.3)(zod@4.4.3) + dotenv: + specifier: ^17.4.2 + version: 17.4.2 zod: specifier: ^4.4.3 version: 4.4.3 @@ -840,6 +846,9 @@ importers: fast-json-patch: specifier: ^3.1.1 version: 3.1.1 + find-up: + specifier: ^8.0.0 + version: 8.0.0 tailwind-merge: specifier: ^3.5.0 version: 3.5.0 @@ -6260,6 +6269,10 @@ packages: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} + find-up@8.0.0: + resolution: {integrity: sha512-JGG8pvDi2C+JxidYdIwQDyS/CgcrIdh18cvgxcBge3wSHRQOrooMD3GlFBcmMJAN9M42SAZjDp5zv1dglJjwww==} + engines: {node: '>=20'} + fontkit@2.0.4: resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==} @@ -7075,6 +7088,10 @@ packages: linkifyjs@4.3.2: resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==} + locate-path@8.0.0: + resolution: {integrity: sha512-XT9ewWAC43tiAV7xDAPflMkG0qOPn2QjHqlgX8FOqmWa/rxnyYDulF9T0F7tRy1u+TVTmK/M//6VIOye+2zDXg==} + engines: {node: '>=20'} + lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} @@ -7472,6 +7489,14 @@ packages: oxc-resolver@11.19.1: resolution: {integrity: sha512-qE/CIg/spwrTBFt5aKmwe3ifeDdLfA2NESN30E42X/lII5ClF8V7Wt6WIJhcGZjp0/Q+nQ+9vgxGk//xZNX2hg==} + p-limit@4.0.0: + resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + p-locate@6.0.0: + resolution: {integrity: sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -9006,6 +9031,10 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} + engines: {node: '>=12.20'} + yocto-spinner@1.2.0: resolution: {integrity: sha512-Yw0hUB6UA3o4YUgKy3oSe9a4cxoaZ9sBfYDw+JSxo6Id0KoJGoxzPA24qqUXYKBWABs/zDSGTz9kww7t3F0XGw==} engines: {node: '>=18.19'} @@ -14665,6 +14694,11 @@ snapshots: transitivePeerDependencies: - supports-color + find-up@8.0.0: + dependencies: + locate-path: 8.0.0 + unicorn-magic: 0.3.0 + fontkit@2.0.4: dependencies: '@swc/helpers': 0.5.21 @@ -15409,6 +15443,10 @@ snapshots: linkifyjs@4.3.2: {} + locate-path@8.0.0: + dependencies: + p-locate: 6.0.0 + lodash.debounce@4.0.8: {} lodash.sortby@4.7.0: {} @@ -15844,6 +15882,14 @@ snapshots: - '@emnapi/core' - '@emnapi/runtime' + p-limit@4.0.0: + dependencies: + yocto-queue: 1.2.2 + + p-locate@6.0.0: + dependencies: + p-limit: 4.0.0 + package-json-from-dist@1.0.1: {} pako@0.2.9: {} @@ -17495,6 +17541,8 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 + yocto-queue@1.2.2: {} + yocto-spinner@1.2.0: dependencies: yoctocolors: 2.1.2