diff --git a/api/src/services/auth.service.ts b/api/src/services/auth.service.ts index da394d6b..0e4503d2 100644 --- a/api/src/services/auth.service.ts +++ b/api/src/services/auth.service.ts @@ -13,10 +13,10 @@ import { } from "../utils/custom-errors.utils.js"; import AuthenticationModel from "../models/authentication.js"; import logger from "../utils/logger.js"; -import path from "path"; import fs from "fs"; import axios from "axios"; import { getAppOrganization, getAppOrganizationUID } from "../utils/auth.utils.js"; +import { getAppJsonPath } from "../utils/app-config-path.utils.js"; import { decryptAppConfig } from "../utils/crypto.utils.js"; import { normalizeContentstackAuthorizeUrl } from "../utils/contentstack-oauth-url.utils.js"; @@ -232,7 +232,7 @@ const requestSms = async (req: Request): Promise => { }; const getAppConfig = () => { - const configPath = path.resolve(process.cwd(), '..', 'app.json'); + const configPath = getAppJsonPath(); if (!fs.existsSync(configPath)) { throw new InternalServerError("SSO is not configured. Please run the setup script first."); } @@ -253,7 +253,6 @@ const saveOAuthToken = async (req: Request): Promise => { } try { - // Exchange the code for access token const appConfig = getAppConfig(); const { client_id, client_secret, redirect_uri } = appConfig?.oauthData; const { code_verifier } = appConfig?.pkce; @@ -271,44 +270,32 @@ const saveOAuthToken = async (req: Request): Promise => { formData.append('redirect_uri', redirect_uri); formData.append('code', code as string); formData.append('code_verifier', code_verifier); + const tokenResponse = await https({ - method: "POST", - url: tokenUrl, - data: formData, - headers: { 'Content-Type': 'application/x-www-form-urlencoded' } + method: "POST", + url: tokenUrl, + data: formData, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }); - const { access_token, refresh_token, organization_uid } = tokenResponse.data; + const { access_token, refresh_token, organization_uid } = tokenResponse?.data; - const expectedOrgUid = getAppOrganizationUID(); if (!organization_uid) { throw new BadRequestError( "No organization was linked to this authorization. When you install or authorize the app in Contentstack, choose the organization that matches your Migration Tool SSO setup, then try again." ); } - if (organization_uid !== expectedOrgUid) { - let orgLabel = expectedOrgUid; - try { - orgLabel = getAppOrganization().name; - } catch { - /* keep UID if app.json incomplete */ - } - throw new BadRequestError( - `Organization mismatch: authorize this app in Contentstack for "${orgLabel}" (the organization from your SSO setup). You signed in under a different organization—select the correct one and try SSO again.` - ); - } + // ── Fetch user FIRST so we have csUser.uid before the org check ────────── const apiHost = regionalApiHosts[region as keyof typeof regionalApiHosts]; const [userErr, userRes] = await safePromise( https({ method: "GET", url: `https://${apiHost}/v3/user`, - headers: { - 'authorization': `Bearer ${access_token}`, - }, + headers: { 'authorization': `Bearer ${access_token}` }, }) ); - + if (userErr) { logger.error("Error fetching user details with new token", userErr?.response?.data); throw new InternalServerError(userErr); @@ -316,23 +303,71 @@ const saveOAuthToken = async (req: Request): Promise => { const csUser = userRes?.data?.user; + // ── Org mismatch check — write failure to DB before throwing ───────────── + const expectedOrgUid = getAppOrganizationUID(); + if (organization_uid !== expectedOrgUid) { + let orgLabel = expectedOrgUid; + try { + orgLabel = getAppOrganization().name; + } catch { + /* keep UID if app.json incomplete */ + } + + // Write terminal failure so the poller can stop immediately + try { + await AuthenticationModel.read(); + const userIndex = AuthenticationModel.chain + .get("users") + .findIndex({ user_id: csUser?.uid }) + .value(); + + AuthenticationModel.update((data: any) => { + const failureRecord = { + user_id: csUser?.uid, + email: csUser?.email, + region: region as string, + sso_failed: true, + sso_error: `Organization mismatch: authorize this app in Contentstack for "${orgLabel}" (the organization from your SSO setup). You signed in under a different organization—select the correct one and try SSO again.`, + updated_at: new Date().toISOString(), + }; + if (userIndex >= 0) { + data.users[userIndex] = { ...data.users[userIndex], ...failureRecord }; + } else { + data.users.push({ ...failureRecord, created_at: new Date().toISOString() }); + } + }); + } catch (dbErr) { + logger.error('Failed to write SSO org-mismatch failure to DB', dbErr); + } + + throw new BadRequestError( + `Organization mismatch: authorize this app in Contentstack for "${orgLabel}" (the organization from your SSO setup). You signed in under a different organization—select the correct one and try SSO again.` + ); + } + // ───────────────────────────────────────────────────────────────────────── + const appTokenPayload = { region: region as string, - user_id: csUser?.uid, + user_id: csUser?.uid, is_sso: true, }; - + const appToken = generateToken(appTokenPayload); await AuthenticationModel.read(); - const userIndex = AuthenticationModel.chain.get("users").findIndex({ user_id: csUser?.uid }).value(); + const userIndex = AuthenticationModel.chain + .get("users") + .findIndex({ user_id: csUser?.uid }) + .value(); AuthenticationModel.update((data: any) => { const userRecord = { ...appTokenPayload, email: csUser?.email, - access_token: access_token, + access_token: access_token, refresh_token: refresh_token, organization_uid: organization_uid, + sso_failed: false, // clear any previous failure + sso_error: null, updated_at: new Date().toISOString(), }; if (userIndex < 0) { @@ -344,17 +379,12 @@ const saveOAuthToken = async (req: Request): Promise => { logger.info(`Token and user data for ${csUser.email} (Region: ${region}) saved successfully.`); return { - data: { - message: HTTP_TEXTS.SUCCESS_LOGIN, - app_token: appToken, - }, + data: { message: HTTP_TEXTS.SUCCESS_LOGIN, app_token: appToken }, status: HTTP_CODES.OK, - } + }; } catch (error) { - if (error instanceof AppError) { - throw error; - } + if (error instanceof AppError) throw error; logger.error("An error occurred during token exchange and save:", error); throw new InternalServerError("Failed to process OAuth callback."); } @@ -379,7 +409,7 @@ export const refreshOAuthToken = async (userId: string): Promise => { throw new Error(`No refresh token available for user: ${userId}`); } - const appConfigPath = path.join(process.cwd(), "..", 'app.json'); + const appConfigPath = getAppJsonPath(); if (!fs.existsSync(appConfigPath)) { throw new Error('app.json file not found - OAuth configuration required'); } @@ -440,8 +470,8 @@ export const refreshOAuthToken = async (userId: string): Promise => { */ export const getAppData = async () => { try { - const appConfigPath = path.join(process.cwd(), '..','app.json'); - + const appConfigPath = getAppJsonPath(); + if (!fs.existsSync(appConfigPath)) { throw new Error('app.json file not found - SSO configuration required'); } @@ -485,9 +515,28 @@ export const checkSSOAuthStatus = async (userId: string) => { .find({ user_id: userId }) .value(); - if (!userRecord || !userRecord?.access_token) { + if (!userRecord) { return { authenticated: false, + terminal: false, + message: 'SSO authentication not completed' + }; + } + + // ── Check for terminal failure written by the OAuth callback ───────────── + if (userRecord?.sso_failed) { + return { + authenticated: false, + terminal: true, + message: userRecord.sso_error ?? 'Organization mismatch: please select the correct organization and try SSO again.', + }; + } + // ───────────────────────────────────────────────────────────────────────── + + if (!userRecord?.access_token) { + return { + authenticated: false, + terminal: false, message: 'SSO authentication not completed' }; } @@ -495,15 +544,14 @@ export const checkSSOAuthStatus = async (userId: string) => { if (!userRecord?.organization_uid) { return { authenticated: false, + terminal: false, message: 'Organization not linked to user' }; } const appOrgUID = getAppOrganizationUID(); - if (userRecord.organization_uid !== appOrgUID) { - let detail = - 'Organization mismatch: the authorized org does not match the Migration Tool SSO configuration.'; + let detail = 'Organization mismatch: the authorized org does not match the Migration Tool SSO configuration.'; try { const { name } = getAppOrganization(); detail = `Organization mismatch: authorize "${name}" in Contentstack (same org as SSO setup), then try again.`; @@ -512,16 +560,16 @@ export const checkSSOAuthStatus = async (userId: string) => { } return { authenticated: false, + terminal: true, // ← terminal, not just pending message: detail, }; } - const tokenAge = - Date.now() - new Date(userRecord.updated_at).getTime(); - + const tokenAge = Date.now() - new Date(userRecord.updated_at).getTime(); if (tokenAge > 10 * 60 * 1000) { return { authenticated: false, + terminal: true, // ← terminal, not just pending message: 'SSO authentication expired' }; } @@ -534,6 +582,7 @@ export const checkSSOAuthStatus = async (userId: string) => { return { authenticated: true, + terminal: false, message: 'SSO authentication successful', app_token: appToken, user: { @@ -546,9 +595,7 @@ export const checkSSOAuthStatus = async (userId: string) => { } catch (error: any) { logger.error('SSO status check failed', error); - throw new Error( - `Failed to check SSO authentication status: ${error?.message}` - ); + throw new Error(`Failed to check SSO authentication status: ${error?.message}`); } }; diff --git a/api/src/services/user.service.ts b/api/src/services/user.service.ts index a477b3c4..bc49d4a5 100644 --- a/api/src/services/user.service.ts +++ b/api/src/services/user.service.ts @@ -36,9 +36,9 @@ const getUserProfile = async (req: Request): Promise => { .value(); if (userIndex < 0) throw new BadRequestError(HTTP_TEXTS.NO_CS_USER); - const { uid: org_uid, name: org_name } = getAppOrganization(); const userRecord = AuthenticationModel.data?.users?.[userIndex]; if (appTokenPayload?.is_sso === true) { + const { uid: org_uid, name: org_name } = getAppOrganization(); if (!userRecord?.access_token) { throw new BadRequestError("SSO authentication not completed"); } diff --git a/api/src/utils/app-config-path.utils.ts b/api/src/utils/app-config-path.utils.ts new file mode 100644 index 00000000..2136acd9 --- /dev/null +++ b/api/src/utils/app-config-path.utils.ts @@ -0,0 +1,18 @@ +import fs from "fs"; +import path from "path"; + +/** + * Resolves the Contentstack app manifest path (decrypted app.json). + * Order: APP_CONFIG_PATH env → ./app.json next to cwd (Docker mount) → ../app.json (local monorepo). + */ +export function getAppJsonPath(): string { + const fromEnv = process.env.APP_CONFIG_PATH?.trim(); + if (fromEnv) { + return fromEnv; + } + const nextToCwd = path.join(process.cwd(), "app.json"); + if (fs.existsSync(nextToCwd)) { + return nextToCwd; + } + return path.join(process.cwd(), "..", "app.json"); +} diff --git a/api/src/utils/auth.utils.ts b/api/src/utils/auth.utils.ts index 8fd269fc..3817b2c8 100644 --- a/api/src/utils/auth.utils.ts +++ b/api/src/utils/auth.utils.ts @@ -1,11 +1,11 @@ import fs from "fs"; -import path from "path"; import AuthenticationModel from "../models/authentication.js"; import { UnauthorizedError } from "../utils/custom-errors.utils.js"; import { decryptAppConfig } from "./crypto.utils.js"; +import { getAppJsonPath } from "./app-config-path.utils.js"; function loadAppConfig() { - const configPath = path.join(process.cwd(), "..", "app.json"); + const configPath = getAppJsonPath(); if (!fs.existsSync(configPath)) { throw new Error("app.json file not found"); } diff --git a/api/tests/unit/services/user.service.test.ts b/api/tests/unit/services/user.service.test.ts index 21a49dbf..e9aa921b 100644 --- a/api/tests/unit/services/user.service.test.ts +++ b/api/tests/unit/services/user.service.test.ts @@ -81,6 +81,7 @@ describe('user.service', () => { expect(result.status).toBe(200); expect(result.data.user.email).toBe('test@example.com'); expect(result.data.user.orgs).toHaveLength(2); + expect(getAppOrganization).not.toHaveBeenCalled(); }); it('should throw when user not found in AuthenticationModel', async () => { @@ -205,14 +206,18 @@ describe('user.service', () => { ).rejects.toMatchObject({ message: 'Organization access revoked' }); }); - it('should wrap unexpected errors in ExceptionFunction', async () => { + it('should wrap unexpected errors in ExceptionFunction (SSO path reads app org)', async () => { mockChainValue.mockReturnValue(0); vi.mocked(getAppOrganization).mockImplementationOnce(() => { throw new Error('unexpected'); }); await expect( - userService.getUserProfile(createReq() as any) + userService.getUserProfile({ + body: { + token_payload: { region: 'NA', user_id: 'user-123', is_sso: true }, + }, + } as any) ).rejects.toMatchObject({ message: 'unexpected' }); }); }); diff --git a/docker-compose.yml b/docker-compose.yml index 1404f670..a18b0090 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,7 @@ services: volumes: - ${DOCKER_MOUNT_PATH:-${CMS_DATA_PATH}}:${CONTAINER_PATH}:ro - shared_data:/app/extracted_files + - ./app.json:/usr/src/app/app.json:ro environment: - NODE_ENV=${NODE_ENV:-production} - CMS_TYPE=${CMS_TYPE} diff --git a/setup-docker.sh b/setup-docker.sh index 3a259490..c4aa8d56 100755 --- a/setup-docker.sh +++ b/setup-docker.sh @@ -55,7 +55,8 @@ if [[ "$CMS_TYPE" == "drupal" ]]; then echo "Drupal uses a MySQL database connection. Please provide your database details:" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - read -rp "MySQL Host (e.g. host.docker.internal for host DB): " MYSQL_HOST + read -rp "MySQL Host [host.docker.internal] (use this when MySQL runs on your Mac/PC, not inside Docker): " MYSQL_HOST + MYSQL_HOST="${MYSQL_HOST:-host.docker.internal}" read -rp "MySQL User: " MYSQL_USER read -rsp "MySQL Password: " MYSQL_PASSWORD echo "" @@ -182,6 +183,13 @@ else fi fi +# Check for app.json (required by API for org + OAuth; mounted into the API container) +if [ ! -f "app.json" ]; then + echo "❌ app.json not found in the repository root." + echo " Run your Contentstack / OAuth setup so app.json exists, then retry Docker." + exit 1 +fi + # Check if docker-compose.yml exists before running if [ ! -f "docker-compose.yml" ]; then echo "❌ docker-compose.yml not found in current directory" diff --git a/ui/package-lock.json b/ui/package-lock.json index 26eda5fa..f014dad4 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -25,6 +25,7 @@ "react-redux": "^9.1.2", "react-router": "^7.0.0", "react-router-dom": "^7.0.0", + "react-toastify": "npm:@contentstack/react-toastify@6.1.5", "redux-persist": "^6.0.0", "sass": "^1.68.0", "socket.io-client": "^4.7.5", @@ -425,6 +426,21 @@ "react-select": "^2.0.0 || ^3.0.0" } }, + "node_modules/@contentstack/venus-components/node_modules/react-select-async-paginate/node_modules/react-dom": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", + "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "scheduler": "^0.20.2" + }, + "peerDependencies": { + "react": "17.0.2" + } + }, "node_modules/@contentstack/venus-components/node_modules/react-select-async-paginate/node_modules/react-is-mounted-hook": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/react-is-mounted-hook/-/react-is-mounted-hook-1.1.2.tgz", @@ -463,6 +479,17 @@ "react-dom": "^15.0.0 || ^16.0.0" } }, + "node_modules/@contentstack/venus-components/node_modules/scheduler": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz", + "integrity": "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + } + }, "node_modules/@contentstack/venus-components/node_modules/slate": { "version": "0.77.2", "resolved": "https://registry.npmjs.org/slate/-/slate-0.77.2.tgz", @@ -3314,6 +3341,15 @@ "node": ">=0.8.0" } }, + "node_modules/babel-plugin-emotion/node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, "node_modules/babel-plugin-macros": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", @@ -3556,6 +3592,15 @@ "node": ">=10" } }, + "node_modules/cosmiconfig/node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -9199,6 +9244,21 @@ } } }, + "node_modules/vite-tsconfig-paths/node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/vitest": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz", @@ -9574,12 +9634,20 @@ } }, "node_modules/yaml": { - "version": "1.10.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", - "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, "engines": { - "node": ">= 6" + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" } }, "node_modules/yocto-queue": { diff --git a/ui/src/components/LegacyCms/Actions/LoadSelectCms.tsx b/ui/src/components/LegacyCms/Actions/LoadSelectCms.tsx index b9bb9bd1..1302e014 100644 --- a/ui/src/components/LegacyCms/Actions/LoadSelectCms.tsx +++ b/ui/src/components/LegacyCms/Actions/LoadSelectCms.tsx @@ -150,7 +150,7 @@ const LoadSelectCms = (props: LoadSelectCmsProps) => { ...newMigrationData?.legacy_cms?.uploadedFile, file_details: { ...existingFileDetails, - mySQLDetails: data?.mysql || existingFileDetails?.mySQLDetails, // Preserve existing if config is empty + mysql: data?.mysql || existingFileDetails?.mysql, assetsConfig: data?.assetsConfig || existingFileDetails?.assetsConfig, cmsType: data?.cmsType || existingFileDetails?.cmsType, localPath: data?.localPath || existingFileDetails?.localPath, diff --git a/upload-api/fs-readdir-recursive.d.ts b/upload-api/fs-readdir-recursive.d.ts new file mode 100644 index 00000000..0127359e --- /dev/null +++ b/upload-api/fs-readdir-recursive.d.ts @@ -0,0 +1,4 @@ +declare module 'fs-readdir-recursive' { + function read(root: string): string[]; + export default read; +} diff --git a/upload-api/migration-aem/fs-readdir-recursive.d.ts b/upload-api/migration-aem/fs-readdir-recursive.d.ts new file mode 100644 index 00000000..0127359e --- /dev/null +++ b/upload-api/migration-aem/fs-readdir-recursive.d.ts @@ -0,0 +1,4 @@ +declare module 'fs-readdir-recursive' { + function read(root: string): string[]; + export default read; +} diff --git a/upload-api/migration-aem/tsconfig.json b/upload-api/migration-aem/tsconfig.json index f1ae4b68..bbe0a756 100644 --- a/upload-api/migration-aem/tsconfig.json +++ b/upload-api/migration-aem/tsconfig.json @@ -109,5 +109,6 @@ /* Completeness */ // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ "skipLibCheck": true /* Skip type checking all .d.ts files. */ - } + }, + "include": ["./**/*.ts", "./**/*.d.ts"] } diff --git a/upload-api/src/config/index.ts b/upload-api/src/config/index.ts index 9722a13c..769f4103 100644 --- a/upload-api/src/config/index.ts +++ b/upload-api/src/config/index.ts @@ -1,3 +1,29 @@ +import fs from 'fs'; + +/** True when this Node process runs inside a Linux container (upload-api Docker image). */ +function runningInDocker(): boolean { + try { + return fs.existsSync('/.dockerenv'); + } catch { + return false; + } +} + +/** + * MySQL host from env. Inside Docker, localhost refer to the container, not the host, + * so we use host.docker.internal to reach MySQL on the machine. + */ +function mysqlHostFromEnv(): string { + const raw = (process.env.MYSQL_HOST || '').trim(); + if (!raw) { + return runningInDocker() ? 'host.docker.internal' : 'host_name'; + } + if (runningInDocker() && (/^localhost$/i.test(raw) || raw === '127.0.0.1')) { + return 'host.docker.internal'; + } + return raw; +} + export default { plan: { dropdown: { optionLimit: 100 } @@ -18,7 +44,7 @@ export default { // Drupal database configuration mysql: { - host: process.env.MYSQL_HOST || 'host_name', + host: mysqlHostFromEnv(), user: process.env.MYSQL_USER || 'user_name', password: process.env.MYSQL_PASSWORD || '', database: process.env.MYSQL_DATABASE || 'database_name', diff --git a/upload-api/tsconfig.json b/upload-api/tsconfig.json index acddda23..9968340a 100644 --- a/upload-api/tsconfig.json +++ b/upload-api/tsconfig.json @@ -110,6 +110,6 @@ // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ "skipLibCheck": true /* Skip type checking all .d.ts files. */ }, - "include": ["src/**/*.ts", "express.d.ts"], + "include": ["src/**/*.ts", "express.d.ts", "fs-readdir-recursive.d.ts"], "exclude": ["node_modules", "build"] }