import path from "node:path"; import type { OttoAPIKey } from "@proofkit/fmdapi/adapters/otto"; import chalk from "chalk"; import fs from "fs-extra"; import semver from "semver"; import { IndentationText, Project, ScriptKind } from "ts-morph"; import type { PackageJson } from "type-fest"; import type { z } from "zod/v4"; import { buildLayoutClient } from "./buildLayoutClient"; import { buildOverrideFile, buildSchema } from "./buildSchema"; import { commentHeader, defaultEnvNames, overrideCommentHeader } from "./constants"; import { getFmMcpSessionId } from "./fmMcpSession"; import { formatAndSaveSourceFiles, runPostGenerateCommand } from "./formatting"; import { getEnvValues, validateAndLogEnvValues } from "./getEnvValues"; import { rethrowMissingDependency } from "./optionalDeps"; import { type BuildSchemaArgs, typegenConfig, type typegenConfigSingle } from "./types"; type GlobalOptions = Omit, "config">; interface FmMcpClientIdentity { clientName: string; clientDescription: string; } const typegenCliIdleTimeoutSeconds = 120; const connectedFilesTimeoutMs = 5000; const trailingSlashesRegex = /\/+$/; const normalizeFmMcpBaseUrl = (baseUrl: string) => { const trimmedBaseUrl = baseUrl.trim().replace(trailingSlashesRegex, ""); try { const url = new URL(trimmedBaseUrl); url.pathname = url.pathname.replace(trailingSlashesRegex, ""); url.search = ""; url.hash = ""; return url.toString().replace(trailingSlashesRegex, ""); } catch { return trimmedBaseUrl; } }; const getProjectName = (cwd: string) => { try { const packageJson = JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf8")) as PackageJson; if (typeof packageJson.name === "string" && packageJson.name.trim() !== "") { return packageJson.name; } } catch { // Fall back to folder name when typegen runs outside a package root. } return path.basename(cwd); }; const getFmMcpClientIdentity = (cwd: string) => { const projectName = getProjectName(cwd); return { clientName: `ProofKit Typegen (${projectName})`, clientDescription: "ProofKit Typegen wants to read layout metadata from your FileMaker file to generate correct field names and field types into your codebase.", }; }; export const generateTypedClients = async ( config: z.infer["config"], options?: GlobalOptions & { resetOverrides?: boolean; cwd?: string; configPath?: string; proofkitToken?: string; fmMcpClientIdentity?: FmMcpClientIdentity; fmMcpIdleTimeoutSeconds?: number; }, ): Promise< | { successCount: number; errorCount: number; totalCount: number; outputPaths: string[]; } | undefined > => { const parsedConfig = typegenConfig.safeParse({ config }); if (!parsedConfig.success) { console.log(chalk.red("ERROR: Invalid config")); console.log(config); console.dir(parsedConfig.error, { depth: null }); return; } const configArray = Array.isArray(parsedConfig.data.config) ? parsedConfig.data.config : [parsedConfig.data.config]; const postGenerateCommand = options?.postGenerateCommand ?? parsedConfig.data.postGenerateCommand; const { resetOverrides = false, cwd = process.cwd() } = options ?? {}; const clientIndexPathsToReset = new Set(); const rootDirsToClear = new Set(); for (const singleConfig of configArray) { if (singleConfig?.type !== "fmdapi") { continue; } const rootDir = path.join(cwd, singleConfig.path ?? "schema"); clientIndexPathsToReset.add(path.join(rootDir, "client", "index.ts")); if (singleConfig.clearOldFiles) { rootDirsToClear.add(rootDir); } } for (const rootDir of rootDirsToClear) { fs.emptyDirSync(path.join(rootDir, "client")); fs.emptyDirSync(path.join(rootDir, "generated")); } for (const clientIndexPath of clientIndexPathsToReset) { fs.rmSync(clientIndexPath, { force: true }); } let totalSuccessCount = 0; let totalErrorCount = 0; let totalCount = 0; const outputPaths: string[] = []; const isConfigArray = Array.isArray(parsedConfig.data.config); for (let configIndex = 0; configIndex < configArray.length; configIndex++) { const singleConfig = configArray[configIndex]; if (!singleConfig) { continue; } if (singleConfig.type === "fmdapi") { const result = await generateTypedClientsSingle(singleConfig, { resetOverrides, cwd, configPath: options?.configPath, configIndex: isConfigArray ? configIndex : undefined, proofkitToken: options?.proofkitToken, fmMcpClientIdentity: options?.fmMcpClientIdentity, fmMcpIdleTimeoutSeconds: options?.fmMcpIdleTimeoutSeconds, }); if (result) { totalSuccessCount += result.successCount; totalErrorCount += result.errorCount; totalCount += result.totalCount; if (result.outputPath) { outputPaths.push(result.outputPath); } } } else if (singleConfig.type === "fmodata") { const { generateODataTablesSingle } = await import("./fmodata/typegen").catch((error: unknown) => rethrowMissingDependency(error, "@proofkit/fmodata", "fmodata type generation"), ); const outputPath = await generateODataTablesSingle(singleConfig, { cwd }); if (outputPath) { outputPaths.push(outputPath); } } else { console.log(chalk.red("ERROR: Invalid config type")); } } // Run post-generate command once after all configs have been processed await runPostGenerateCommand(postGenerateCommand, cwd); return { successCount: totalSuccessCount, errorCount: totalErrorCount, totalCount, outputPaths }; }; const generateTypedClientsSingle = async ( config: Extract, { type: "fmdapi" }>, options?: GlobalOptions & { resetOverrides?: boolean; cwd?: string; configPath?: string; configIndex?: number; proofkitToken?: string; fmMcpClientIdentity?: FmMcpClientIdentity; fmMcpIdleTimeoutSeconds?: number; }, ) => { const { envNames, layouts, clientSuffix = "Client", generateClient = true, ...rest } = config; const { resetOverrides = false, cwd = process.cwd() } = options ?? {}; const validator = rest.validator ?? "zod/v4"; const rootDir = path.join(cwd, rest.path ?? "schema"); try { const packageJson = JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf8")) as PackageJson; const fmdapiVersion = packageJson.dependencies?.["@proofkit/fmdapi"]; if (fmdapiVersion && semver.valid(fmdapiVersion)) { const isAtLeast501 = semver.satisfies(fmdapiVersion, ">=5.0.1"); if (!isAtLeast501) { console.log( chalk.yellow( "WARNING: @proofkit/typegen will generate types only compatible with @proofkit/fmdapi version 5.0.1 or higher. Please update to the latest version of @proofkit/fmdapi", ), ); } } } catch (_e) { // ignore } const project = new Project({ manipulationSettings: { indentationText: IndentationText.TwoSpaces, }, }); const isFmMcpMode = config.fmMcp != null && config.fmMcp.enabled !== false; const fmMcpObj = config.fmMcp ?? undefined; if (isFmMcpMode && !config.webviewerScriptName) { console.log( chalk.blue( `INFO: Generated clients will use WebViewerAdapter with script "${fmMcpObj?.scriptName ?? "PK_execute_data_api"}".`, ), ); } const envValues = getEnvValues(envNames); const validationResult = validateAndLogEnvValues(envValues, envNames, { fmMcp: isFmMcpMode, fmMcpConfig: isFmMcpMode ? { baseUrl: fmMcpObj?.baseUrl, connectedFileName: fmMcpObj?.connectedFileName, persistentToken: fmMcpObj?.persistentToken, } : undefined, }); if (!validationResult?.success) { return; } // Extract connection details based on mode let server: string | undefined; let db: string | undefined; let auth: { apiKey: OttoAPIKey } | { username: string; password: string } | undefined; let fmMcpBaseUrl: string | undefined; let fmMcpConnectedFileName: string | undefined; let fmMcpPersistentToken: string | undefined; let fmMcpSessionId: string | undefined; const proofkitToken = options?.proofkitToken ?? process.env.FM_MCP_SESSION_ID; if (validationResult.mode === "fmMcp") { fmMcpBaseUrl = normalizeFmMcpBaseUrl(validationResult.baseUrl); fmMcpConnectedFileName = validationResult.connectedFileName; fmMcpPersistentToken = validationResult.persistentToken; // Auto-discover connectedFileName if not provided if (!fmMcpConnectedFileName) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), connectedFilesTimeoutMs); try { const headers = new Headers(); if (proofkitToken) { headers.set("X-ProofKit-Session", proofkitToken); } const res = await fetch(`${fmMcpBaseUrl}/connectedFiles`, { headers, signal: controller.signal, }); clearTimeout(timeout); if (res.ok) { const files = (await res.json()) as string[]; if (files.length === 1) { fmMcpConnectedFileName = files[0]; console.log(chalk.green(`Auto-discovered connected file: ${fmMcpConnectedFileName}`)); // Write discovered connectedFileName back to config file if (options?.configPath) { const configFilePath = path.resolve(cwd, options.configPath); try { const raw = fs.readFileSync(configFilePath, "utf8"); const { modify, applyEdits } = await import("jsonc-parser"); const fmtOpts = { formattingOptions: { insertSpaces: true, tabSize: 2 } }; // Build the JSON path: array configs use ["config", index, "fmMcp", ...], single uses ["config", "fmMcp", ...] const basePath = options.configIndex !== undefined ? ["config", options.configIndex, "fmMcp"] : ["config", "fmMcp"]; // If fmMcp was `true` in the raw file, replace it with an object first let current = raw; const parsed = (await import("jsonc-parser")).parseTree(raw); if (parsed) { const { findNodeAtLocation } = await import("jsonc-parser"); const fmMcpNode = findNodeAtLocation(parsed, basePath); if (fmMcpNode?.type === "boolean") { const replaceEdits = modify( current, basePath, { enabled: true, connectedFileName: fmMcpConnectedFileName }, fmtOpts, ); current = applyEdits(current, replaceEdits); fs.writeFileSync(configFilePath, current, "utf8"); console.log(chalk.green(`Updated config with connectedFileName: ${fmMcpConnectedFileName}`)); } else { const edits = modify(current, [...basePath, "connectedFileName"], fmMcpConnectedFileName, fmtOpts); current = applyEdits(current, edits); fs.writeFileSync(configFilePath, current, "utf8"); console.log(chalk.green(`Updated config with connectedFileName: ${fmMcpConnectedFileName}`)); } } } catch (writeErr) { console.log( chalk.yellow( `Could not update config file: ${writeErr instanceof Error ? writeErr.message : String(writeErr)}`, ), ); } } } else if (files.length > 1) { console.log( chalk.red( "ERROR: Multiple connected files found. Please specify connectedFileName in your fmMcp config.", ), ); console.log(chalk.yellow(`Connected files: ${files.join(", ")}`)); return; } else { console.log(chalk.red("ERROR: No connected files found on the FM MCP server.")); return; } } else { console.log(chalk.red(`ERROR: Failed to auto-discover connected files from ${fmMcpBaseUrl}/connectedFiles`)); return; } } catch (err) { clearTimeout(timeout); if (err instanceof Error && err.name === "AbortError") { console.log(chalk.red(`ERROR: Timed out reading connected files from ${fmMcpBaseUrl}/connectedFiles`)); return; } console.log(chalk.red(`ERROR: Could not reach FM MCP server at ${fmMcpBaseUrl}`)); console.log(chalk.yellow("Ensure the FM MCP server is running and accessible.")); return; } } if (!fmMcpConnectedFileName) { console.log(chalk.red("ERROR: Missing connected FileMaker file name for FM MCP mode.")); return; } const sessionClientIdentity = options?.fmMcpClientIdentity ?? getFmMcpClientIdentity(cwd); fmMcpSessionId = getFmMcpSessionId( { cwd, baseUrl: fmMcpBaseUrl, connectedFileName: fmMcpConnectedFileName, clientName: fmMcpObj?.clientName ?? sessionClientIdentity.clientName, }, proofkitToken ?? fmMcpPersistentToken ?? fmMcpObj?.sessionId, ); } else { server = validationResult.server; db = validationResult.db; const validatedAuth = validationResult.auth; if ("clarisId" in validatedAuth) { console.log(chalk.red("ERROR: Claris ID auth is not supported for fmdapi type generation.")); return; } auth = "apiKey" in validatedAuth ? { apiKey: validatedAuth.apiKey as OttoAPIKey } : validatedAuth; } await fs.ensureDir(rootDir); const clientIndexFilePath = path.join(rootDir, "client", "index.ts"); const [ { default: DataApi }, { FetchAdapter }, { FmMcpAdapter }, { OttoAdapter }, { memoryStore }, { getLayoutMetadata }, ] = await Promise.all([ import("@proofkit/fmdapi").catch((error: unknown) => rethrowMissingDependency(error, "@proofkit/fmdapi", "fmdapi type generation"), ), import("@proofkit/fmdapi/adapters/fetch").catch((error: unknown) => rethrowMissingDependency(error, "@proofkit/fmdapi", "fmdapi type generation"), ), import("@proofkit/fmdapi/adapters/fm-mcp").catch((error: unknown) => rethrowMissingDependency(error, "@proofkit/fmdapi", "fmdapi type generation"), ), import("@proofkit/fmdapi/adapters/otto").catch((error: unknown) => rethrowMissingDependency(error, "@proofkit/fmdapi", "fmdapi type generation"), ), import("@proofkit/fmdapi/tokenStore/memory").catch((error: unknown) => rethrowMissingDependency(error, "@proofkit/fmdapi", "fmdapi type generation"), ), import("./getLayoutMetadata"), ]); let successCount = 0; let errorCount = 0; let totalCount = 0; const fmMcpClientIdentity = options?.fmMcpClientIdentity ?? getFmMcpClientIdentity(cwd); for await (const item of layouts) { totalCount++; let client: ReturnType; if (isFmMcpMode) { client = DataApi({ adapter: new FmMcpAdapter({ baseUrl: fmMcpBaseUrl as string, connectedFileName: fmMcpConnectedFileName as string, scriptName: fmMcpObj?.scriptName ?? config.webviewerScriptName, sessionId: fmMcpSessionId, clientName: fmMcpObj?.clientName ?? fmMcpClientIdentity.clientName, clientDescription: fmMcpObj?.clientDescription ?? fmMcpClientIdentity.clientDescription, idleTimeoutSeconds: options?.fmMcpIdleTimeoutSeconds ?? typegenCliIdleTimeoutSeconds, authorizationTimeoutMs: fmMcpObj?.authorizationTimeoutMs, disableInteractiveAuthorization: fmMcpObj?.disableInteractiveAuthorization, }), layout: item.layoutName, }); } else if (auth && "apiKey" in auth) { client = DataApi({ adapter: new OttoAdapter({ auth, server: server as string, db: db as string }), layout: item.layoutName, }); } else { client = DataApi({ adapter: new FetchAdapter({ auth: auth as { username: string; password: string }, server: server as string, db: db as string, tokenStore: memoryStore(), }), layout: item.layoutName, }); } const result = await getLayoutMetadata({ client, valueLists: item.valueLists, }); if (!result) { errorCount++; continue; } const { schema, portalSchema, valueLists } = result; const args: BuildSchemaArgs = { schemaName: item.schemaName, schema, layoutName: item.layoutName, portalSchema, valueLists, type: validator === "zod" || validator === "zod/v4" || validator === "zod/v3" ? validator : "ts", strictNumbers: item.strictNumbers, webviewerScriptName: config?.type === "fmdapi" ? config.webviewerScriptName : undefined, fmMcp: config?.type === "fmdapi" ? !!config.fmMcp : undefined, envNames: (() => { // FM MCP mode: only need baseUrl + connectedFileName if (isFmMcpMode) { return { fmMcp: { baseUrl: envNames?.fmMcp?.baseUrl ?? defaultEnvNames.fmMcpBaseUrl, connectedFileName: envNames?.fmMcp?.connectedFileName ?? defaultEnvNames.fmMcpConnectedFileName, }, }; } // Determine the intended auth type based on config AND runtime. // Priority: // 1. If user explicitly specified apiKey in config → use OttoAdapter // 2. If user explicitly specified username in config → use FetchAdapter // 3. If neither specified (defaults) → use what was actually used at runtime // // Note: We check for the VALUE being defined, not just the property existing, // because the Zod schema defines both apiKey and username as optional properties, // so both exist on the object but with undefined values when not specified. const configHasApiKey = envNames?.auth?.apiKey !== undefined; const configHasUsername = envNames?.auth?.username !== undefined; const runtimeUsedApiKey = auth ? "apiKey" in auth : false; // Use apiKey if: explicitly specified in config, OR not explicitly set to username AND runtime used apiKey const useApiKey = configHasApiKey || (!configHasUsername && runtimeUsedApiKey); // Determine the env var names to use in generated code const apiKeyEnvName = envNames?.auth?.apiKey ?? defaultEnvNames.apiKey; const usernameEnvName = envNames?.auth?.username ?? defaultEnvNames.username; const passwordEnvName = envNames?.auth?.password ?? defaultEnvNames.password; return { auth: useApiKey ? { apiKey: apiKeyEnvName, username: undefined, password: undefined, } : { apiKey: undefined, username: usernameEnvName, password: passwordEnvName, }, db: envNames?.db ?? defaultEnvNames.db, server: envNames?.server ?? defaultEnvNames.server, }; })(), }; const schemaFile = project.createSourceFile( path.join(rootDir, "generated", `${item.schemaName}.ts`), { leadingTrivia: commentHeader }, { overwrite: true, scriptKind: ScriptKind.TS, }, ); buildSchema(schemaFile, args); const overrideFilePath = path.join(rootDir, `${item.schemaName}.ts`); if (!fs.existsSync(overrideFilePath) || resetOverrides) { // only build the override file if it doesn't exist const overrideFile = project.createSourceFile( overrideFilePath, { leadingTrivia: overrideCommentHeader, }, { overwrite: true, scriptKind: ScriptKind.TS, }, ); buildOverrideFile(overrideFile, schemaFile, args); } if (item.generateClient ?? generateClient) { await fs.ensureDir(path.join(rootDir, "client")); const layoutClientFile = project.createSourceFile( path.join(rootDir, "client", `${item.schemaName}.ts`), { leadingTrivia: commentHeader }, { overwrite: true, scriptKind: ScriptKind.TS, }, ); buildLayoutClient(layoutClientFile, args); await fs.ensureFile(clientIndexFilePath); const clientIndexFile = project.addSourceFileAtPath(clientIndexFilePath); clientIndexFile.addExportDeclaration({ namedExports: [{ name: "client", alias: `${item.schemaName}${clientSuffix}` }], moduleSpecifier: `./${item.schemaName}`, }); } else { console.log(chalk.yellow(`Skipping client generation for ${item.schemaName} because generateClient is false`)); } successCount++; } // Format and save files await formatAndSaveSourceFiles(project, cwd); return { successCount, errorCount, totalCount, outputPath: rootDir }; };