diff --git a/.changeset/fm-http-typegen-flow.md b/.changeset/fm-http-typegen-flow.md
new file mode 100644
index 00000000..235c6551
--- /dev/null
+++ b/.changeset/fm-http-typegen-flow.md
@@ -0,0 +1,6 @@
+---
+"@proofkit/fmdapi": minor
+"@proofkit/typegen": minor
+---
+
+Add FM HTTP adapter support for type generation, including local FM HTTP metadata fetch flow, env name handling, and improved adapter error parsing.
diff --git a/.gitattributes b/.gitattributes
deleted file mode 100644
index 560f0635..00000000
--- a/.gitattributes
+++ /dev/null
@@ -1,2 +0,0 @@
-# mdx
-*.mdx linguist-detectable=false
diff --git a/_artifacts/domain_map.yaml b/_artifacts/domain_map.yaml
index fe25c604..fd92049f 100644
--- a/_artifacts/domain_map.yaml
+++ b/_artifacts/domain_map.yaml
@@ -2,7 +2,7 @@
# Generated by skill-domain-discovery
# Library: ProofKit
# Version: fmdapi@5.0.3-beta.1, fmodata@0.1.0-beta.31, typegen@1.1.0-beta.15, webviewer@3.0.7-beta.0, better-auth@0.4.0-beta.7
-# Date: 2026-03-13
+# Date: 2026-03-16
# Status: reviewed
library:
@@ -50,6 +50,9 @@ skills:
- 'Environment variables (FM_SERVER, FM_DATABASE, OTTO_API_KEY, FM_USERNAME, FM_PASSWORD)'
- 'strictNumbers, strictValueLists options'
- 'webviewerScriptName option'
+ - 'fmHttp config (local FM HTTP proxy for typegen metadata fetching)'
+ - 'FM HTTP env vars (FM_HTTP_BASE_URL, FM_CONNECTED_FILE_NAME)'
+ - 'FM HTTP auto-discovery of connectedFileName via /connectedFiles'
- 'Field type overrides for OData'
- 'reduceMetadata, alwaysOverrideFieldNames'
- 'Entity ID preservation across regeneration'
@@ -59,6 +62,8 @@ skills:
- 'Regenerate types after FileMaker schema changes'
- 'Switch from Data API to OData config'
- 'Configure environment variables for FM connection'
+ - 'Set up FM HTTP mode for local typegen without credentials'
+ - 'Troubleshoot FM HTTP connectivity issues'
- 'Customize generated schemas with override files'
failure_modes:
- mistake: 'Editing files in generated/ or client/ directories'
@@ -168,6 +173,60 @@ skills:
status: active
skills: ['typegen-setup', 'fmdapi-client', 'fmodata-client']
+ - mistake: 'Using FmHttpAdapter in production application code'
+ mechanism: 'FmHttpAdapter is internal to typegen metadata fetching. Generated clients use WebViewerAdapter. Agent sees the adapter import in typegen source and uses it in app code.'
+ wrong_pattern: |
+ import { FmHttpAdapter } from "@proofkit/fmdapi/adapters/fm-http";
+ const client = DataApi({
+ adapter: new FmHttpAdapter({ baseUrl: "http://127.0.0.1:1365", connectedFileName: "MyFile" }),
+ layout: "Contacts",
+ });
+ correct_pattern: |
+ // Use typegen-generated client (which uses WebViewerAdapter internally)
+ import { ContactsLayout } from "./schema/client";
+ const { data } = await ContactsLayout.find({ query: { name: "==John" } });
+ source: 'source: typegen.ts, buildLayoutClient.ts'
+ priority: CRITICAL
+ status: active
+ skills: ['typegen-setup']
+
+ - mistake: 'Setting standard FM env vars when using fmHttp mode'
+ mechanism: 'fmHttp mode does not need FM_SERVER, FM_DATABASE, or OTTO_API_KEY. Agent configures both standard and fmHttp env vars, causing confusion when standard vars fail validation.'
+ wrong_pattern: |
+ # .env — agent sets both
+ FM_SERVER=https://fm.example.com
+ FM_DATABASE=MyFile.fmp12
+ OTTO_API_KEY=dk_abc123
+ FM_HTTP_BASE_URL=http://127.0.0.1:1365
+ correct_pattern: |
+ # fmHttp mode only needs these (baseUrl defaults to 127.0.0.1:1365)
+ # connectedFileName is auto-discovered if not set
+ FM_CONNECTED_FILE_NAME=MyFile
+ source: 'source: getEnvValues.ts, constants.ts'
+ priority: HIGH
+ status: active
+ skills: ['typegen-setup']
+
+ - mistake: 'Suggesting OttoFMS/FetchAdapter fallback when FM HTTP fails'
+ mechanism: 'Agent troubleshoots FM HTTP connection failure by suggesting standard auth. Developer chose fmHttp because they have no hosted credentials or are working offline. Correct troubleshooting: check daemon health (GET http://127.0.0.1:1365/health), check /connectedFiles, ensure FM file has run "Connect to MCP" script and WebViewer window is open in Browse mode.'
+ source: 'maintainer interview'
+ priority: HIGH
+ status: active
+ skills: ['typegen-setup']
+
+ - mistake: 'FM HTTP WebViewer window closed or in Layout mode'
+ mechanism: 'The FM HTTP proxy works via a WebViewer window opened by the "Connect to MCP" script in FileMaker. If this window is closed or switched to Layout mode, all proxy requests fail. Error may not be obvious — typegen just fails to connect.'
+ correct_pattern: |
+ # Troubleshooting checklist:
+ # 1. Is the fm-http daemon running? GET http://127.0.0.1:1365/health
+ # 2. Is the file connected? GET http://127.0.0.1:1365/connectedFiles
+ # 3. If file not listed: open it in FileMaker, run "Connect to MCP" script
+ # 4. Ensure the WebViewer window stays open in Browse mode (not Layout mode)
+ source: 'maintainer interview'
+ priority: HIGH
+ status: active
+ skills: ['typegen-setup']
+
- name: 'Data API Client'
slug: 'fmdapi-client'
domain: 'data-access'
@@ -646,10 +705,12 @@ skills:
- 'First typegen run'
- 'First query with generated client'
- 'Choosing between Data API and OData'
+ - 'FM HTTP mode for local/offline WebViewer development (no credentials needed)'
tasks:
- 'Set up a new project with ProofKit'
- 'Connect to FileMaker server for the first time'
- 'Generate types and make first data query'
+ - 'Set up FM HTTP mode for local WebViewer development'
failure_modes:
- mistake: 'Missing fmrest or fmodata privilege on FM account'
mechanism: 'Data API requires fmrest extended privilege. OData requires fmodata privilege. Without these, all API calls return 401/403.'
diff --git a/_artifacts/skill_spec.md b/_artifacts/skill_spec.md
index da30a122..4d2ab0ce 100644
--- a/_artifacts/skill_spec.md
+++ b/_artifacts/skill_spec.md
@@ -15,7 +15,7 @@ ProofKit is a monorepo of TypeScript tools for building web applications integra
| Skill | Type | Domain | What it covers | Failure modes |
| --- | --- | --- | --- | --- |
-| typegen-setup | core | connecting | Config, CLI, Data API + OData modes, validators, generated file structure, env vars | 7 |
+| typegen-setup | core | connecting | Config, CLI, Data API + OData + FM HTTP modes, validators, generated file structure, env vars | 11 |
| fmdapi-client | core | data-access | DataApi factory, adapters, token stores, CRUD, find variants, scripts, validation | 6 |
| fmodata-client | core | data-access | FMServerConnection, schema/field builders, query builder, CRUD, relationships, batch, errors | 8 |
| webviewer-integration | core | webviewer | fmFetch, callFMScript, WebViewerAdapter, browser-only constraints, local mode perf | 6 |
@@ -25,7 +25,7 @@ ProofKit is a monorepo of TypeScript tools for building web applications integra
## Failure Mode Inventory
-### typegen-setup (7 failure modes)
+### typegen-setup (11 failure modes)
| # | Mistake | Priority | Source | Cross-skill? |
| --- | --- | --- | --- | --- |
@@ -36,6 +36,10 @@ ProofKit is a monorepo of TypeScript tools for building web applications integra
| 5 | Omitting type discriminator for OData config | HIGH | source + docs | — |
| 6 | Manually redefining types instead of using generated/inferred types | CRITICAL | maintainer | fmdapi-client, fmodata-client |
| 7 | Mixing Zod v3 and v4 in the same project | HIGH | maintainer | fmdapi-client, fmodata-client |
+| 8 | Using FmHttpAdapter in production application code | CRITICAL | source | — |
+| 9 | Setting standard FM env vars when using fmHttp mode | HIGH | source | — |
+| 10 | Suggesting OttoFMS/FetchAdapter fallback when FM HTTP fails | HIGH | maintainer | — |
+| 11 | FM HTTP WebViewer window closed or in Layout mode | HIGH | maintainer | — |
### fmdapi-client (6 failure modes)
diff --git a/apps/docs/content/docs/typegen/config.mdx b/apps/docs/content/docs/typegen/config.mdx
index 9ef5a49f..b7c10f00 100644
--- a/apps/docs/content/docs/typegen/config.mdx
+++ b/apps/docs/content/docs/typegen/config.mdx
@@ -75,6 +75,11 @@ The typegen tool is configured using the `proofkit-typegen-config.jsonc` file at
description:
"If set, will generate the client using the @proofkit/webviewer package",
},
+ fmHttp: {
+ type: "{ scriptName?: string }",
+ description:
+ "Optional local-only adapter mode for typegen metadata fetch. Generated clients still use the @proofkit/webviewer adapter.",
+ },
generateClient: {
type: "boolean",
default: "true",
@@ -90,6 +95,9 @@ If set to `false`, will only generate the zod schema and/or typescript types, bu
### `webviewerScriptName`
If set, will generate the client using the [@proofkit/webviewer](/docs/webviewer) package. This allows all calls to run via a FileMaker script rather than a network request. For more information, see the [@proofkit/webviewer](/docs/webviewer) documentation.
+### `fmHttp`
+If set, typegen uses the local FM HTTP adapter only while generating schemas and clients. The generated runtime client still uses [@proofkit/webviewer](/docs/webviewer). If `webviewerScriptName` is not set, `fmHttp.scriptName` (or `"execute_data_api"`) is used for the generated webviewer client script name.
+
### `clientSuffix` (default: `"Layout"`)
The suffix to add to the client name.
@@ -156,4 +164,4 @@ Even if you ignore the value lists for type purposes, the value lists will still
This setting will apply to all fields with value lists in the layout. For more granular control, override the Zod schema using the `extend` method. See the [Transformations](/docs/fmdapi/validation) page for more details.
-
\ No newline at end of file
+
diff --git a/btca.config.jsonc b/btca.config.jsonc
new file mode 100644
index 00000000..2543b4b0
--- /dev/null
+++ b/btca.config.jsonc
@@ -0,0 +1,13 @@
+{
+ "$schema": "https://btca.dev/btca.schema.json",
+ "resources": [
+ {
+ "type": "git",
+ "name": "fm-mcp",
+ "url": "https://github.com/proofgeist/fm-mcp",
+ "branch": "main"
+ }
+ ],
+ "model": "gpt-5.3-codex",
+ "provider": "openai"
+}
\ No newline at end of file
diff --git a/packages/fmdapi/src/adapters/fm-http.ts b/packages/fmdapi/src/adapters/fm-http.ts
new file mode 100644
index 00000000..c7deccda
--- /dev/null
+++ b/packages/fmdapi/src/adapters/fm-http.ts
@@ -0,0 +1,224 @@
+import type {
+ CreateResponse,
+ DeleteResponse,
+ GetResponse,
+ LayoutMetadataResponse,
+ RawFMResponse,
+ ScriptResponse,
+ UpdateResponse,
+} from "../client-types.js";
+import { FileMakerError } from "../client-types.js";
+import type {
+ Adapter,
+ CreateOptions,
+ DeleteOptions,
+ ExecuteScriptOptions,
+ FindOptions,
+ GetOptions,
+ LayoutMetadataOptions,
+ ListOptions,
+ UpdateOptions,
+} from "./core.js";
+
+const TRAILING_SLASHES_REGEX = /\/+$/;
+
+export interface FmHttpAdapterOptions {
+ /** Base URL of the local FM HTTP server (e.g. "http://localhost:3000") */
+ baseUrl: string;
+ /** Name of the connected FileMaker file */
+ connectedFileName: string;
+ /** Name of the FM script that executes Data API calls. Defaults to "execute_data_api" */
+ scriptName?: string;
+}
+
+export class FmHttpAdapter implements Adapter {
+ protected baseUrl: string;
+ protected connectedFileName: string;
+ protected scriptName: string;
+
+ constructor(options: FmHttpAdapterOptions) {
+ this.baseUrl = options.baseUrl.replace(TRAILING_SLASHES_REGEX, "");
+ this.connectedFileName = options.connectedFileName;
+ this.scriptName = options.scriptName ?? "execute_data_api";
+ }
+
+ protected request = async (params: {
+ layout: string;
+ body: object;
+ action?: "read" | "metaData" | "create" | "update" | "delete";
+ timeout?: number;
+ fetchOptions?: RequestInit;
+ }): Promise => {
+ const { action = "read", layout, body, fetchOptions = {} } = params;
+
+ // Normalize underscore-prefixed keys to match FM script expectations
+ const normalizedBody: Record = { ...body } as Record;
+ if ("_offset" in normalizedBody) {
+ normalizedBody.offset = normalizedBody._offset;
+ normalizedBody._offset = undefined;
+ }
+ if ("_limit" in normalizedBody) {
+ normalizedBody.limit = normalizedBody._limit;
+ normalizedBody._limit = undefined;
+ }
+ if ("_sort" in normalizedBody) {
+ normalizedBody.sort = normalizedBody._sort;
+ normalizedBody._sort = undefined;
+ }
+
+ const scriptParam = JSON.stringify({
+ ...normalizedBody,
+ layouts: layout,
+ action,
+ version: "vLatest",
+ });
+
+ const controller = new AbortController();
+ let timeout: NodeJS.Timeout | null = null;
+ if (params.timeout) {
+ timeout = setTimeout(() => controller.abort(), params.timeout);
+ }
+
+ const headers = new Headers(fetchOptions?.headers);
+ headers.set("Content-Type", "application/json");
+
+ let res: Response;
+ try {
+ res = await fetch(`${this.baseUrl}/callScript`, {
+ ...fetchOptions,
+ method: "POST",
+ headers,
+ body: JSON.stringify({
+ connectedFileName: this.connectedFileName,
+ scriptName: this.scriptName,
+ data: scriptParam,
+ }),
+ signal: controller.signal,
+ });
+ } finally {
+ if (timeout) {
+ clearTimeout(timeout);
+ }
+ }
+
+ if (!res.ok) {
+ throw new FileMakerError(String(res.status), `FM HTTP request failed (${res.status}): ${await res.text()}`);
+ }
+
+ const raw = await res.json();
+ // The /callScript response wraps the script result as a string or object
+ let scriptResult: unknown;
+ try {
+ scriptResult = typeof raw.result === "string" ? JSON.parse(raw.result) : (raw.result ?? raw);
+ } catch (err) {
+ throw new FileMakerError(
+ "500",
+ `FM HTTP response parse failed: ${err instanceof Error ? err.message : String(err)}`,
+ );
+ }
+
+ const respData = scriptResult as RawFMResponse;
+
+ const errorCode = respData.messages?.[0]?.code;
+ if (errorCode !== undefined && errorCode !== "0") {
+ throw new FileMakerError(
+ errorCode,
+ `Filemaker Data API failed with (${errorCode}): ${JSON.stringify(respData, null, 2)}`,
+ );
+ }
+
+ return respData.response;
+ };
+
+ list = async (opts: ListOptions): Promise => {
+ return (await this.request({
+ body: opts.data,
+ layout: opts.layout,
+ timeout: opts.timeout,
+ fetchOptions: opts.fetch,
+ })) as GetResponse;
+ };
+
+ get = async (opts: GetOptions): Promise => {
+ return (await this.request({
+ body: opts.data,
+ layout: opts.layout,
+ timeout: opts.timeout,
+ fetchOptions: opts.fetch,
+ })) as GetResponse;
+ };
+
+ find = async (opts: FindOptions): Promise => {
+ return (await this.request({
+ body: opts.data,
+ layout: opts.layout,
+ timeout: opts.timeout,
+ fetchOptions: opts.fetch,
+ })) as GetResponse;
+ };
+
+ create = async (opts: CreateOptions): Promise => {
+ return (await this.request({
+ action: "create",
+ body: opts.data,
+ layout: opts.layout,
+ timeout: opts.timeout,
+ fetchOptions: opts.fetch,
+ })) as CreateResponse;
+ };
+
+ update = async (opts: UpdateOptions): Promise => {
+ return (await this.request({
+ action: "update",
+ body: opts.data,
+ layout: opts.layout,
+ timeout: opts.timeout,
+ fetchOptions: opts.fetch,
+ })) as UpdateResponse;
+ };
+
+ delete = async (opts: DeleteOptions): Promise => {
+ return (await this.request({
+ action: "delete",
+ body: opts.data,
+ layout: opts.layout,
+ timeout: opts.timeout,
+ fetchOptions: opts.fetch,
+ })) as DeleteResponse;
+ };
+
+ layoutMetadata = async (opts: LayoutMetadataOptions): Promise => {
+ return (await this.request({
+ action: "metaData",
+ layout: opts.layout,
+ body: {},
+ timeout: opts.timeout,
+ fetchOptions: opts.fetch,
+ })) as LayoutMetadataResponse;
+ };
+
+ executeScript = async (opts: ExecuteScriptOptions): Promise => {
+ const res = await fetch(`${this.baseUrl}/callScript`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ connectedFileName: this.connectedFileName,
+ scriptName: opts.script,
+ data: opts.scriptParam,
+ }),
+ });
+
+ if (!res.ok) {
+ throw new FileMakerError(String(res.status), `FM HTTP executeScript failed (${res.status}): ${await res.text()}`);
+ }
+
+ const raw = await res.json();
+ return {
+ scriptResult: typeof raw.result === "string" ? raw.result : JSON.stringify(raw.result),
+ } as ScriptResponse;
+ };
+
+ containerUpload = (): Promise => {
+ throw new Error("Container upload is not supported via FM HTTP adapter");
+ };
+}
diff --git a/packages/fmdapi/src/index.ts b/packages/fmdapi/src/index.ts
index 09514bb7..3c730bcf 100644
--- a/packages/fmdapi/src/index.ts
+++ b/packages/fmdapi/src/index.ts
@@ -1,4 +1,5 @@
export { FetchAdapter } from "./adapters/fetch.js";
+export { FmHttpAdapter, type FmHttpAdapterOptions } from "./adapters/fm-http.js";
export { OttoAdapter, type OttoAPIKey } from "./adapters/otto.js";
export { DataApi, DataApi as default } from "./client.js";
export * as clientTypes from "./client-types.js";
diff --git a/packages/fmdapi/tests/fm-http-adapter.test.ts b/packages/fmdapi/tests/fm-http-adapter.test.ts
new file mode 100644
index 00000000..f855886c
--- /dev/null
+++ b/packages/fmdapi/tests/fm-http-adapter.test.ts
@@ -0,0 +1,316 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { FmHttpAdapter } from "../src/adapters/fm-http";
+import { FileMakerError } from "../src/client-types";
+import { DataApi } from "../src/index";
+
+/** Wraps a FM Data API response in the /callScript envelope */
+function callScriptResponse(fmResponse: object, { asString = false } = {}) {
+ return {
+ result: asString ? JSON.stringify(fmResponse) : fmResponse,
+ };
+}
+
+function mockFetch(body: object, status = 200): typeof fetch {
+ return () =>
+ Promise.resolve(
+ new Response(JSON.stringify(body), {
+ status,
+ headers: { "content-type": "application/json" },
+ }),
+ );
+}
+
+function createAdapter(opts?: Partial<{ scriptName: string }>) {
+ return new FmHttpAdapter({
+ baseUrl: "http://localhost:3000",
+ connectedFileName: "MyFile",
+ ...opts,
+ });
+}
+
+function createClient(adapter = createAdapter()) {
+ return DataApi({ adapter, layout: "TestLayout" });
+}
+
+const successEnvelope = (response: unknown) =>
+ callScriptResponse({
+ messages: [{ code: "0" }],
+ response,
+ });
+
+const errorEnvelope = (code: string) =>
+ callScriptResponse({
+ messages: [{ code }],
+ response: {},
+ });
+
+describe("FmHttpAdapter", () => {
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ describe("list / read", () => {
+ it("sends correct payload to /callScript", async () => {
+ const spy = vi.fn(() =>
+ Promise.resolve(
+ new Response(
+ JSON.stringify(
+ successEnvelope({
+ data: [],
+ dataInfo: { totalRecordCount: 0, foundCount: 0, returnedCount: 0 },
+ }),
+ ),
+ { status: 200, headers: { "content-type": "application/json" } },
+ ),
+ ),
+ );
+ vi.stubGlobal("fetch", spy);
+
+ const client = createClient();
+ await client.list();
+
+ expect(spy).toHaveBeenCalledOnce();
+ const [url, init] = spy.mock.calls[0];
+ expect(url).toBe("http://localhost:3000/callScript");
+ expect(init?.method).toBe("POST");
+
+ const body = JSON.parse(init?.body as string);
+ expect(body.connectedFileName).toBe("MyFile");
+ expect(body.scriptName).toBe("execute_data_api");
+
+ const param = JSON.parse(body.data);
+ expect(param.layouts).toBe("TestLayout");
+ expect(param.action).toBe("read");
+ expect(param.version).toBe("vLatest");
+ });
+
+ it("returns records from list", async () => {
+ const records = [
+ { fieldData: { name: "A" }, recordId: "1", modId: "1", portalData: {} },
+ { fieldData: { name: "B" }, recordId: "2", modId: "1", portalData: {} },
+ ];
+ vi.stubGlobal(
+ "fetch",
+ mockFetch(
+ successEnvelope({
+ data: records,
+ dataInfo: { totalRecordCount: 2, foundCount: 2, returnedCount: 2 },
+ }),
+ ),
+ );
+
+ const client = createClient();
+ const resp = await client.list();
+ expect(resp.data.length).toBe(2);
+ });
+ });
+
+ describe("create", () => {
+ it("sends create action", async () => {
+ const spy = vi.fn(() =>
+ Promise.resolve(
+ new Response(JSON.stringify(successEnvelope({ recordId: "42", modId: "1" })), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ }),
+ ),
+ );
+ vi.stubGlobal("fetch", spy);
+
+ const client = createClient();
+ const resp = await client.create({ fieldData: { name: "test" } });
+ expect(resp.recordId).toBe("42");
+
+ const body = JSON.parse(spy.mock.calls[0][1]?.body as string);
+ const param = JSON.parse(body.data);
+ expect(param.action).toBe("create");
+ });
+ });
+
+ describe("update", () => {
+ it("sends update action with recordId", async () => {
+ const spy = vi.fn(() =>
+ Promise.resolve(
+ new Response(JSON.stringify(successEnvelope({ modId: "2" })), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ }),
+ ),
+ );
+ vi.stubGlobal("fetch", spy);
+
+ const client = createClient();
+ const resp = await client.update({ recordId: 1, fieldData: { name: "updated" } });
+ expect(resp.modId).toBe("2");
+
+ const body = JSON.parse(spy.mock.calls[0][1]?.body as string);
+ const param = JSON.parse(body.data);
+ expect(param.action).toBe("update");
+ expect(param.fieldData).toEqual({ name: "updated" });
+ });
+ });
+
+ describe("delete", () => {
+ it("sends delete action", async () => {
+ const spy = vi.fn(() =>
+ Promise.resolve(
+ new Response(JSON.stringify(successEnvelope({})), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ }),
+ ),
+ );
+ vi.stubGlobal("fetch", spy);
+
+ const client = createClient();
+ await client.delete({ recordId: 5 });
+
+ const body = JSON.parse(spy.mock.calls[0][1]?.body as string);
+ const param = JSON.parse(body.data);
+ expect(param.action).toBe("delete");
+ expect(param.recordId).toBe(5);
+ });
+ });
+
+ describe("layoutMetadata", () => {
+ it("sends metaData action", async () => {
+ const metadata = {
+ fieldMetaData: [{ name: "name", type: "normal" }],
+ portalMetaData: {},
+ };
+ vi.stubGlobal("fetch", mockFetch(successEnvelope(metadata)));
+
+ const client = createClient();
+ const resp = await client.layoutMetadata();
+ expect(resp.fieldMetaData).toHaveLength(1);
+ });
+ });
+
+ describe("error handling", () => {
+ it("throws FileMakerError on non-0 message code", async () => {
+ vi.stubGlobal("fetch", mockFetch(errorEnvelope("401")));
+
+ const client = createClient();
+ await expect(client.list()).rejects.toBeInstanceOf(FileMakerError);
+ await expect(client.list()).rejects.toHaveProperty("code", "401");
+ });
+
+ it("throws FileMakerError on HTTP error", async () => {
+ vi.stubGlobal("fetch", () => Promise.resolve(new Response("Internal Server Error", { status: 500 })));
+
+ const client = createClient();
+ await expect(client.list()).rejects.toBeInstanceOf(FileMakerError);
+ });
+ });
+
+ describe("string result parsing", () => {
+ it("parses result when returned as JSON string", async () => {
+ const records = [{ fieldData: { name: "A" }, recordId: "1", modId: "1", portalData: {} }];
+ vi.stubGlobal(
+ "fetch",
+ mockFetch(
+ callScriptResponse(
+ {
+ messages: [{ code: "0" }],
+ response: { data: records, dataInfo: { totalRecordCount: 1, foundCount: 1, returnedCount: 1 } },
+ },
+ { asString: true },
+ ),
+ ),
+ );
+
+ const client = createClient();
+ const resp = await client.list();
+ expect(resp.data.length).toBe(1);
+ });
+ });
+
+ describe("custom script name", () => {
+ it("uses custom scriptName when provided", async () => {
+ const spy = vi.fn(() =>
+ Promise.resolve(
+ new Response(
+ JSON.stringify(
+ successEnvelope({
+ data: [],
+ dataInfo: { totalRecordCount: 0, foundCount: 0, returnedCount: 0 },
+ }),
+ ),
+ { status: 200, headers: { "content-type": "application/json" } },
+ ),
+ ),
+ );
+ vi.stubGlobal("fetch", spy);
+
+ const adapter = createAdapter({ scriptName: "my_custom_script" });
+ const client = createClient(adapter);
+ await client.list();
+
+ const body = JSON.parse(spy.mock.calls[0][1]?.body as string);
+ expect(body.scriptName).toBe("my_custom_script");
+ });
+ });
+
+ describe("underscore param normalization", () => {
+ it("converts _offset/_limit/_sort to offset/limit/sort", async () => {
+ const spy = vi.fn(() =>
+ Promise.resolve(
+ new Response(
+ JSON.stringify(
+ successEnvelope({
+ data: [],
+ dataInfo: { totalRecordCount: 0, foundCount: 0, returnedCount: 0 },
+ }),
+ ),
+ { status: 200, headers: { "content-type": "application/json" } },
+ ),
+ ),
+ );
+ vi.stubGlobal("fetch", spy);
+
+ const client = createClient();
+ await client.list({
+ offset: 5,
+ limit: 10,
+ sort: { fieldName: "name", sortOrder: "ascend" },
+ });
+
+ const body = JSON.parse(spy.mock.calls[0][1]?.body as string);
+ const param = JSON.parse(body.data);
+ expect(param.offset).toBe(5);
+ expect(param.limit).toBe(10);
+ expect(param._offset).toBeUndefined();
+ expect(param._limit).toBeUndefined();
+ });
+ });
+
+ describe("containerUpload", () => {
+ it("throws not supported error", () => {
+ const adapter = createAdapter();
+ expect(() => adapter.containerUpload({} as never)).toThrow("not supported");
+ });
+ });
+
+ describe("executeScript", () => {
+ it("calls /callScript with the given script name", async () => {
+ const spy = vi.fn(() =>
+ Promise.resolve(
+ new Response(JSON.stringify({ result: "hello" }), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ }),
+ ),
+ );
+ vi.stubGlobal("fetch", spy);
+
+ const client = createClient();
+ const resp = await client.executeScript({ script: "MyScript", scriptParam: "param1" });
+
+ expect(resp.scriptResult).toBe("hello");
+ const body = JSON.parse(spy.mock.calls[0][1]?.body as string);
+ expect(body.scriptName).toBe("MyScript");
+ expect(body.data).toBe("param1");
+ expect(body.connectedFileName).toBe("MyFile");
+ });
+ });
+});
diff --git a/packages/typegen/live-fm-http-output/client/contacts.ts b/packages/typegen/live-fm-http-output/client/contacts.ts
new file mode 100644
index 00000000..ee29c212
--- /dev/null
+++ b/packages/typegen/live-fm-http-output/client/contacts.ts
@@ -0,0 +1,14 @@
+/**
+ * Generated by @proofkit/typegen package
+ * https://proofkit.dev/docs/typegen
+ * DO NOT EDIT THIS FILE DIRECTLY. Changes may be overwritten
+ */
+import { DataApi } from "@proofkit/fmdapi";
+import { WebViewerAdapter } from "@proofkit/webviewer/adapter";
+import { Zcontacts } from "../contacts";
+
+export const client = DataApi({
+ adapter: new WebViewerAdapter({ scriptName: "execute_data_api" }),
+ layout: "Contacts",
+ schema: { fieldData: Zcontacts },
+});
diff --git a/packages/typegen/live-fm-http-output/client/index.ts b/packages/typegen/live-fm-http-output/client/index.ts
new file mode 100644
index 00000000..07903551
--- /dev/null
+++ b/packages/typegen/live-fm-http-output/client/index.ts
@@ -0,0 +1 @@
+export { client as contactsLayout } from "./contacts";
diff --git a/packages/typegen/live-fm-http-output/contacts.ts b/packages/typegen/live-fm-http-output/contacts.ts
new file mode 100644
index 00000000..c9556702
--- /dev/null
+++ b/packages/typegen/live-fm-http-output/contacts.ts
@@ -0,0 +1,10 @@
+/**
+ * Put your custom overrides or transformations here.
+ * Changes to this file will NOT be overwritten.
+ */
+import type { z } from "zod/v4";
+import { Zcontacts as Zcontacts_generated } from "./generated/contacts";
+
+export const Zcontacts = Zcontacts_generated;
+
+export type Tcontacts = z.infer;
diff --git a/packages/typegen/proofkit-typegen.fm-http.local.jsonc b/packages/typegen/proofkit-typegen.fm-http.local.jsonc
new file mode 100644
index 00000000..1a8d13bb
--- /dev/null
+++ b/packages/typegen/proofkit-typegen.fm-http.local.jsonc
@@ -0,0 +1,20 @@
+{
+ "$schema": "https://proofkit.dev/typegen-config-schema.json",
+ "config": {
+ "type": "fmdapi",
+ "webviewerScriptName": "execute_data_api",
+ "fmHttp": {
+ "enabled": true,
+ "connectedFileName": "MCP_Test"
+ },
+ "layouts": [
+ {
+ "layoutName": "Contacts",
+ "schemaName": "contacts"
+ }
+ ],
+ "path": "packages/typegen/live-fm-http-output",
+ "validator": "zod/v4",
+ "generateClient": true
+ }
+}
diff --git a/packages/typegen/schema/fmdapi_test.ts b/packages/typegen/schema/fmdapi_test.ts
new file mode 100644
index 00000000..7be9195b
--- /dev/null
+++ b/packages/typegen/schema/fmdapi_test.ts
@@ -0,0 +1,16 @@
+import { fmTableOccurrence, numberField, textField } from "@proofkit/fmodata";
+
+export const fmdapi_test = fmTableOccurrence(
+ "fmdapi_test",
+ {
+ PrimaryKey: textField()
+ .primaryKey()
+ .entityId("FMFID:4296032385")
+ .comment("Unique identifier of each record in this table"),
+ recordId: numberField().readOnly().entityId("FMFID:30065836161"),
+ },
+ {
+ entityId: "FMTID:1065089",
+ navigationPaths: [],
+ },
+);
diff --git a/packages/typegen/schema/index.ts b/packages/typegen/schema/index.ts
new file mode 100644
index 00000000..0c7d1473
--- /dev/null
+++ b/packages/typegen/schema/index.ts
@@ -0,0 +1,6 @@
+// ============================================================================
+// Auto-generated index file - exports all table occurrences
+// ============================================================================
+
+export { fmdapi_test } from "./fmdapi_test";
+export { isolated_contacts } from "./isolated_contacts";
diff --git a/packages/typegen/schema/isolated_contacts.ts b/packages/typegen/schema/isolated_contacts.ts
new file mode 100644
index 00000000..9a1a8ebf
--- /dev/null
+++ b/packages/typegen/schema/isolated_contacts.ts
@@ -0,0 +1,35 @@
+import { fmTableOccurrence, textField, timestampField } from "@proofkit/fmodata";
+
+export const isolated_contacts = fmTableOccurrence(
+ "isolated_contacts",
+ {
+ PrimaryKey: textField()
+ .primaryKey()
+ .entityId("FMFID:4296032394")
+ .comment("Unique identifier of each record in this table"),
+ CreationTimestamp: timestampField()
+ .notNull()
+ .entityId("FMFID:8590999690")
+ .comment("Date and time each record was created"),
+ CreatedBy: textField()
+ .notNull()
+ .entityId("FMFID:12885966986")
+ .comment("Account name of the user who created each record"),
+ ModificationTimestamp: timestampField()
+ .notNull()
+ .entityId("FMFID:17180934282")
+ .comment("Date and time each record was last modified"),
+ ModifiedBy: textField()
+ .notNull()
+ .entityId("FMFID:21475901578")
+ .comment("Account name of the user who last modified each record"),
+ name: textField().entityId("FMFID:25770868874"),
+ hobby: textField().entityId("FMFID:30065836170"),
+ id_user: textField().entityId("FMFID:38655770762"),
+ my_calc: textField().readOnly().entityId("FMFID:42950738058"),
+ },
+ {
+ entityId: "FMTID:1065098",
+ navigationPaths: [],
+ },
+);
diff --git a/packages/typegen/skills/getting-started/SKILL.md b/packages/typegen/skills/getting-started/SKILL.md
index 029075f5..4eda27f7 100644
--- a/packages/typegen/skills/getting-started/SKILL.md
+++ b/packages/typegen/skills/getting-started/SKILL.md
@@ -187,6 +187,56 @@ if (error) {
}
```
+### Path D: Local WebViewer Development (FM HTTP — no credentials)
+
+For WebViewer apps running inside FileMaker, you can use FM HTTP mode to generate types from a local FileMaker file without any server credentials.
+
+**Prerequisites:**
+- FM HTTP daemon installed and running (`curl http://127.0.0.1:1365/health`)
+- FileMaker file open locally with "Connect to MCP" script run
+
+1. Install packages:
+
+```bash
+pnpm add @proofkit/fmdapi @proofkit/webviewer zod
+```
+
+2. No `.env` file needed for typegen (baseUrl defaults, connectedFileName is auto-discovered). Optionally set "connectedFileName" in the config.fmHttp.connectedFileName to override the auto-discovery.
+
+3. Create typegen config with `fmHttp` enabled:
+
+```jsonc
+{
+ "$schema": "https://proofkit.dev/typegen-config-schema.json",
+ "config": {
+ "type": "fmdapi",
+ "fmHttp": { "enabled": true },
+ "layouts": [
+ { "layoutName": "api_Contacts", "schemaName": "Contacts" }
+ ],
+ "path": "schema"
+ }
+}
+```
+
+4. Run typegen — it auto-discovers the connected file and writes it back to config:
+
+```bash
+npx @proofkit/typegen
+```
+
+5. First query (runs inside FileMaker WebViewer):
+
+```ts
+import { ContactsLayout } from "./schema/client";
+
+const { data } = await ContactsLayout.findOne({
+ query: { id: "==123" },
+});
+```
+
+The generated client uses `WebViewerAdapter` with `"execute_data_api"` as the default script name. No server URL or API keys are needed at runtime — all calls go through the FileMaker script engine.
+
## Choosing Data API vs OData
| Aspect | Data API (`@proofkit/fmdapi`) | OData (`@proofkit/fmodata`) |
diff --git a/packages/typegen/skills/typegen-setup/SKILL.md b/packages/typegen/skills/typegen-setup/SKILL.md
index 65ab327e..e052bc7d 100644
--- a/packages/typegen/skills/typegen-setup/SKILL.md
+++ b/packages/typegen/skills/typegen-setup/SKILL.md
@@ -5,7 +5,8 @@ description: >
validators from FileMaker layouts or OData metadata. Covers
proofkit-typegen-config.jsonc, validator options (zod/v4, zod/v3),
generated vs override file structure, envNames, InferTableSchema,
- InferZodPortals, and the schema/generated/client directory layout.
+ InferZodPortals, schema/generated/client directory layout, and
+ fmHttp mode for local typegen without credentials.
type: core
library: proofkit
library_version: "1.1.0-beta.15"
@@ -181,35 +182,42 @@ import { Customers } from "./schema/odata/generated/Customers";
type CustomerRow = InferTableSchema;
```
-### Multiple configs (mixed Data API + OData)
+### Multiple configs
+
+The `config` key can be an array mixing `fmdapi` and `fmodata` entries, each with its own `path` and `envNames`.
+
+### FM HTTP mode (local development, no credentials)
+
+FM HTTP mode lets typegen fetch layout metadata from a locally running FileMaker file via the FM HTTP proxy, without needing OttoFMS, a hosted server, or any credentials. Generated clients still use `WebViewerAdapter` — FM HTTP is only used during typegen.
```jsonc
{
"$schema": "https://proofkit.dev/typegen-config-schema.json",
- "config": [
- {
- "type": "fmdapi",
- "path": "schema/dapi",
- "layouts": [
- { "layoutName": "api_Contacts", "schemaName": "Contacts" }
- ]
- },
- {
- "type": "fmodata",
- "path": "schema/odata",
- "envNames": {
- "server": "ODATA_SERVER_URL",
- "db": "ODATA_DATABASE_NAME",
- "auth": { "apiKey": "ODATA_API_KEY" }
- },
- "tables": [
- { "tableName": "Products" }
- ]
- }
- ]
+ "config": {
+ "type": "fmdapi",
+ "fmHttp": { "enabled": true },
+ "layouts": [
+ { "layoutName": "api_Contacts", "schemaName": "Contacts" }
+ ]
+ }
}
```
+`fmHttp` accepts an object with optional overrides:
+
+- `enabled` — `true` to enable (default when object is present)
+- `scriptName` — FM script the proxy calls for Data API operations. Resolution: `fmHttp.scriptName` > `webviewerScriptName` > `"execute_data_api"`
+- `baseUrl` — FM HTTP server URL (default: `http://127.0.0.1:1365`). Can also be set via `FM_HTTP_BASE_URL` env var
+- `connectedFileName` — FileMaker file name. If omitted, auto-discovered from `GET /connectedFiles` and written back to config
+
+The generated client uses `WebViewerAdapter` with `webviewerScriptName` if set, otherwise `"execute_data_api"`.
+
+**Prerequisites:**
+1. FM HTTP daemon running locally (`GET http://127.0.0.1:1365/health` should return OK)
+2. FileMaker file open on the local machine
+3. "Connect to MCP" script run in the FileMaker file (opens a WebViewer window that bridges HTTP requests)
+4. That WebViewer window must stay open in **Browse mode** (not Layout mode)
+
### Custom env variable names
```jsonc
@@ -418,6 +426,62 @@ Zod v3 and v4 have incompatible APIs (`z.infer` vs `z.output`, different `extend
Source: apps/docs/content/docs/typegen/config.mdx
+### CRITICAL: Using FmHttpAdapter in production application code
+
+Wrong:
+```ts
+import { FmHttpAdapter } from "@proofkit/fmdapi/adapters/fm-http";
+
+const client = DataApi({
+ adapter: new FmHttpAdapter({
+ baseUrl: "http://127.0.0.1:1365",
+ connectedFileName: "MyFile",
+ }),
+ layout: "Contacts",
+});
+```
+
+Correct:
+```ts
+// Use the typegen-generated client (uses WebViewerAdapter internally)
+import { ContactsLayout } from "./schema/client";
+
+const { data } = await ContactsLayout.find({ query: { name: "==John" } });
+```
+
+`FmHttpAdapter` is internal to typegen's metadata fetching process. It only runs during code generation, never in production. Generated clients use `WebViewerAdapter` for runtime data access inside FileMaker WebViewer.
+
+### HIGH: Setting standard FM env vars when using fmHttp mode
+
+Wrong:
+```bash
+# Agent configures both standard and fmHttp vars
+FM_SERVER=https://fm.example.com
+FM_DATABASE=MyFile.fmp12
+OTTO_API_KEY=dk_abc123
+FM_HTTP_BASE_URL=http://127.0.0.1:1365
+```
+
+Correct:
+```bash
+# fmHttp mode only — no server/db/auth needed
+# baseUrl defaults to http://127.0.0.1:1365 if not set
+# connectedFileName is auto-discovered if not set
+FM_CONNECTED_FILE_NAME=MyFile
+```
+
+fmHttp mode bypasses the standard FM_SERVER/FM_DATABASE/auth env vars entirely. Setting both causes confusion when standard validation reports missing values.
+
+### HIGH: FM HTTP connection failures — troubleshooting
+
+If typegen fails to connect in fmHttp mode, do NOT suggest falling back to OttoFMS or FetchAdapter. The developer chose fmHttp because they don't have hosted credentials or are working with a local-only file.
+
+Troubleshooting checklist:
+1. **Daemon running?** `curl http://127.0.0.1:1365/health` — should return `{"service":"fm-http","status":"ok"}`
+2. **File connected?** `curl http://127.0.0.1:1365/connectedFiles` — should list the target file
+3. **File not listed?** Open the FileMaker file and run the **"Connect to MCP"** script
+4. **Still not working?** Ensure the WebViewer window opened by "Connect to MCP" is in **Browse mode**, not Layout mode. Closing this window or switching to Layout mode silently breaks the proxy.
+
## References
- **fmdapi-client**: Typegen generates the layout-specific clients consumed by `@proofkit/fmdapi`. Override files and `InferZodPortals` bridge typegen output into fmdapi usage.
diff --git a/packages/typegen/src/buildLayoutClient.ts b/packages/typegen/src/buildLayoutClient.ts
index 61abc06a..04bc5801 100644
--- a/packages/typegen/src/buildLayoutClient.ts
+++ b/packages/typegen/src/buildLayoutClient.ts
@@ -2,15 +2,35 @@ import { type CodeBlockWriter, type SourceFile, VariableDeclarationKind } from "
import { defaultEnvNames } from "./constants";
import type { BuildSchemaArgs } from "./types";
+const defaultWebviewerScriptName = "execute_data_api";
+
+function normalizeScriptName(scriptName?: string) {
+ const normalized = scriptName?.trim();
+ return normalized ? normalized : undefined;
+}
+
+function getGeneratedWebviewerScriptName(args: Pick) {
+ const explicitWebviewerScriptName = normalizeScriptName(args.webviewerScriptName);
+ if (explicitWebviewerScriptName) {
+ return explicitWebviewerScriptName;
+ }
+ if (args.fmHttp) {
+ return defaultWebviewerScriptName;
+ }
+ return undefined;
+}
+
export function buildLayoutClient(sourceFile: SourceFile, args: BuildSchemaArgs) {
- const { schemaName, portalSchema, envNames, type, webviewerScriptName, layoutName } = args;
+ const { schemaName, portalSchema, envNames, type, webviewerScriptName, fmHttp, layoutName } = args;
+ const generatedWebviewerScriptName = getGeneratedWebviewerScriptName({ webviewerScriptName, fmHttp });
+ const usesWebviewerAdapter = generatedWebviewerScriptName !== undefined;
const fmdapiImport = sourceFile.addImportDeclaration({
moduleSpecifier: "@proofkit/fmdapi",
namedImports: ["DataApi"],
});
const hasPortals = (portalSchema ?? []).length > 0;
- if (webviewerScriptName) {
+ if (usesWebviewerAdapter) {
sourceFile.addImportDeclaration({
moduleSpecifier: "@proofkit/webviewer/adapter",
namedImports: ["WebViewerAdapter"],
@@ -45,7 +65,7 @@ export function buildLayoutClient(sourceFile: SourceFile, args: BuildSchemaArgs)
}
}
- if (!webviewerScriptName) {
+ if (!usesWebviewerAdapter) {
addTypeGuardStatements(sourceFile, {
envVarName: envNames.db ?? defaultEnvNames.db,
});
@@ -110,11 +130,12 @@ function addTypeGuardStatements(sourceFile: SourceFile, { envVarName }: { envVar
}
function buildAdapter(writer: CodeBlockWriter, args: BuildSchemaArgs): string {
- const { envNames, webviewerScriptName } = args;
+ const { envNames } = args;
+ const generatedWebviewerScriptName = getGeneratedWebviewerScriptName(args);
- if (webviewerScriptName) {
+ if (generatedWebviewerScriptName !== undefined) {
writer.write("new WebViewerAdapter({scriptName: ");
- writer.quote(webviewerScriptName);
+ writer.quote(generatedWebviewerScriptName);
writer.write("})");
} else if (typeof envNames.auth === "object" && "apiKey" in envNames.auth && envNames.auth.apiKey !== undefined) {
writer
diff --git a/packages/typegen/src/cli.ts b/packages/typegen/src/cli.ts
index 5c68bee0..78788e7a 100644
--- a/packages/typegen/src/cli.ts
+++ b/packages/typegen/src/cli.ts
@@ -90,6 +90,7 @@ async function runCodegen({ configLocation, resetOverrides = false }: ConfigArgs
const result = await generateTypedClients(configParsed.data.config, {
resetOverrides,
postGenerateCommand: configParsed.data.postGenerateCommand,
+ configPath: configLocation,
}).catch((err: unknown) => {
console.error(err);
return process.exit(1);
diff --git a/packages/typegen/src/constants.ts b/packages/typegen/src/constants.ts
index ed779e59..3537996a 100644
--- a/packages/typegen/src/constants.ts
+++ b/packages/typegen/src/constants.ts
@@ -1,7 +1,7 @@
export const commentHeader = `/**
* Generated by @proofkit/typegen package
* https://proofkit.dev/docs/typegen
- * DO NOT EDIT THIS FILE DIRECTLY. Changes may be overritten
+ * DO NOT EDIT THIS FILE DIRECTLY. Changes may be overwritten
*/
@@ -29,4 +29,8 @@ export const defaultEnvNames = {
password: "FM_PASSWORD",
server: "FM_SERVER",
db: "FM_DATABASE",
+ fmHttpBaseUrl: "FM_HTTP_BASE_URL",
+ fmHttpConnectedFileName: "FM_CONNECTED_FILE_NAME",
};
+
+export const defaultFmHttpBaseUrl = "http://127.0.0.1:1365";
diff --git a/packages/typegen/src/fmodata/downloadMetadata.ts b/packages/typegen/src/fmodata/downloadMetadata.ts
index b0056a23..7bdf4702 100644
--- a/packages/typegen/src/fmodata/downloadMetadata.ts
+++ b/packages/typegen/src/fmodata/downloadMetadata.ts
@@ -29,6 +29,10 @@ export async function downloadTableMetadata({
throw new Error(validationResult.errorMessage);
}
+ if (validationResult.mode === "fmHttp") {
+ throw new Error("FM HTTP mode is not supported for OData metadata download");
+ }
+
const { server, db, auth } = validationResult;
// Create connection based on authentication method
diff --git a/packages/typegen/src/getEnvValues.ts b/packages/typegen/src/getEnvValues.ts
index c0f54b28..6a125576 100644
--- a/packages/typegen/src/getEnvValues.ts
+++ b/packages/typegen/src/getEnvValues.ts
@@ -1,6 +1,6 @@
import chalk from "chalk";
import type { z } from "zod/v4";
-import { defaultEnvNames } from "./constants";
+import { defaultEnvNames, defaultFmHttpBaseUrl } from "./constants";
import type { typegenConfigSingle } from "./types";
type EnvNames = z.infer["envNames"];
@@ -11,15 +11,24 @@ export interface EnvValues {
apiKey: string | undefined;
username: string | undefined;
password: string | undefined;
+ fmHttpBaseUrl: string | undefined;
+ fmHttpConnectedFileName: string | undefined;
}
export type EnvValidationResult =
| {
success: true;
+ mode: "standard";
server: string;
db: string;
auth: { apiKey: string } | { username: string; password: string };
}
+ | {
+ success: true;
+ mode: "fmHttp";
+ baseUrl: string;
+ connectedFileName: string;
+ }
| {
success: false;
errorMessage: string;
@@ -61,12 +70,27 @@ export function getEnvValues(envNames?: EnvNames): EnvValues {
const username = process.env[usernameEnvName];
const password = process.env[passwordEnvName];
+ // FM HTTP env vars
+ const fmHttpBaseUrlEnvName =
+ envNames?.fmHttp && "baseUrl" in envNames.fmHttp
+ ? getEnvName(envNames.fmHttp.baseUrl, defaultEnvNames.fmHttpBaseUrl)
+ : defaultEnvNames.fmHttpBaseUrl;
+ const fmHttpConnectedFileNameEnvName =
+ envNames?.fmHttp && "connectedFileName" in envNames.fmHttp
+ ? getEnvName(envNames.fmHttp.connectedFileName, defaultEnvNames.fmHttpConnectedFileName)
+ : defaultEnvNames.fmHttpConnectedFileName;
+
+ const fmHttpBaseUrl = process.env[fmHttpBaseUrlEnvName];
+ const fmHttpConnectedFileName = process.env[fmHttpConnectedFileNameEnvName];
+
return {
server,
db,
apiKey,
username,
password,
+ fmHttpBaseUrl,
+ fmHttpConnectedFileName,
};
}
@@ -78,8 +102,27 @@ export function getEnvValues(envNames?: EnvNames): EnvValues {
* @param envNames - Optional custom environment variable names (for error messages)
* @returns Validation result with success flag and either data or error message
*/
-export function validateEnvValues(envValues: EnvValues, envNames?: EnvNames): EnvValidationResult {
- const { server, db, apiKey, username, password } = envValues;
+export function validateEnvValues(
+ envValues: EnvValues,
+ envNames?: EnvNames,
+ options?: { fmHttp?: boolean; fmHttpConfig?: { baseUrl?: string; connectedFileName?: string } },
+): EnvValidationResult {
+ const { server, db, apiKey, username, password, fmHttpBaseUrl, fmHttpConnectedFileName } = envValues;
+
+ // FM HTTP mode: resolve baseUrl and connectedFileName with fallback chain
+ // Priority: config value > env var > default/auto-discover
+ if (options?.fmHttp) {
+ const resolvedBaseUrl = options.fmHttpConfig?.baseUrl || fmHttpBaseUrl || defaultFmHttpBaseUrl;
+ const resolvedConnectedFileName = options.fmHttpConfig?.connectedFileName || fmHttpConnectedFileName;
+
+ // connectedFileName will be auto-discovered later if still missing
+ return {
+ success: true,
+ mode: "fmHttp",
+ baseUrl: resolvedBaseUrl,
+ connectedFileName: resolvedConnectedFileName ?? "",
+ };
+ }
// Helper to get env name, treating empty strings as undefined
const getEnvName = (customName: string | undefined, defaultName: string) =>
@@ -159,6 +202,7 @@ export function validateEnvValues(envValues: EnvValues, envNames?: EnvNames): En
return {
success: true,
+ mode: "standard",
server,
db,
auth,
@@ -173,38 +217,73 @@ export function validateEnvValues(envValues: EnvValues, envNames?: EnvNames): En
* @param envNames - Optional custom environment variable names (for error messages)
* @returns Validated values or undefined if validation failed
*/
-export function validateAndLogEnvValues(envValues: EnvValues, envNames?: EnvNames): EnvValidationResult | undefined {
- const result = validateEnvValues(envValues, envNames);
+export function validateAndLogEnvValues(
+ envValues: EnvValues,
+ envNames?: EnvNames,
+ options?: { fmHttp?: boolean; fmHttpConfig?: { baseUrl?: string; connectedFileName?: string } },
+): EnvValidationResult | undefined {
+ const result = validateEnvValues(envValues, envNames, options);
if (!result.success) {
console.log(chalk.red("ERROR: Could not get all required config values"));
console.log("Ensure the following environment variables are set:");
- const { server, db, apiKey } = envValues;
+ const getEnvName = (customName: string | undefined, defaultName: string) =>
+ customName && customName.trim() !== "" ? customName : defaultName;
+
+ if (options?.fmHttp) {
+ if (!envValues.fmHttpBaseUrl) {
+ console.log(
+ getEnvName(
+ envNames?.fmHttp && "baseUrl" in envNames.fmHttp ? envNames.fmHttp.baseUrl : undefined,
+ defaultEnvNames.fmHttpBaseUrl,
+ ),
+ );
+ }
+ if (!envValues.fmHttpConnectedFileName) {
+ console.log(
+ getEnvName(
+ envNames?.fmHttp && "connectedFileName" in envNames.fmHttp ? envNames.fmHttp.connectedFileName : undefined,
+ defaultEnvNames.fmHttpConnectedFileName,
+ ),
+ );
+ }
+ console.log();
+ return undefined;
+ }
+
+ const { server, db, apiKey, username, password } = envValues;
if (!server) {
- console.log(`${envNames?.server ?? defaultEnvNames.server}`);
+ console.log(getEnvName(envNames?.server, defaultEnvNames.server));
}
if (!db) {
- console.log(`${envNames?.db ?? defaultEnvNames.db}`);
+ console.log(getEnvName(envNames?.db, defaultEnvNames.db));
}
- if (!apiKey) {
+ if (!(apiKey || username)) {
// Determine the names to display in the error message
- const apiKeyNameToLog =
- envNames?.auth && "apiKey" in envNames.auth && envNames.auth.apiKey
- ? envNames.auth.apiKey
- : defaultEnvNames.apiKey;
- const usernameNameToLog =
- envNames?.auth && "username" in envNames.auth && envNames.auth.username
- ? envNames.auth.username
- : defaultEnvNames.username;
- const passwordNameToLog =
- envNames?.auth && "password" in envNames.auth && envNames.auth.password
- ? envNames.auth.password
- : defaultEnvNames.password;
+ const apiKeyNameToLog = getEnvName(
+ envNames?.auth && "apiKey" in envNames.auth ? envNames.auth.apiKey : undefined,
+ defaultEnvNames.apiKey,
+ );
+ const usernameNameToLog = getEnvName(
+ envNames?.auth && "username" in envNames.auth ? envNames.auth.username : undefined,
+ defaultEnvNames.username,
+ );
+ const passwordNameToLog = getEnvName(
+ envNames?.auth && "password" in envNames.auth ? envNames.auth.password : undefined,
+ defaultEnvNames.password,
+ );
console.log(`${apiKeyNameToLog} (or ${usernameNameToLog} and ${passwordNameToLog})`);
+ } else if (username && !password) {
+ console.log(
+ getEnvName(
+ envNames?.auth && "password" in envNames.auth ? envNames.auth.password : undefined,
+ defaultEnvNames.password,
+ ),
+ );
}
console.log();
diff --git a/packages/typegen/src/index.ts b/packages/typegen/src/index.ts
index aa25964b..2f2916c1 100644
--- a/packages/typegen/src/index.ts
+++ b/packages/typegen/src/index.ts
@@ -1,5 +1,5 @@
/** biome-ignore-all lint/performance/noBarrelFile: Re-exporting typegen functions */
export { buildSchema } from "./buildSchema";
export { generateTypedClients } from "./typegen";
-export { typegenConfig, typegenConfigSingle } from "./types";
export type { BuildSchemaArgs, FmodataConfig, TSchema, TypegenConfig, ValueListsOptions } from "./types";
+export { typegenConfig, typegenConfigSingle } from "./types";
diff --git a/packages/typegen/src/server/createDataApiClient.ts b/packages/typegen/src/server/createDataApiClient.ts
index d3440ab0..6c8456fb 100644
--- a/packages/typegen/src/server/createDataApiClient.ts
+++ b/packages/typegen/src/server/createDataApiClient.ts
@@ -1,23 +1,24 @@
import path from "node:path";
import DataApi from "@proofkit/fmdapi";
import { FetchAdapter } from "@proofkit/fmdapi/adapters/fetch";
+import { FmHttpAdapter } from "@proofkit/fmdapi/adapters/fm-http";
import { OttoAdapter, type OttoAPIKey } from "@proofkit/fmdapi/adapters/otto";
import { memoryStore } from "@proofkit/fmdapi/tokenStore/memory";
import { type Database, FMServerConnection } from "@proofkit/fmodata";
import fs from "fs-extra";
import { parse } from "jsonc-parser";
import type { z } from "zod/v4";
-import { defaultEnvNames } from "../constants";
+import { defaultEnvNames, defaultFmHttpBaseUrl } from "../constants";
import { typegenConfig, type typegenConfigSingle } from "../types";
import type { ApiContext } from "./app";
export interface CreateClientResult {
// biome-ignore lint/suspicious/noExplicitAny: DataApi is a generic type
- client: ReturnType>;
+ client: ReturnType>;
config: Extract, { type: "fmdapi" }>;
server: string;
db: string;
- authType: "apiKey" | "username";
+ authType: "apiKey" | "username" | "fmHttp";
}
export interface CreateClientError {
@@ -195,14 +196,64 @@ export function createOdataClientFromConfig(config: FmodataConfig): OdataClientR
* @returns The client, server, and db, or an error object
*/
export function createClientFromConfig(config: FmdapiConfig): Omit | CreateClientError {
+ // FM HTTP mode
+ if (config.fmHttp != null && config.fmHttp.enabled !== false) {
+ const fmHttpObj = config.fmHttp;
+
+ const getEnvName = (customName: string | undefined, defaultName: string) =>
+ customName && customName.trim() !== "" ? customName : defaultName;
+
+ const baseUrlEnvName = getEnvName(config.envNames?.fmHttp?.baseUrl, defaultEnvNames.fmHttpBaseUrl);
+ const connectedFileNameEnvName = getEnvName(
+ config.envNames?.fmHttp?.connectedFileName,
+ defaultEnvNames.fmHttpConnectedFileName,
+ );
+
+ // Resolution: config value > env var > default
+ const baseUrl = fmHttpObj?.baseUrl || process.env[baseUrlEnvName] || defaultFmHttpBaseUrl;
+ const connectedFileName = fmHttpObj?.connectedFileName || process.env[connectedFileNameEnvName];
+
+ // connectedFileName is required (auto-discovery not available in sync context)
+ if (!connectedFileName) {
+ return {
+ error: "Missing connectedFileName for FM HTTP mode",
+ statusCode: 400,
+ kind: "missing_env",
+ details: { missing: { connectedFileName: true } },
+ suspectedField: "db",
+ message: "Set connectedFileName in your fmHttp config or FM_CONNECTED_FILE_NAME env var",
+ };
+ }
+
+ try {
+ // biome-ignore lint/suspicious/noExplicitAny: DataApi is a generic type
+ const client: ReturnType> = DataApi({
+ adapter: new FmHttpAdapter({
+ baseUrl,
+ connectedFileName,
+ scriptName: fmHttpObj?.scriptName ?? config.webviewerScriptName,
+ }),
+ layout: "",
+ });
+ return { client, server: baseUrl, db: connectedFileName, authType: "fmHttp" };
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : "Failed to create FM HTTP adapter";
+ return {
+ error: errorMessage,
+ statusCode: 400,
+ kind: "adapter_error",
+ suspectedField: "server",
+ message: errorMessage,
+ };
+ }
+ }
+
const result = getEnvVarsFromConfig(config.envNames);
if ("error" in result) {
return result;
}
const { server, db, authType, auth } = result;
- // Determine which auth method will be used (prefer API key if available)
-
// Create DataApi client with error handling for adapter construction
// biome-ignore lint/suspicious/noExplicitAny: DataApi is a generic type
let client: ReturnType>;
diff --git a/packages/typegen/src/typegen.ts b/packages/typegen/src/typegen.ts
index a135d4dc..51d3a8b7 100644
--- a/packages/typegen/src/typegen.ts
+++ b/packages/typegen/src/typegen.ts
@@ -1,6 +1,7 @@
import path from "node:path";
import DataApi from "@proofkit/fmdapi";
import { FetchAdapter } from "@proofkit/fmdapi/adapters/fetch";
+import { FmHttpAdapter } from "@proofkit/fmdapi/adapters/fm-http";
import { OttoAdapter, type OttoAPIKey } from "@proofkit/fmdapi/adapters/otto";
import { memoryStore } from "@proofkit/fmdapi/tokenStore/memory";
import chalk from "chalk";
@@ -11,7 +12,7 @@ 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 { commentHeader, defaultEnvNames, defaultFmHttpBaseUrl, overrideCommentHeader } from "./constants";
import { generateODataTablesSingle } from "./fmodata/typegen";
import { formatAndSaveSourceFiles, runPostGenerateCommand } from "./formatting";
import { getEnvValues, validateAndLogEnvValues } from "./getEnvValues";
@@ -22,7 +23,7 @@ type GlobalOptions = Omit, "config">;
export const generateTypedClients = async (
config: z.infer["config"],
- options?: GlobalOptions & { resetOverrides?: boolean; cwd?: string },
+ options?: GlobalOptions & { resetOverrides?: boolean; cwd?: string; configPath?: string },
): Promise<
| {
successCount: number;
@@ -50,9 +51,17 @@ export const generateTypedClients = async (
let totalCount = 0;
const outputPaths: string[] = [];
- for (const singleConfig of configArray) {
+ 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 });
+ const result = await generateTypedClientsSingle(singleConfig, {
+ resetOverrides,
+ cwd,
+ configPath: options?.configPath,
+ configIndex: isConfigArray ? configIndex : undefined,
+ });
if (result) {
totalSuccessCount += result.successCount;
totalErrorCount += result.errorCount;
@@ -79,7 +88,7 @@ export const generateTypedClients = async (
const generateTypedClientsSingle = async (
config: Extract, { type: "fmdapi" }>,
- options?: GlobalOptions & { resetOverrides?: boolean; cwd?: string },
+ options?: GlobalOptions & { resetOverrides?: boolean; cwd?: string; configPath?: string; configIndex?: number },
) => {
const {
envNames,
@@ -120,16 +129,102 @@ const generateTypedClientsSingle = async (
},
});
+ const isFmHttpMode = config.fmHttp != null && config.fmHttp.enabled !== false;
+ const fmHttpObj = config.fmHttp ?? undefined;
+
+ if (isFmHttpMode && !config.webviewerScriptName) {
+ console.log(chalk.blue(`INFO: Generated clients will use WebViewerAdapter with script "${fmHttpObj?.scriptName ?? "execute_data_api"}".`));
+ }
+
const envValues = getEnvValues(envNames);
- const validationResult = validateAndLogEnvValues(envValues, envNames);
+ const validationResult = validateAndLogEnvValues(envValues, envNames, {
+ fmHttp: isFmHttpMode,
+ fmHttpConfig: isFmHttpMode ? { baseUrl: fmHttpObj?.baseUrl, connectedFileName: fmHttpObj?.connectedFileName } : undefined,
+ });
if (!validationResult?.success) {
return;
}
- const { server, db, auth: validatedAuth } = validationResult;
- const auth: { apiKey: OttoAPIKey } | { username: string; password: string } =
- "apiKey" in validatedAuth ? { apiKey: validatedAuth.apiKey as OttoAPIKey } : validatedAuth;
+ // Extract connection details based on mode
+ let server: string | undefined;
+ let db: string | undefined;
+ let auth: { apiKey: OttoAPIKey } | { username: string; password: string } | undefined;
+ let fmHttpBaseUrl: string | undefined;
+ let fmHttpConnectedFileName: string | undefined;
+
+ if (validationResult.mode === "fmHttp") {
+ fmHttpBaseUrl = validationResult.baseUrl;
+ fmHttpConnectedFileName = validationResult.connectedFileName;
+
+ // Auto-discover connectedFileName if not provided
+ if (!fmHttpConnectedFileName) {
+ try {
+ const res = await fetch(`${fmHttpBaseUrl}/connectedFiles`);
+ if (res.ok) {
+ const files = (await res.json()) as string[];
+ if (files.length === 1) {
+ fmHttpConnectedFileName = files[0];
+ console.log(chalk.green(`Auto-discovered connected file: ${fmHttpConnectedFileName}`));
+
+ // 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, "fmHttp", ...], single uses ["config", "fmHttp", ...]
+ const basePath = options.configIndex !== undefined
+ ? ["config", options.configIndex, "fmHttp"]
+ : ["config", "fmHttp"];
+
+ // If fmHttp 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 fmHttpNode = findNodeAtLocation(parsed, basePath);
+ if (fmHttpNode?.type === "boolean") {
+ const replaceEdits = modify(current, basePath, { enabled: true, connectedFileName: fmHttpConnectedFileName }, fmtOpts);
+ current = applyEdits(current, replaceEdits);
+ fs.writeFileSync(configFilePath, current, "utf8");
+ console.log(chalk.green(`Updated config with connectedFileName: ${fmHttpConnectedFileName}`));
+ } else {
+ const edits = modify(current, [...basePath, "connectedFileName"], fmHttpConnectedFileName, fmtOpts);
+ current = applyEdits(current, edits);
+ fs.writeFileSync(configFilePath, current, "utf8");
+ console.log(chalk.green(`Updated config with connectedFileName: ${fmHttpConnectedFileName}`));
+ }
+ }
+ } 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 fmHttp config."));
+ console.log(chalk.yellow(`Connected files: ${files.join(", ")}`));
+ return;
+ } else {
+ console.log(chalk.red("ERROR: No connected files found on the FM HTTP server."));
+ return;
+ }
+ } else {
+ console.log(chalk.red(`ERROR: Failed to auto-discover connected files from ${fmHttpBaseUrl}/connectedFiles`));
+ return;
+ }
+ } catch (err) {
+ console.log(chalk.red(`ERROR: Could not reach FM HTTP server at ${fmHttpBaseUrl}`));
+ console.log(chalk.yellow("Ensure the FM HTTP server is running and accessible."));
+ return;
+ }
+ }
+ } else {
+ server = validationResult.server;
+ db = validationResult.db;
+ const validatedAuth = validationResult.auth;
+ auth = "apiKey" in validatedAuth ? { apiKey: validatedAuth.apiKey as OttoAPIKey } : validatedAuth;
+ }
await fs.ensureDir(rootDir);
if (clearOldFiles) {
@@ -145,21 +240,32 @@ const generateTypedClientsSingle = async (
for await (const item of layouts) {
totalCount++;
- const client =
- "apiKey" in auth
- ? DataApi({
- adapter: new OttoAdapter({ auth, server, db }),
- layout: item.layoutName,
- })
- : DataApi({
- adapter: new FetchAdapter({
- auth,
- server,
- db,
- tokenStore: memoryStore(),
- }),
- layout: item.layoutName,
- });
+ let client: ReturnType;
+ if (isFmHttpMode) {
+ client = DataApi({
+ adapter: new FmHttpAdapter({
+ baseUrl: fmHttpBaseUrl as string,
+ connectedFileName: fmHttpConnectedFileName as string,
+ scriptName: fmHttpObj?.scriptName ?? config.webviewerScriptName,
+ }),
+ 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,
@@ -180,7 +286,18 @@ const generateTypedClientsSingle = async (
type: validator === "zod" || validator === "zod/v4" || validator === "zod/v3" ? validator : "ts",
strictNumbers: item.strictNumbers,
webviewerScriptName: config?.type === "fmdapi" ? config.webviewerScriptName : undefined,
+ fmHttp: config?.type === "fmdapi" ? !!config.fmHttp : undefined,
envNames: (() => {
+ // FM HTTP mode: only need baseUrl + connectedFileName
+ if (isFmHttpMode) {
+ return {
+ fmHttp: {
+ baseUrl: envNames?.fmHttp?.baseUrl ?? defaultEnvNames.fmHttpBaseUrl,
+ connectedFileName: envNames?.fmHttp?.connectedFileName ?? defaultEnvNames.fmHttpConnectedFileName,
+ },
+ };
+ }
+
// Determine the intended auth type based on config AND runtime.
// Priority:
// 1. If user explicitly specified apiKey in config → use OttoAdapter
@@ -192,7 +309,7 @@ const generateTypedClientsSingle = async (
// 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 = "apiKey" in auth;
+ 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);
diff --git a/packages/typegen/src/types.ts b/packages/typegen/src/types.ts
index 3fbcaffb..f1e0db0f 100644
--- a/packages/typegen/src/types.ts
+++ b/packages/typegen/src/types.ts
@@ -35,6 +35,12 @@ export const envNamesBase = z
password: z.string().optional(),
})
.optional(),
+ fmHttp: z
+ .object({
+ baseUrl: z.string().optional(),
+ connectedFileName: z.string().optional(),
+ })
+ .optional(),
})
.optional()
.meta({
@@ -60,6 +66,12 @@ const envNames = envNamesBase
password: val.auth.password === "" ? undefined : val.auth.password,
}
: undefined,
+ fmHttp: val.fmHttp
+ ? {
+ baseUrl: val.fmHttp.baseUrl === "" ? undefined : val.fmHttp.baseUrl,
+ connectedFileName: val.fmHttp.connectedFileName === "" ? undefined : val.fmHttp.connectedFileName,
+ }
+ : undefined,
};
// Remove auth if all values are undefined
@@ -67,6 +79,11 @@ const envNames = envNamesBase
transformed.auth = undefined;
}
+ // Remove fmHttp if all values are undefined
+ if (transformed.fmHttp && Object.values(transformed.fmHttp).every((v) => v === undefined)) {
+ transformed.fmHttp = undefined;
+ }
+
// Return undefined if all top-level values are undefined
if (Object.values(transformed).every((v) => v === undefined)) {
return undefined;
@@ -173,6 +190,35 @@ const webviewerScriptNameField = z.string().optional().meta({
"The name of the webviewer script to be used. If this key is set, the generated client will use the @proofkit/webviewer adapter instead of the OttoFMS or Fetch adapter, which will only work when loaded inside of a FileMaker webviewer.",
});
+const fmHttpFieldObject = z.object({
+ enabled: z.boolean().default(true).optional().meta({
+ description: "Enable the FM HTTP proxy for metadata fetching during typegen.",
+ }),
+ scriptName: z.string().optional().meta({
+ description:
+ 'The FM script the HTTP proxy calls to execute Data API operations. Overrides webviewerScriptName for the proxy call. Defaults to "execute_data_api".',
+ }),
+ baseUrl: z.string().optional().meta({
+ description:
+ 'Base URL of the local FM HTTP server. Defaults to "http://127.0.0.1:1365". Can also be set via FM_HTTP_BASE_URL env var.',
+ }),
+ connectedFileName: z.string().optional().meta({
+ description:
+ "Name of the connected FileMaker file. If not provided, it will be auto-discovered from the FM HTTP server's /connectedFiles endpoint and written back to your config. Can also be set via FM_CONNECTED_FILE_NAME env var.",
+ }),
+});
+
+const fmHttpField = z
+ .preprocess((val) => {
+ if (val === true) return { enabled: true };
+ return val;
+ }, fmHttpFieldObject)
+ .optional()
+ .meta({
+ description:
+ "Enable the FM HTTP proxy for metadata fetching during typegen. Generated clients will use the @proofkit/webviewer adapter with webviewerScriptName or 'execute_data_api' as the default.",
+ });
+
const reduceMetadataField = z.boolean().optional().meta({
description:
"If true, reduced OData annotations will be requested from the server to reduce payload size. This will prevent comments, entity ids, and other properties from being generated.",
@@ -206,6 +252,7 @@ const createFmdapiConfig = (envNamesSchema: typeof envNames | typeof envNamesBas
clientSuffix: clientSuffixField,
generateClient: generateClientField,
webviewerScriptName: webviewerScriptNameField,
+ fmHttp: fmHttpField,
});
const createFmodataConfig = (envNamesSchema: typeof envNames | typeof envNamesBase) =>
@@ -279,4 +326,5 @@ export interface BuildSchemaArgs {
layoutName: string;
strictNumbers?: boolean;
webviewerScriptName?: string;
+ fmHttp?: boolean;
}
diff --git a/packages/typegen/tests/__snapshots__/strict-numbers.snap.ts b/packages/typegen/tests/__snapshots__/strict-numbers.snap.ts
index fea41bd7..02761941 100644
--- a/packages/typegen/tests/__snapshots__/strict-numbers.snap.ts
+++ b/packages/typegen/tests/__snapshots__/strict-numbers.snap.ts
@@ -1,7 +1,7 @@
/**
* Generated by @proofkit/typegen package
* https://proofkit.dev/docs/typegen
- * DO NOT EDIT THIS FILE DIRECTLY. Changes may be overritten
+ * DO NOT EDIT THIS FILE DIRECTLY. Changes may be overwritten
*/
import type { InferZodPortals } from "@proofkit/fmdapi";
import { z } from "zod/v4";
diff --git a/packages/typegen/tests/__snapshots__/zod-layout-client.snap.ts b/packages/typegen/tests/__snapshots__/zod-layout-client.snap.ts
index 2bdb5480..546809dc 100644
--- a/packages/typegen/tests/__snapshots__/zod-layout-client.snap.ts
+++ b/packages/typegen/tests/__snapshots__/zod-layout-client.snap.ts
@@ -1,7 +1,7 @@
/**
* Generated by @proofkit/typegen package
* https://proofkit.dev/docs/typegen
- * DO NOT EDIT THIS FILE DIRECTLY. Changes may be overritten
+ * DO NOT EDIT THIS FILE DIRECTLY. Changes may be overwritten
*/
import type { InferZodPortals } from "@proofkit/fmdapi";
import { z } from "zod";
diff --git a/packages/typegen/tests/getEnvValues.test.ts b/packages/typegen/tests/getEnvValues.test.ts
new file mode 100644
index 00000000..2be311fe
--- /dev/null
+++ b/packages/typegen/tests/getEnvValues.test.ts
@@ -0,0 +1,117 @@
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { getEnvValues, validateEnvValues } from "../src/getEnvValues";
+
+describe("getEnvValues + validateEnvValues", () => {
+ const originalEnv: Record = {};
+
+ beforeEach(() => {
+ for (const key of [
+ "FM_SERVER",
+ "FM_DATABASE",
+ "OTTO_API_KEY",
+ "FM_USERNAME",
+ "FM_PASSWORD",
+ "FM_HTTP_BASE_URL",
+ "FM_CONNECTED_FILE_NAME",
+ "CUSTOM_SERVER",
+ "CUSTOM_DB",
+ "CUSTOM_KEY",
+ "CUSTOM_HTTP_URL",
+ "CUSTOM_HTTP_FILE",
+ ]) {
+ originalEnv[key] = process.env[key];
+ delete process.env[key];
+ }
+ });
+
+ afterEach(() => {
+ for (const [key, value] of Object.entries(originalEnv)) {
+ if (value === undefined) {
+ delete process.env[key];
+ } else {
+ process.env[key] = value;
+ }
+ }
+ });
+
+ it("validates standard mode with api key", () => {
+ process.env.FM_SERVER = "https://example.com";
+ process.env.FM_DATABASE = "MyDB";
+ process.env.OTTO_API_KEY = "KEY_test_123";
+
+ const envValues = getEnvValues();
+ const result = validateEnvValues(envValues);
+
+ expect(result.success).toBe(true);
+ if (result.success && result.mode === "standard") {
+ expect(result.mode).toBe("standard");
+ expect(result.server).toBe("https://example.com");
+ expect(result.db).toBe("MyDB");
+ expect(result.auth).toEqual({ apiKey: "KEY_test_123" });
+ }
+ });
+
+ it("validates fmHttp mode with default env names", () => {
+ process.env.FM_HTTP_BASE_URL = "http://127.0.0.1:1365";
+ process.env.FM_CONNECTED_FILE_NAME = "MyFile";
+
+ const envValues = getEnvValues();
+ const result = validateEnvValues(envValues, undefined, { fmHttp: true });
+
+ expect(result.success).toBe(true);
+ if (result.success && result.mode === "fmHttp") {
+ expect(result.mode).toBe("fmHttp");
+ expect(result.baseUrl).toBe("http://127.0.0.1:1365");
+ expect(result.connectedFileName).toBe("MyFile");
+ }
+ });
+
+ it("defaults baseUrl and allows empty connectedFileName for auto-discovery when fmHttp env vars are missing", () => {
+ const envValues = getEnvValues();
+ const result = validateEnvValues(envValues, undefined, { fmHttp: true });
+
+ expect(result.success).toBe(true);
+ if (result.success && result.mode === "fmHttp") {
+ expect(result.baseUrl).toBe("http://127.0.0.1:1365");
+ expect(result.connectedFileName).toBe("");
+ }
+ });
+
+ it("uses config values over env vars for fmHttp", () => {
+ process.env.FM_HTTP_BASE_URL = "http://env-url:9999";
+ process.env.FM_CONNECTED_FILE_NAME = "EnvFile";
+
+ const envValues = getEnvValues();
+ const result = validateEnvValues(envValues, undefined, {
+ fmHttp: true,
+ fmHttpConfig: { baseUrl: "http://config-url:1234", connectedFileName: "ConfigFile" },
+ });
+
+ expect(result.success).toBe(true);
+ if (result.success && result.mode === "fmHttp") {
+ expect(result.baseUrl).toBe("http://config-url:1234");
+ expect(result.connectedFileName).toBe("ConfigFile");
+ }
+ });
+
+ it("reads custom env variable names", () => {
+ process.env.CUSTOM_SERVER = "https://custom.example.com";
+ process.env.CUSTOM_DB = "CustomDB";
+ process.env.CUSTOM_KEY = "KEY_custom_123";
+ process.env.CUSTOM_HTTP_URL = "http://127.0.0.1:1365";
+ process.env.CUSTOM_HTTP_FILE = "CustomFile";
+
+ const envValues = getEnvValues({
+ server: "CUSTOM_SERVER",
+ db: "CUSTOM_DB",
+ auth: { apiKey: "CUSTOM_KEY" },
+ fmHttp: { baseUrl: "CUSTOM_HTTP_URL", connectedFileName: "CUSTOM_HTTP_FILE" },
+ });
+
+ expect(envValues.server).toBe("https://custom.example.com");
+ expect(envValues.db).toBe("CustomDB");
+ expect(envValues.apiKey).toBe("KEY_custom_123");
+ expect(envValues.fmHttpBaseUrl).toBe("http://127.0.0.1:1365");
+ expect(envValues.fmHttpConnectedFileName).toBe("CustomFile");
+ });
+});
diff --git a/packages/typegen/tests/public-api.test.ts b/packages/typegen/tests/public-api.test.ts
index 34a7706c..ae9b5fc2 100644
--- a/packages/typegen/tests/public-api.test.ts
+++ b/packages/typegen/tests/public-api.test.ts
@@ -1,18 +1,14 @@
-import { describe, expect, it } from "vitest";
import { Project, ScriptKind } from "ts-morph";
-import { buildSchema, type BuildSchemaArgs } from "../src/index";
+import { describe, expect, it } from "vitest";
+import { type BuildSchemaArgs, buildSchema } from "../src/index";
describe("typegen public api", () => {
it("exports buildSchema from the root entrypoint", () => {
const project = new Project();
- const schemaFile = project.createSourceFile(
- "customer.ts",
- "",
- {
- overwrite: true,
- scriptKind: ScriptKind.TS,
- },
- );
+ const schemaFile = project.createSourceFile("customer.ts", "", {
+ overwrite: true,
+ scriptKind: ScriptKind.TS,
+ });
const args: BuildSchemaArgs = {
schemaName: "customer",
@@ -37,8 +33,8 @@ describe("typegen public api", () => {
const content = schemaFile.getFullText();
expect(content).toContain("export type Tcustomer");
- expect(content).toContain("\"recordId\": string");
- expect(content).toContain("\"balance\": string | number");
- expect(content).toContain("export const layoutName = \"Customer\"");
+ expect(content).toContain('"recordId": string');
+ expect(content).toContain('"balance": string | number');
+ expect(content).toContain('export const layoutName = "Customer"');
});
});
diff --git a/packages/typegen/tests/typegen.test.ts b/packages/typegen/tests/typegen.test.ts
index 3d570fd1..9f9cc843 100644
--- a/packages/typegen/tests/typegen.test.ts
+++ b/packages/typegen/tests/typegen.test.ts
@@ -39,7 +39,8 @@ describe("typegen unit tests", () => {
originalEnv.FM_DATABASE = process.env.FM_DATABASE;
originalEnv.FM_USERNAME = process.env.FM_USERNAME;
originalEnv.FM_PASSWORD = process.env.FM_PASSWORD;
-
+ originalEnv.FM_HTTP_BASE_URL = process.env.FM_HTTP_BASE_URL;
+ originalEnv.FM_CONNECTED_FILE_NAME = process.env.FM_CONNECTED_FILE_NAME;
// Set mock env values for tests
// Use valid Otto API key format (KEY_ prefix for Otto v3)
process.env.OTTO_API_KEY = "KEY_test_api_key_12345";
@@ -465,4 +466,54 @@ describe("typegen unit tests", () => {
expect(content).toContain("suffixSchemaLayout");
});
+
+ it("generates client using WebViewerAdapter when fmHttp config is provided", async () => {
+ process.env.FM_HTTP_BASE_URL = "http://127.0.0.1:1365";
+ process.env.FM_CONNECTED_FILE_NAME = "TestFile";
+
+ const fetchMock = vi.fn(
+ createLayoutMetadataMock({
+ FmHttpLayout: mockLayoutMetadata["basic-layout"],
+ }),
+ );
+ vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
+
+ const config: Extract, { type: "fmdapi" }> = {
+ type: "fmdapi",
+ envNames: undefined,
+ layouts: [
+ {
+ layoutName: "FmHttpLayout",
+ schemaName: "fmHttpSchema",
+ },
+ ],
+ path: "unit-typegen-output/fm-http",
+ generateClient: true,
+ validator: false,
+ webviewerScriptName: "execute_data_api_custom",
+ fmHttp: { enabled: true },
+ };
+
+ await generateTypedClients(config, { cwd: import.meta.dirname });
+
+ const clientPath = path.join(__dirname, "unit-typegen-output/fm-http/client/fmHttpSchema.ts");
+ const content = await fs.readFile(clientPath, "utf-8");
+
+ expect(content).toContain("WebViewerAdapter");
+ expect(content).not.toContain("FmHttpAdapter");
+ expect(content).not.toContain("FM_HTTP_BASE_URL");
+ expect(content).not.toContain("FM_CONNECTED_FILE_NAME");
+ expect(content).toContain('scriptName: "execute_data_api_custom"');
+
+ expect(fetchMock).toHaveBeenCalled();
+ const [url, init] = fetchMock.mock.calls[0] ?? [];
+ expect(String(url)).toContain("/callScript");
+
+ const body = JSON.parse(String(init?.body ?? "{}"));
+ // FmHttpAdapter uses webviewerScriptName when fmHttp.scriptName not set
+ expect(body.scriptName).toBe("execute_data_api_custom");
+ const scriptParam = JSON.parse(String(body.data ?? "{}"));
+ expect(scriptParam.action).toBe("metaData");
+ expect(scriptParam.layouts).toBe("FmHttpLayout");
+ });
});
diff --git a/packages/typegen/tests/utils/mock-fetch.ts b/packages/typegen/tests/utils/mock-fetch.ts
index 3711ef50..cb03375e 100644
--- a/packages/typegen/tests/utils/mock-fetch.ts
+++ b/packages/typegen/tests/utils/mock-fetch.ts
@@ -29,6 +29,7 @@ import type { LayoutMetadata, MockLayoutMetadataKey } from "../fixtures/layout-m
// Move regex to top level for performance
const LAYOUT_URL_PATTERN = /\/layouts\/([^/?]+)(?:\?|$)/;
const SESSIONS_URL_PATTERN = /\/sessions$/;
+const CALL_SCRIPT_URL_PATTERN = /\/callScript$/;
/**
* Extract URL string from various input types
@@ -54,6 +55,50 @@ export function createLayoutMetadataMock(layouts: Record
return (input: RequestInfo | URL, _init?: RequestInit): Promise => {
const url = getUrlString(input);
+ // Handle FmHttpAdapter requests to /callScript
+ if (CALL_SCRIPT_URL_PATTERN.test(url)) {
+ const body = _init?.body ? JSON.parse(String(_init.body)) : {};
+ const scriptData = body?.data ? JSON.parse(String(body.data)) : {};
+ const layoutName = scriptData.layouts;
+ const action = scriptData.action;
+
+ if (action === "metaData" && layoutName && layouts[layoutName]) {
+ return Promise.resolve(
+ new Response(
+ JSON.stringify({
+ result: {
+ messages: [{ code: "0", message: "OK" }],
+ response: layouts[layoutName],
+ },
+ }),
+ {
+ status: 200,
+ statusText: "OK",
+ headers: { "content-type": "application/json" },
+ },
+ ),
+ );
+ }
+
+ if (action === "metaData" && layoutName && !layouts[layoutName]) {
+ return Promise.resolve(
+ new Response(
+ JSON.stringify({
+ result: {
+ messages: [{ code: "105", message: "Layout is missing" }],
+ response: {},
+ },
+ }),
+ {
+ status: 200,
+ statusText: "OK",
+ headers: { "content-type": "application/json" },
+ },
+ ),
+ );
+ }
+ }
+
// Handle FetchAdapter session/token requests
// FetchAdapter expects token in X-FM-Data-Access-Token header
if (SESSIONS_URL_PATTERN.test(url)) {
diff --git a/packages/typegen/typegen.schema.json b/packages/typegen/typegen.schema.json
index fc72fc9a..12793e50 100644
--- a/packages/typegen/typegen.schema.json
+++ b/packages/typegen/typegen.schema.json
@@ -24,7 +24,9 @@
]
}
},
- "required": ["config"],
+ "required": [
+ "config"
+ ],
"additionalProperties": false,
"definitions": {
"__schema0": {
@@ -84,7 +86,10 @@
]
}
},
- "required": ["layoutName", "schemaName"],
+ "required": [
+ "layoutName",
+ "schemaName"
+ ],
"additionalProperties": false
}
},
@@ -125,9 +130,20 @@
"$ref": "#/definitions/__schema14"
}
]
+ },
+ "fmHttp": {
+ "description": "Enable the FM HTTP proxy for metadata fetching during typegen. Generated clients will use the @proofkit/webviewer adapter with webviewerScriptName or 'execute_data_api' as the default.",
+ "allOf": [
+ {
+ "$ref": "#/definitions/__schema15"
+ }
+ ]
}
},
- "required": ["type", "layouts"],
+ "required": [
+ "type",
+ "layouts"
+ ],
"additionalProperties": false
},
{
@@ -154,7 +170,7 @@
"description": "If true, reduced OData annotations will be requested from the server to reduce payload size. This will prevent comments, entity ids, and other properties from being generated.",
"allOf": [
{
- "$ref": "#/definitions/__schema15"
+ "$ref": "#/definitions/__schema20"
}
]
},
@@ -165,7 +181,7 @@
"description": "If true (default), field names will always be updated to match metadata, even when matching by entity ID. If false, existing field names are preserved when matching by entity ID.",
"allOf": [
{
- "$ref": "#/definitions/__schema16"
+ "$ref": "#/definitions/__schema21"
}
]
},
@@ -174,7 +190,7 @@
"description": "Required array of tables to generate. Only the tables specified here will be downloaded and generated. Each table can have field-level overrides for excluding fields, renaming variables, and overriding field types.",
"allOf": [
{
- "$ref": "#/definitions/__schema17"
+ "$ref": "#/definitions/__schema22"
}
]
},
@@ -182,12 +198,15 @@
"description": "If true, all fields will be included by default. If false, only fields that are explicitly listed in the `fields` array will be included.",
"allOf": [
{
- "$ref": "#/definitions/__schema25"
+ "$ref": "#/definitions/__schema30"
}
]
}
},
- "required": ["type", "tables"],
+ "required": [
+ "type",
+ "tables"
+ ],
"additionalProperties": false
}
]
@@ -223,13 +242,29 @@
}
},
"additionalProperties": false
+ },
+ "fmHttp": {
+ "type": "object",
+ "properties": {
+ "baseUrl": {
+ "type": "string"
+ },
+ "connectedFileName": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
}
},
"additionalProperties": false
},
"__schema4": {
"type": "string",
- "enum": ["strict", "allowEmpty", "ignore"]
+ "enum": [
+ "strict",
+ "allowEmpty",
+ "ignore"
+ ]
},
"__schema5": {
"type": "boolean"
@@ -266,7 +301,11 @@
"anyOf": [
{
"type": "string",
- "enum": ["zod", "zod/v4", "zod/v3"]
+ "enum": [
+ "zod",
+ "zod/v4",
+ "zod/v3"
+ ]
},
{
"type": "boolean",
@@ -286,13 +325,64 @@
"type": "string"
},
"__schema15": {
- "type": "boolean"
+ "type": "object",
+ "properties": {
+ "enabled": {
+ "description": "Enable the FM HTTP proxy for metadata fetching during typegen.",
+ "allOf": [
+ {
+ "$ref": "#/definitions/__schema16"
+ }
+ ]
+ },
+ "scriptName": {
+ "description": "The FM script the HTTP proxy calls to execute Data API operations. Overrides webviewerScriptName for the proxy call. Defaults to \"execute_data_api\".",
+ "allOf": [
+ {
+ "$ref": "#/definitions/__schema17"
+ }
+ ]
+ },
+ "baseUrl": {
+ "description": "Base URL of the local FM HTTP server. Defaults to \"http://127.0.0.1:1365\". Can also be set via FM_HTTP_BASE_URL env var.",
+ "allOf": [
+ {
+ "$ref": "#/definitions/__schema18"
+ }
+ ]
+ },
+ "connectedFileName": {
+ "description": "Name of the connected FileMaker file. If not provided, it will be auto-discovered from the FM HTTP server's /connectedFiles endpoint and written back to your config. Can also be set via FM_CONNECTED_FILE_NAME env var.",
+ "allOf": [
+ {
+ "$ref": "#/definitions/__schema19"
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
},
"__schema16": {
"default": true,
"type": "boolean"
},
"__schema17": {
+ "type": "string"
+ },
+ "__schema18": {
+ "type": "string"
+ },
+ "__schema19": {
+ "type": "string"
+ },
+ "__schema20": {
+ "type": "boolean"
+ },
+ "__schema21": {
+ "default": true,
+ "type": "boolean"
+ },
+ "__schema22": {
"type": "array",
"items": {
"type": "object",
@@ -305,7 +395,7 @@
"description": "Override the generated TypeScript variable name. The original entity set name is still used for the OData path.",
"allOf": [
{
- "$ref": "#/definitions/__schema18"
+ "$ref": "#/definitions/__schema23"
}
]
},
@@ -313,7 +403,7 @@
"description": "Field-specific overrides as an array",
"allOf": [
{
- "$ref": "#/definitions/__schema19"
+ "$ref": "#/definitions/__schema24"
}
]
},
@@ -321,7 +411,7 @@
"description": "If undefined, the top-level setting will be used. If true, reduced OData annotations will be requested from the server to reduce payload size. This will prevent comments, entity ids, and other properties from being generated.",
"allOf": [
{
- "$ref": "#/definitions/__schema22"
+ "$ref": "#/definitions/__schema27"
}
]
},
@@ -329,7 +419,7 @@
"description": "If undefined, the top-level setting will be used. If true, field names will always be updated to match metadata, even when matching by entity ID. If false, existing field names are preserved when matching by entity ID.",
"allOf": [
{
- "$ref": "#/definitions/__schema23"
+ "$ref": "#/definitions/__schema28"
}
]
},
@@ -337,19 +427,21 @@
"description": "If true, all fields will be included by default. If false, only fields that are explicitly listed in the `fields` array will be included.",
"allOf": [
{
- "$ref": "#/definitions/__schema24"
+ "$ref": "#/definitions/__schema29"
}
]
}
},
- "required": ["tableName"],
+ "required": [
+ "tableName"
+ ],
"additionalProperties": false
}
},
- "__schema18": {
+ "__schema23": {
"type": "string"
},
- "__schema19": {
+ "__schema24": {
"type": "array",
"items": {
"type": "object",
@@ -362,7 +454,7 @@
"description": "If true, this field will be excluded from generation",
"allOf": [
{
- "$ref": "#/definitions/__schema20"
+ "$ref": "#/definitions/__schema25"
}
]
},
@@ -370,19 +462,21 @@
"description": "Override the inferred field type from metadata. Options: text, number, boolean, date, timestamp, container, list",
"allOf": [
{
- "$ref": "#/definitions/__schema21"
+ "$ref": "#/definitions/__schema26"
}
]
}
},
- "required": ["fieldName"],
+ "required": [
+ "fieldName"
+ ],
"additionalProperties": false
}
},
- "__schema20": {
+ "__schema25": {
"type": "boolean"
},
- "__schema21": {
+ "__schema26": {
"type": "string",
"enum": [
"text",
@@ -394,16 +488,16 @@
"list"
]
},
- "__schema22": {
+ "__schema27": {
"type": "boolean"
},
- "__schema23": {
+ "__schema28": {
"type": "boolean"
},
- "__schema24": {
+ "__schema29": {
"type": "boolean"
},
- "__schema25": {
+ "__schema30": {
"default": true,
"type": "boolean"
}