diff --git a/apps/docs/content/docs/fmodata/cli.mdx b/apps/docs/content/docs/fmodata/cli.mdx new file mode 100644 index 00000000..629929d7 --- /dev/null +++ b/apps/docs/content/docs/fmodata/cli.mdx @@ -0,0 +1,469 @@ +--- +title: CLI +description: Run fmodata operations from the command line — queries, scripts, webhooks, metadata, and schema changes +--- + +import { Callout } from "fumadocs-ui/components/callout"; +import { Tab, Tabs } from "fumadocs-ui/components/tabs"; + +The `@proofkit/fmodata` package ships a built-in CLI binary called **`fmodata`**. It exposes every library operation — querying records, running scripts, managing webhooks, inspecting metadata, and modifying schema — as a non-interactive command suitable for scripting, CI pipelines, and quick one-off database operations. + +## Installation + +The binary is included automatically when you install the package: + +```bash +pnpm add @proofkit/fmodata +# or +npm install @proofkit/fmodata +``` + +If you want it available globally: + +```bash +pnpm add -g @proofkit/fmodata +``` + +## Connection Configuration + +All commands share the same global connection options. Each flag has an environment variable fallback so you can set credentials once and run many commands. + +| Flag | Env var | Description | +|---|---|---| +| `--server ` | `FM_SERVER` | FileMaker Server URL (e.g. `https://fm.example.com`) | +| `--database ` | `FM_DATABASE` | Database filename (e.g. `MyApp.fmp12`) | +| `--username ` | `FM_USERNAME` | FileMaker account username | +| `--password ` | `FM_PASSWORD` | FileMaker account password | +| `--api-key ` | `OTTO_API_KEY` | OttoFMS API key (preferred over username/password) | + + +When both `--api-key` and `--username` are present, the API key is used. If an API key is present, missing `FM_PASSWORD` does not block authentication. + + +**Example — using environment variables:** + +```bash +export FM_SERVER=https://fm.example.com +export FM_DATABASE=MyApp.fmp12 +export OTTO_API_KEY=otto_... + +fmodata metadata tables +``` + +**Example — passing flags directly:** + +```bash +fmodata --server https://fm.example.com \ + --database MyApp.fmp12 \ + --api-key otto_... \ + metadata tables +``` + +## Output Formats + +By default all commands print **JSON** to stdout. Add `--pretty` to render results as a human-readable ASCII table instead. + +```bash +# JSON (default) +fmodata metadata tables + +# ASCII table +fmodata metadata tables --pretty +``` + +Errors are written to **stderr** and the process exits with code `1`. + +--- + +## Commands + +### `records` + +CRUD operations against any table. + +#### `records list` + +Fetch records from a table. + +```bash +fmodata records list --table contacts +fmodata records list --table contacts --top 10 --skip 20 +fmodata records list --table contacts --select "name,email" +fmodata records list --table contacts --where "name eq 'Alice'" +fmodata records list --table contacts --order-by "name:asc" +fmodata records list --table contacts --order-by "createdAt:desc,name:asc" +``` + +| Option | Description | +|---|---| +| `--table ` | **Required.** Table to query | +| `--top ` | Maximum records to return | +| `--skip ` | Records to skip (for pagination) | +| `--select ` | Comma-separated field names | +| `--where ` | OData `$filter` expression | +| `--order-by ` | `field:asc` or `field:desc`, comma-separated for multi-sort | + + +Values passed to `--select`, `--where`, and `--order-by` are URL-encoded by the CLI before sending the request. + + +#### `records insert` + +Insert a single record. + +```bash +fmodata records insert --table contacts --data '{"name":"Alice","email":"alice@example.com"}' +``` + +| Option | Description | +|---|---| +| `--table ` | **Required.** Target table | +| `--data ` | **Required.** Record fields as a JSON object | + +#### `records update` + +Update records matching a filter (or all records if `--where` is omitted). + +```bash +fmodata records update \ + --table contacts \ + --data '{"status":"inactive"}' \ + --where "lastLogin lt 2024-01-01" +``` + +| Option | Description | +|---|---| +| `--table ` | **Required.** Target table | +| `--data ` | **Required.** Fields to update as a JSON object | +| `--where ` | OData `$filter` expression (omit to update all rows) | + +#### `records delete` + +Delete records matching a filter. + +```bash +fmodata records delete --table contacts --where "status eq 'archived'" +``` + +| Option | Description | +|---|---| +| `--table ` | **Required.** Target table | +| `--where ` | OData `$filter` expression | + + +Omitting `--where` from `query delete` will delete **all records** in the table. + + +--- + +### `script` + +#### `script run` + +Execute a FileMaker script and print the result code and return value. + +```bash +# No parameter +fmodata script run MyScriptName + +# String parameter +fmodata script run SendEmail --param '"hello@example.com"' + +# JSON object parameter +fmodata script run ProcessOrder --param '{"orderId":"123","action":"approve"}' +``` + +| Option | Description | +|---|---| +| `--param ` | Script parameter — parsed as JSON, falls back to plain string | + +The output is a JSON object: + +```json +{ + "resultCode": 0, + "result": "optional-return-value" +} +``` + + +OData does not support script names with special characters (`@`, `&`, `/`) or names beginning with a number. + + +--- + +### `webhook` + +#### `webhook list` + +List all webhooks registered on the database. + +```bash +fmodata webhook list +fmodata webhook list --pretty +``` + +#### `webhook get` + +Get details for a specific webhook by its numeric ID. + +```bash +fmodata webhook get 42 +``` + +#### `webhook add` + +Register a new webhook on a table. + +```bash +fmodata webhook add \ + --table contacts \ + --url https://example.com/hooks/contacts + +# With field selection and custom headers +fmodata webhook add \ + --table contacts \ + --url https://example.com/hooks/contacts \ + --select "name,email,modifiedAt" \ + --header "Authorization=Bearer token123" \ + --header "X-App-ID=my-app" +``` + +| Option | Description | +|---|---| +| `--table ` | **Required.** Table to monitor | +| `--url ` | **Required.** Webhook endpoint URL | +| `--select ` | Comma-separated field names to include in the payload | +| `--header ` | Custom request header in `key=value` format (repeatable) | + +#### `webhook remove` + +Delete a webhook by ID. + +```bash +fmodata webhook remove 42 +``` + +--- + +### `metadata` + +#### `metadata get` + +Retrieve OData metadata for the database. + +```bash +# JSON (default) +fmodata metadata get + +# XML +fmodata metadata get --format xml +``` + +| Option | Description | +|---|---| +| `--format ` | `json` (default) or `xml` | + +#### `metadata tables` + +List all table names in the database. This is the quickest way to inspect what's available. + +```bash +fmodata metadata tables +fmodata metadata tables --pretty +``` + +--- + +### `schema` + +Schema modification commands are **safe by default**: without `--confirm` they perform a **dry run** and print what _would_ happen without making any changes. + +#### `schema list-tables` + +List all tables (alias for `metadata tables`). + +```bash +fmodata schema list-tables +``` + +#### `schema create-table` + +Create a new table. The `--fields` option accepts the same JSON field definition used by the TypeScript API. + +```bash +# Dry run (no changes) +fmodata schema create-table \ + --name NewTable \ + --fields '[{"name":"id","type":"string","primary":true},{"name":"label","type":"string"}]' + +# Execute for real +fmodata schema create-table \ + --name NewTable \ + --fields '[{"name":"id","type":"string","primary":true},{"name":"label","type":"string"}]' \ + --confirm +``` + +| Option | Description | +|---|---| +| `--name ` | **Required.** New table name | +| `--fields ` | **Required.** Array of field definitions (see [Schema Management](/docs/fmodata/schema-management)) | +| `--confirm` | Execute the operation (without this flag it's a dry run) | + +#### `schema add-fields` + +Add fields to an existing table. + +```bash +# Dry run +fmodata schema add-fields \ + --table contacts \ + --fields '[{"name":"phone","type":"string","nullable":true}]' + +# Execute +fmodata schema add-fields \ + --table contacts \ + --fields '[{"name":"phone","type":"string","nullable":true}]' \ + --confirm +``` + +| Option | Description | +|---|---| +| `--table ` | **Required.** Existing table name | +| `--fields ` | **Required.** Array of field definitions | +| `--confirm` | Execute the operation (without this flag it's a dry run) | + + +Creating tables and adding fields require a FileMaker account with DDL (Data Definition Language) privileges. Operations will throw an error if the account lacks sufficient permissions. + + +--- + +## Using in CI / Scripts + +Because all connection options accept environment variables, the CLI integrates cleanly into CI pipelines: + +```bash +# GitHub Actions example +- name: Run post-deploy script + env: + FM_SERVER: ${{ secrets.FM_SERVER }} + FM_DATABASE: ${{ secrets.FM_DATABASE }} + OTTO_API_KEY: ${{ secrets.OTTO_API_KEY }} + run: | + npx fmodata script run PostDeploy --param '"${{ github.sha }}"' +``` + +```bash +# Quick schema check in a shell script +#!/usr/bin/env bash +set -euo pipefail + +TABLES=$(fmodata metadata tables) +echo "Tables in $FM_DATABASE: $TABLES" +``` + +--- + +## Using with an AI Agent + +Because `fmodata` is a standard shell tool that reads from environment variables and writes JSON to stdout, it works as a natural tool for AI coding agents (Claude Code, Claude Desktop with MCP, custom agents built on the Claude API, etc.). + +### Claude Code + +If your project has `@proofkit/fmodata` installed, Claude Code can run `fmodata` commands directly in the terminal during a conversation. Set your credentials in the environment first: + +```bash +export FM_SERVER=https://fm.example.com +export FM_DATABASE=MyApp.fmp12 +export OTTO_API_KEY=otto_... +``` + +Then just describe what you want in plain language: + +> "List the first 5 records from the `contacts` table and show me which fields are available." + +Claude will run something like: + +```bash +fmodata records list --table contacts --top 5 +``` + +…and use the JSON output to reason about the schema and answer your question. + +### MCP Tool Server + +You can expose `fmodata` as a set of MCP tools so any MCP-compatible host (Claude Desktop, IDEs, custom agents) can call FileMaker operations directly. Create an MCP server that shells out to the CLI: + +```typescript +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { execFileSync } from "node:child_process"; +import { z } from "zod"; + +const server = new McpServer({ name: "fmodata", version: "1.0.0" }); + +server.tool( + "query_list", + "List records from a FileMaker table", + { + table: z.string().describe("Table name"), + where: z.string().optional().describe("OData $filter expression"), + top: z.number().optional().describe("Max records to return"), + }, + async ({ table, where, top }) => { + const args = ["records", "list", "--table", table]; + if (where) args.push("--where", where); + if (top) args.push("--top", String(top)); + + const output = execFileSync("fmodata", args, { encoding: "utf8" }); + return { content: [{ type: "text", text: output }] }; + }, +); + +server.tool( + "run_script", + "Execute a FileMaker script", + { + scriptName: z.string(), + param: z.string().optional().describe("Script parameter as JSON"), + }, + async ({ scriptName, param }) => { + const args = ["script", "run", scriptName]; + if (param) args.push("--param", param); + + const output = execFileSync("fmodata", args, { encoding: "utf8" }); + return { content: [{ type: "text", text: output }] }; + }, +); +``` + + +Each `fmodata` command is atomic, stateless, and returns clean JSON — exactly the shape MCP tools expect. The agent never needs to understand OData internals; it just passes field names and filter strings as arguments. + + +### Giving an Agent Context + +The more context an agent has about your database, the more useful it can be. Provide a brief schema description alongside the CLI: + +``` +You have access to the `fmodata` CLI connected to our production FileMaker database. + +Key tables: +- contacts (name, email, phone, status, id_company) +- companies (name, industry, arr, id_owner) +- deals (title, stage, amount, close_date, id_contact) + +Use `fmodata metadata tables` to list all tables. +Use `fmodata records list --table --top 1` to inspect a table's fields. +OData filter syntax: eq, ne, lt, gt, contains(), startswith() +``` + +### Safety Considerations + + +Grant agents only the permissions they need: + +- **Read-only tasks**: restrict to `records list`, `metadata get/tables` +- **Automation tasks**: allow `records insert/update` and `script run` +- **Schema changes**: keep `schema create-table` and `schema add-fields` behind a human approval step — the `--confirm` flag exists specifically to make this easy to enforce + diff --git a/apps/docs/content/docs/fmodata/meta.json b/apps/docs/content/docs/fmodata/meta.json index eb29546d..4b79ab69 100644 --- a/apps/docs/content/docs/fmodata/meta.json +++ b/apps/docs/content/docs/fmodata/meta.json @@ -20,6 +20,8 @@ "entity-ids", "extra-properties", "custom-fetch-handlers", + "---CLI---", + "cli", "---Reference---", "errors", "methods" diff --git a/packages/fmodata/package.json b/packages/fmodata/package.json index 64296198..608e91a5 100644 --- a/packages/fmodata/package.json +++ b/packages/fmodata/package.json @@ -9,6 +9,9 @@ "type": "module", "main": "./dist/esm/index.js", "types": "./dist/esm/index.d.ts", + "bin": { + "fmodata": "dist/cli/index.js" + }, "exports": { ".": { "import": { @@ -19,7 +22,7 @@ "./package.json": "./package.json" }, "scripts": { - "build": "tsc && vite build && publint --strict", + "build": "tsc && vite build && tsdown && publint --strict", "build:watch": "tsc && vite build --watch", "lint": "biome check . --write", "lint:summary": "biome check . --reporter=summary", @@ -31,6 +34,7 @@ "test:build": "pnpm build && TEST_BUILD=true vitest run --typecheck", "test:watch:build": "TEST_BUILD=true vitest --typecheck", "test:e2e": "doppler run -- vitest run tests/e2e", + "test:cli:e2e": "doppler run -- vitest run tests/cli/e2e", "capture": "doppler run -- tsx scripts/capture-responses.ts", "knip": "knip", "pub:alpha": "bun run scripts/publish-alpha.ts", @@ -38,6 +42,8 @@ }, "dependencies": { "@fetchkit/ffetch": "^4.2.0", + "cli-table3": "^0.6.5", + "commander": "^14.0.2", "dotenv": "^16.6.1", "es-toolkit": "^1.43.0", "neverthrow": "^8.2.0", @@ -57,6 +63,7 @@ "@types/node": "^22.19.5", "fast-xml-parser": "^5.3.3", "publint": "^0.3.16", + "tsdown": "^0.14.2", "tsx": "^4.21.0", "typescript": "^5.9.3", "vite": "^6.4.1", diff --git a/packages/fmodata/src/cli/commands/metadata.ts b/packages/fmodata/src/cli/commands/metadata.ts new file mode 100644 index 00000000..21bb0c00 --- /dev/null +++ b/packages/fmodata/src/cli/commands/metadata.ts @@ -0,0 +1,45 @@ +import { Command } from "commander"; +import type { ConnectionOptions } from "../utils/connection"; +import { buildConnection } from "../utils/connection"; +import { handleCliError } from "../utils/errors"; +import { printResult } from "../utils/output"; + +export function makeMetadataCommand(): Command { + const metadata = new Command("metadata").description("FileMaker OData metadata operations"); + + metadata + .command("get") + .description("Get OData metadata for the database") + .option("--format ", "Output format: json or xml", "json") + .action(async (opts, cmd) => { + const globalOpts = cmd.parent?.parent?.opts() as ConnectionOptions & { pretty: boolean }; + try { + const { db } = buildConnection(globalOpts); + let result: unknown; + if (opts.format === "xml") { + result = await db.getMetadata({ format: "xml" }); + } else { + result = await db.getMetadata({ format: "json" }); + } + printResult(result, { pretty: globalOpts.pretty ?? false }); + } catch (err) { + handleCliError(err); + } + }); + + metadata + .command("tables") + .description("List all table names in the database") + .action(async (_opts, cmd) => { + const globalOpts = cmd.parent?.parent?.opts() as ConnectionOptions & { pretty: boolean }; + try { + const { db } = buildConnection(globalOpts); + const tables = await db.listTableNames(); + printResult(tables, { pretty: globalOpts.pretty ?? false }); + } catch (err) { + handleCliError(err); + } + }); + + return metadata; +} diff --git a/packages/fmodata/src/cli/commands/query.ts b/packages/fmodata/src/cli/commands/query.ts new file mode 100644 index 00000000..7f620977 --- /dev/null +++ b/packages/fmodata/src/cli/commands/query.ts @@ -0,0 +1,155 @@ +import { Command } from "commander"; +import type { ConnectionOptions } from "../utils/connection"; +import { buildConnection } from "../utils/connection"; +import { handleCliError } from "../utils/errors"; +import { printResult } from "../utils/output"; + +function buildQueryString(params: { + top?: number; + skip?: number; + select?: string; + where?: string; + orderBy?: string; +}): string { + const parts: string[] = []; + if (params.top !== undefined) { + parts.push(`$top=${encodeURIComponent(String(params.top))}`); + } + if (params.skip !== undefined) { + parts.push(`$skip=${encodeURIComponent(String(params.skip))}`); + } + if (params.select) { + parts.push(`$select=${encodeURIComponent(params.select)}`); + } + if (params.where) { + parts.push(`$filter=${encodeURIComponent(params.where)}`); + } + if (params.orderBy) { + // Accept "field:asc" or "field:desc" or plain "field" + const orderStr = params.orderBy + .split(",") + .map((part) => { + const [field, dir] = part.trim().split(":"); + return dir ? `${field} ${dir}` : field; + }) + .join(","); + parts.push(`$orderby=${encodeURIComponent(orderStr)}`); + } + return parts.length > 0 ? `?${parts.join("&")}` : ""; +} + +export function makeRecordsCommand(): Command { + const query = new Command("records").description("FileMaker record operations (list, insert, update, delete)"); + + query + .command("list") + .description("List records from a table") + .requiredOption("--table ", "Table name") + .option("--top ", "Max records to return", Number) + .option("--skip ", "Records to skip", Number) + .option("--select ", "Comma-separated field names") + .option("--where ", "OData filter expression") + .option("--order-by ", "Order by field (format: field:asc|desc, or comma-separated)") + .action(async (opts, cmd) => { + const globalOpts = cmd.parent?.parent?.opts() as ConnectionOptions & { pretty: boolean }; + try { + const { db } = buildConnection(globalOpts); + const qs = buildQueryString({ + top: opts.top as number | undefined, + skip: opts.skip as number | undefined, + select: opts.select as string | undefined, + where: opts.where as string | undefined, + orderBy: opts.orderBy as string | undefined, + }); + const result = await db._makeRequest<{ value: unknown[] }>(`/${opts.table}${qs}`); + if (result.error) { + throw result.error; + } + printResult(result.data.value ?? result.data, { pretty: globalOpts.pretty ?? false }); + } catch (err) { + handleCliError(err); + } + }); + + query + .command("insert") + .description("Insert a record into a table") + .requiredOption("--table ", "Table name") + .requiredOption("--data ", "Record data as JSON object") + .action(async (opts, cmd) => { + const globalOpts = cmd.parent?.parent?.opts() as ConnectionOptions & { pretty: boolean }; + try { + const { db } = buildConnection(globalOpts); + let data: Record; + try { + data = JSON.parse(opts.data) as Record; + } catch { + throw new Error("--data must be a valid JSON object"); + } + const result = await db._makeRequest(`/${opts.table}`, { + method: "POST", + body: JSON.stringify(data), + }); + if (result.error) { + throw result.error; + } + printResult(result.data, { pretty: globalOpts.pretty ?? false }); + } catch (err) { + handleCliError(err); + } + }); + + query + .command("update") + .description("Update records in a table") + .requiredOption("--table ", "Table name") + .requiredOption("--data ", "Update data as JSON object") + .option("--where ", "OData filter expression") + .action(async (opts, cmd) => { + const globalOpts = cmd.parent?.parent?.opts() as ConnectionOptions & { pretty: boolean }; + try { + const { db } = buildConnection(globalOpts); + let data: Record; + try { + data = JSON.parse(opts.data) as Record; + } catch { + throw new Error("--data must be a valid JSON object"); + } + const qs = buildQueryString({ where: opts.where as string | undefined }); + const result = await db._makeRequest(`/${opts.table}${qs}`, { + method: "PATCH", + body: JSON.stringify(data), + }); + if (result.error) { + throw result.error; + } + printResult(result.data, { pretty: globalOpts.pretty ?? false }); + } catch (err) { + handleCliError(err); + } + }); + + query + .command("delete") + .description("Delete records from a table") + .requiredOption("--table ", "Table name") + .option("--where ", "OData filter expression") + .action(async (opts, cmd) => { + const globalOpts = cmd.parent?.parent?.opts() as ConnectionOptions & { pretty: boolean }; + try { + const { db } = buildConnection(globalOpts); + const qs = buildQueryString({ where: opts.where as string | undefined }); + const result = await db._makeRequest(`/${opts.table}${qs}`, { + method: "DELETE", + }); + if (result.error) { + throw result.error; + } + printResult(result.data, { pretty: globalOpts.pretty ?? false }); + } catch (err) { + handleCliError(err); + } + }); + + return query; +} diff --git a/packages/fmodata/src/cli/commands/schema.ts b/packages/fmodata/src/cli/commands/schema.ts new file mode 100644 index 00000000..a5d055d5 --- /dev/null +++ b/packages/fmodata/src/cli/commands/schema.ts @@ -0,0 +1,86 @@ +import { Command } from "commander"; +import type { Field } from "../../client/schema-manager"; +import type { ConnectionOptions } from "../utils/connection"; +import { buildConnection } from "../utils/connection"; +import { handleCliError } from "../utils/errors"; +import { printResult } from "../utils/output"; + +export function makeSchemaCommand(): Command { + const schema = new Command("schema").description("FileMaker schema modification operations"); + + schema + .command("list-tables") + .description("List all tables in the database") + .action(async (_opts, cmd) => { + const globalOpts = cmd.parent?.parent?.opts() as ConnectionOptions & { pretty: boolean }; + try { + const { db } = buildConnection(globalOpts); + const tables = await db.listTableNames(); + printResult(tables, { pretty: globalOpts.pretty ?? false }); + } catch (err) { + handleCliError(err); + } + }); + + schema + .command("create-table") + .description("Create a new table (requires --confirm to execute; dry-run by default)") + .requiredOption("--name ", "Table name") + .requiredOption("--fields ", "Fields definition as JSON array") + .option("--confirm", "Execute the operation (without this flag, shows what would be created)") + .action(async (opts, cmd) => { + const globalOpts = cmd.parent?.parent?.opts() as ConnectionOptions & { pretty: boolean }; + try { + let fields: Field[]; + try { + fields = JSON.parse(opts.fields) as Field[]; + } catch { + throw new Error("--fields must be a valid JSON array"); + } + + if (!opts.confirm) { + console.log("[dry-run] Would create table:"); + printResult({ tableName: opts.name, fields }, { pretty: globalOpts.pretty ?? false }); + return; + } + + const { db } = buildConnection(globalOpts); + const result = await db.schema.createTable(opts.name as string, fields); + printResult(result, { pretty: globalOpts.pretty ?? false }); + } catch (err) { + handleCliError(err); + } + }); + + schema + .command("add-fields") + .description("Add fields to an existing table (requires --confirm to execute; dry-run by default)") + .requiredOption("--table ", "Table name") + .requiredOption("--fields ", "Fields to add as JSON array") + .option("--confirm", "Execute the operation (without this flag, shows what would be added)") + .action(async (opts, cmd) => { + const globalOpts = cmd.parent?.parent?.opts() as ConnectionOptions & { pretty: boolean }; + try { + let fields: Field[]; + try { + fields = JSON.parse(opts.fields) as Field[]; + } catch { + throw new Error("--fields must be a valid JSON array"); + } + + if (!opts.confirm) { + console.log("[dry-run] Would add fields to table:"); + printResult({ tableName: opts.table, fields }, { pretty: globalOpts.pretty ?? false }); + return; + } + + const { db } = buildConnection(globalOpts); + const result = await db.schema.addFields(opts.table as string, fields); + printResult(result, { pretty: globalOpts.pretty ?? false }); + } catch (err) { + handleCliError(err); + } + }); + + return schema; +} diff --git a/packages/fmodata/src/cli/commands/script.ts b/packages/fmodata/src/cli/commands/script.ts new file mode 100644 index 00000000..56e5e768 --- /dev/null +++ b/packages/fmodata/src/cli/commands/script.ts @@ -0,0 +1,34 @@ +import { Command } from "commander"; +import type { ConnectionOptions } from "../utils/connection"; +import { buildConnection } from "../utils/connection"; +import { handleCliError } from "../utils/errors"; +import { printResult } from "../utils/output"; + +export function makeScriptCommand(): Command { + const script = new Command("script").description("FileMaker script operations"); + + script + .command("run ") + .description("Run a FileMaker script") + .option("--param ", "Script parameter as JSON string or plain value") + .action(async (scriptName: string, opts, cmd) => { + const globalOpts = cmd.parent?.parent?.opts() as ConnectionOptions & { pretty: boolean }; + try { + const { db } = buildConnection(globalOpts); + let scriptParam: string | number | Record | undefined; + if (opts.param !== undefined) { + try { + scriptParam = JSON.parse(opts.param) as Record; + } catch { + scriptParam = opts.param as string; + } + } + const result = await db.runScript(scriptName, { scriptParam }); + printResult(result, { pretty: globalOpts.pretty ?? false }); + } catch (err) { + handleCliError(err); + } + }); + + return script; +} diff --git a/packages/fmodata/src/cli/commands/webhook.ts b/packages/fmodata/src/cli/commands/webhook.ts new file mode 100644 index 00000000..e607a93a --- /dev/null +++ b/packages/fmodata/src/cli/commands/webhook.ts @@ -0,0 +1,103 @@ +import { Command } from "commander"; +import type { ConnectionOptions } from "../utils/connection"; +import { buildConnection } from "../utils/connection"; +import { handleCliError } from "../utils/errors"; +import { printResult } from "../utils/output"; + +export function makeWebhookCommand(): Command { + const webhook = new Command("webhook").description("FileMaker webhook operations"); + + webhook + .command("list") + .description("List all webhooks") + .action(async (_opts, cmd) => { + const globalOpts = cmd.parent?.parent?.opts() as ConnectionOptions & { pretty: boolean }; + try { + const { db } = buildConnection(globalOpts); + const result = await db.webhook.list(); + printResult(result, { pretty: globalOpts.pretty ?? false }); + } catch (err) { + handleCliError(err); + } + }); + + webhook + .command("get ") + .description("Get a webhook by ID") + .action(async (id: string, _opts, cmd) => { + const globalOpts = cmd.parent?.parent?.opts() as ConnectionOptions & { pretty: boolean }; + try { + const { db } = buildConnection(globalOpts); + const result = await db.webhook.get(Number(id)); + printResult(result, { pretty: globalOpts.pretty ?? false }); + } catch (err) { + handleCliError(err); + } + }); + + webhook + .command("add") + .description("Add a new webhook") + .requiredOption("--table ", "Table to monitor") + .requiredOption("--url ", "Webhook URL to call") + .option("--select ", "Comma-separated field names to include") + .option("--header ", "Header in key=value format (repeatable)", (val, acc: string[]) => { + acc.push(val); + return acc; + }, []) + .action(async (opts, cmd) => { + const globalOpts = cmd.parent?.parent?.opts() as ConnectionOptions & { pretty: boolean }; + try { + const { db } = buildConnection(globalOpts); + + // Parse headers + const headers: Record = {}; + for (const h of opts.header as string[]) { + const eqIdx = h.indexOf("="); + if (eqIdx === -1) { + throw new Error(`Invalid header format (expected key=value): ${h}`); + } + headers[h.slice(0, eqIdx)] = h.slice(eqIdx + 1); + } + + // Build a minimal FMTable-like proxy for the tableName + // webhook.add() only reads the name via Symbol, so this is safe at runtime + const tableProxy = { + [Symbol.for("fmodata:FMTableName")]: opts.table, + } as unknown as import("../../orm/table").FMTable, string>; + + const webhookPayload: import("../../client/webhook-builder").Webhook = { + webhook: opts.url as string, + tableName: tableProxy, + }; + + if (Object.keys(headers).length > 0) { + webhookPayload.headers = headers; + } + if (opts.select) { + webhookPayload.select = opts.select as string; + } + + const result = await db.webhook.add(webhookPayload); + printResult(result, { pretty: globalOpts.pretty ?? false }); + } catch (err) { + handleCliError(err); + } + }); + + webhook + .command("remove ") + .description("Remove a webhook by ID") + .action(async (id: string, _opts, cmd) => { + const globalOpts = cmd.parent?.parent?.opts() as ConnectionOptions & { pretty: boolean }; + try { + const { db } = buildConnection(globalOpts); + await db.webhook.remove(Number(id)); + printResult({ removed: true, id: Number(id) }, { pretty: globalOpts.pretty ?? false }); + } catch (err) { + handleCliError(err); + } + }); + + return webhook; +} diff --git a/packages/fmodata/src/cli/index.ts b/packages/fmodata/src/cli/index.ts new file mode 100644 index 00000000..e55fa83b --- /dev/null +++ b/packages/fmodata/src/cli/index.ts @@ -0,0 +1,42 @@ +import { Command } from "commander"; +import { makeMetadataCommand } from "./commands/metadata"; +import { makeRecordsCommand } from "./commands/query"; +import { makeSchemaCommand } from "./commands/schema"; +import { makeScriptCommand } from "./commands/script"; +import { makeWebhookCommand } from "./commands/webhook"; +import { handleCliError } from "./utils/errors"; +import { ENV_NAMES } from "./utils/connection"; + +const program = new Command(); + +program + .name("fmodata") + .description("FileMaker OData CLI — query, script, webhook, metadata, and schema operations") + .version("0.1.0") + .option("--server ", `FM server URL [env: ${ENV_NAMES.server}]`) + .option("--database ", `FM database name [env: ${ENV_NAMES.db}]`) + .option("--username ", `FM username [env: ${ENV_NAMES.username}]`) + .option("--password ", `FM password [env: ${ENV_NAMES.password}]`) + .option("--api-key ", `OttoFMS API key [env: ${ENV_NAMES.apiKey}]`) + .option("--pretty", "Output as table (default: JSON)", false); + +program.addCommand(makeRecordsCommand()); +program.addCommand(makeScriptCommand()); +program.addCommand(makeWebhookCommand()); +program.addCommand(makeMetadataCommand()); +program.addCommand(makeSchemaCommand()); + +program.exitOverride(); + +try { + await program.parseAsync(process.argv); +} catch (err) { + // Commander throws CommanderError for --help/--version exits (non-zero but expected) + if (err && typeof err === "object" && "code" in err) { + const code = (err as { code: string }).code; + if (code === "commander.helpDisplayed" || code === "commander.version") { + process.exit(0); + } + } + handleCliError(err); +} diff --git a/packages/fmodata/src/cli/utils/connection.ts b/packages/fmodata/src/cli/utils/connection.ts new file mode 100644 index 00000000..b7c74685 --- /dev/null +++ b/packages/fmodata/src/cli/utils/connection.ts @@ -0,0 +1,49 @@ +import type { Database } from "../../client/database"; +import { FMServerConnection } from "../../client/filemaker-odata"; + +export const ENV_NAMES = { + server: "FM_SERVER", + db: "FM_DATABASE", + username: "FM_USERNAME", + password: "FM_PASSWORD", + apiKey: "OTTO_API_KEY", +} as const; + +export interface ConnectionOptions { + server?: string; + database?: string; + username?: string; + password?: string; + apiKey?: string; +} + +export interface BuiltConnection { + connection: FMServerConnection; + db: Database; +} + +export function buildConnection(opts: ConnectionOptions): BuiltConnection { + const server = opts.server ?? process.env[ENV_NAMES.server]; + const database = opts.database ?? process.env[ENV_NAMES.db]; + const apiKey = opts.apiKey ?? process.env[ENV_NAMES.apiKey]; + const username = opts.username ?? process.env[ENV_NAMES.username]; + const password = opts.password ?? process.env[ENV_NAMES.password]; + + if (!server) { + throw new Error(`Missing required: --server or ${ENV_NAMES.server} environment variable`); + } + if (!database) { + throw new Error(`Missing required: --database or ${ENV_NAMES.db} environment variable`); + } + if (!(apiKey || username)) { + throw new Error(`Missing required auth: --api-key (${ENV_NAMES.apiKey}) or --username (${ENV_NAMES.username})`); + } + if (!apiKey && username && !password) { + throw new Error(`Missing required: --password (${ENV_NAMES.password}) when using username auth`); + } + + const auth = apiKey ? { apiKey } : { username: username as string, password: password as string }; + const connection = new FMServerConnection({ serverUrl: server, auth }); + const db = connection.database(database); + return { connection, db }; +} diff --git a/packages/fmodata/src/cli/utils/errors.ts b/packages/fmodata/src/cli/utils/errors.ts new file mode 100644 index 00000000..13b903e1 --- /dev/null +++ b/packages/fmodata/src/cli/utils/errors.ts @@ -0,0 +1,5 @@ +export function handleCliError(err: unknown): never { + const message = err instanceof Error ? err.message : String(err); + process.stderr.write(`Error: ${message}\n`); + process.exit(1); +} diff --git a/packages/fmodata/src/cli/utils/output.ts b/packages/fmodata/src/cli/utils/output.ts new file mode 100644 index 00000000..98099006 --- /dev/null +++ b/packages/fmodata/src/cli/utils/output.ts @@ -0,0 +1,45 @@ +import Table from "cli-table3"; + +export interface OutputOptions { + pretty: boolean; +} + +export function printResult(data: unknown, opts: OutputOptions): void { + if (opts.pretty) { + printTable(data); + } else { + console.log(JSON.stringify(data, null, 2)); + } +} + +function printTable(data: unknown): void { + // Array of objects — render as rows + if (Array.isArray(data) && data.length > 0 && typeof data[0] === "object" && data[0] !== null) { + const keys = Object.keys(data[0] as Record); + const table = new Table({ head: keys }); + for (const row of data) { + table.push(keys.map((k) => String((row as Record)[k] ?? ""))); + } + console.log(table.toString()); + return; + } + + // Single object — render as key-value pairs + if (typeof data === "object" && data !== null && !Array.isArray(data)) { + const table = new Table({ head: ["Key", "Value"] }); + for (const [key, value] of Object.entries(data as Record)) { + table.push([key, typeof value === "object" ? JSON.stringify(value) : String(value ?? "")]); + } + console.log(table.toString()); + return; + } + + // Fallback — just print as JSON + console.log(JSON.stringify(data, null, 2)); +} + +export function printError(err: unknown): void { + const message = err instanceof Error ? err.message : String(err); + process.stderr.write(`Error: ${message}\n`); + process.exit(1); +} diff --git a/packages/fmodata/tests/cli/commands/metadata.test.ts b/packages/fmodata/tests/cli/commands/metadata.test.ts new file mode 100644 index 00000000..09b993fa --- /dev/null +++ b/packages/fmodata/tests/cli/commands/metadata.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { FMServerConnection } from "../../../src/client/filemaker-odata"; +import { simpleMock } from "../../utils/mock-fetch"; + +function createTestDb(mockFetch?: typeof fetch) { + const connection = new FMServerConnection({ + serverUrl: "https://api.example.com", + auth: { apiKey: "test-key" }, + ...(mockFetch ? { fetchClientOptions: { fetchHandler: mockFetch } } : {}), + }); + return connection.database("TestDB.fmp12"); +} + +describe("metadata commands (unit)", () => { + it("listTableNames method exists", () => { + const db = createTestDb(); + expect(typeof db.listTableNames).toBe("function"); + }); + + it("listTableNames parses value array correctly via raw request", async () => { + const db = createTestDb(); + const mockFetch = simpleMock({ + status: 200, + body: { value: [{ name: "contacts" }, { name: "users" }] }, + }); + + const result = await db._makeRequest<{ value: Array<{ name: string }> }>("/", { + fetchHandler: mockFetch, + }); + expect(result.error).toBeUndefined(); + expect(result.data?.value).toHaveLength(2); + expect(result.data?.value?.[0]?.name).toBe("contacts"); + }); + + it("listTableNames returns string array", async () => { + const mockFetch = simpleMock({ + status: 200, + body: { value: [{ name: "contacts" }, { name: "users" }] }, + }); + const db = createTestDb(mockFetch); + const tables = await db.listTableNames(); + expect(tables).toEqual(["contacts", "users"]); + }); + + it("getMetadata (json) returns metadata object via raw request", async () => { + const db = createTestDb(); + const mockFetch = simpleMock({ + status: 200, + body: { "TestDB.fmp12": { entityContainer: { entitySets: [] } } }, + }); + + const result = await db._makeRequest>("/$metadata", { + fetchHandler: mockFetch, + headers: { Accept: "application/json" }, + }); + expect(result.error).toBeUndefined(); + expect(result.data).toHaveProperty("TestDB.fmp12"); + }); +}); diff --git a/packages/fmodata/tests/cli/commands/query.test.ts b/packages/fmodata/tests/cli/commands/query.test.ts new file mode 100644 index 00000000..04e3e2a9 --- /dev/null +++ b/packages/fmodata/tests/cli/commands/query.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { FMServerConnection } from "../../../src/client/filemaker-odata"; +import { simpleMock } from "../../utils/mock-fetch"; + +function createTestDb() { + const connection = new FMServerConnection({ + serverUrl: "https://api.example.com", + auth: { apiKey: "test-key" }, + }); + return connection.database("TestDB.fmp12"); +} + +describe("query commands (unit — raw OData requests)", () => { + it("list builds correct URL with $top and $filter", async () => { + const db = createTestDb(); + const mockFetch = simpleMock({ + status: 200, + body: { value: [{ name: "Alice" }] }, + }); + + const result = await db._makeRequest<{ value: unknown[] }>("/contacts?$top=5&$filter=name eq 'Alice'", { + fetchHandler: mockFetch, + }); + expect(result.error).toBeUndefined(); + expect(result.data?.value).toHaveLength(1); + }); + + it("insert sends POST request", async () => { + const db = createTestDb(); + const mockFetch = simpleMock({ + status: 201, + body: { name: "Bob", id: "1" }, + }); + + const result = await db._makeRequest("/contacts", { + method: "POST", + body: JSON.stringify({ name: "Bob" }), + fetchHandler: mockFetch, + }); + expect(result.error).toBeUndefined(); + }); + + it("update sends PATCH request", async () => { + const db = createTestDb(); + const mockFetch = simpleMock({ + status: 200, + body: { updated: 1 }, + }); + + const result = await db._makeRequest("/contacts?$filter=name eq 'Bob'", { + method: "PATCH", + body: JSON.stringify({ name: "Robert" }), + fetchHandler: mockFetch, + }); + expect(result.error).toBeUndefined(); + }); + + it("delete sends DELETE request", async () => { + const db = createTestDb(); + const mockFetch = simpleMock({ + status: 200, + body: null, + headers: { "fmodata.affected_rows": "1" }, + }); + + const result = await db._makeRequest("/contacts?$filter=name eq 'Bob'", { + method: "DELETE", + fetchHandler: mockFetch, + }); + expect(result.error).toBeUndefined(); + expect(result.data).toBe(1); + }); +}); diff --git a/packages/fmodata/tests/cli/commands/schema.test.ts b/packages/fmodata/tests/cli/commands/schema.test.ts new file mode 100644 index 00000000..1930a5e8 --- /dev/null +++ b/packages/fmodata/tests/cli/commands/schema.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { FMServerConnection } from "../../../src/client/filemaker-odata"; +import { simpleMock } from "../../utils/mock-fetch"; + +function createTestDb() { + const connection = new FMServerConnection({ + serverUrl: "https://api.example.com", + auth: { apiKey: "test-key" }, + }); + return connection.database("TestDB.fmp12"); +} + +describe("schema commands (unit)", () => { + it("createTable sends POST to FileMaker_Tables", async () => { + const db = createTestDb(); + const mockFetch = simpleMock({ + status: 200, + body: { tableName: "NewTable", fields: [] }, + }); + + const result = await db.schema.createTable( + "NewTable", + [{ name: "Name", type: "string" }], + { fetchHandler: mockFetch }, + ); + expect(result.tableName).toBe("NewTable"); + }); + + it("addFields sends PATCH to FileMaker_Tables/tableName", async () => { + const db = createTestDb(); + const mockFetch = simpleMock({ + status: 200, + body: { tableName: "contacts", fields: [{ name: "Notes", type: "varchar" }] }, + }); + + const result = await db.schema.addFields( + "contacts", + [{ name: "Notes", type: "string" }], + { fetchHandler: mockFetch }, + ); + expect(result.tableName).toBe("contacts"); + expect(result.fields).toHaveLength(1); + }); + + it("listTableNames (used for schema list-tables) returns table names", async () => { + const db = createTestDb(); + const mockFetch = simpleMock({ + status: 200, + body: { value: [{ name: "contacts" }, { name: "users" }] }, + }); + + const result = await db._makeRequest<{ value: Array<{ name: string }> }>("/", { + fetchHandler: mockFetch, + }); + const names = result.data?.value?.map((t) => t.name) ?? []; + expect(names).toEqual(["contacts", "users"]); + }); +}); diff --git a/packages/fmodata/tests/cli/commands/script.test.ts b/packages/fmodata/tests/cli/commands/script.test.ts new file mode 100644 index 00000000..63edaa14 --- /dev/null +++ b/packages/fmodata/tests/cli/commands/script.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { FMServerConnection } from "../../../src/client/filemaker-odata"; +import { simpleMock } from "../../utils/mock-fetch"; + +describe("script run command (unit)", () => { + it("runs script without param", async () => { + const mockFetch = simpleMock({ + status: 200, + body: { scriptResult: { code: 0, resultParameter: "done" } }, + }); + const connection = new FMServerConnection({ + serverUrl: "https://api.example.com", + auth: { apiKey: "test-key" }, + fetchClientOptions: { fetchHandler: mockFetch }, + }); + const db = connection.database("TestDB.fmp12"); + + const result = await db.runScript("MyScript"); + expect(result.resultCode).toBe(0); + expect(result.result).toBe("done"); + }); + + it("runs script with string param", async () => { + const mockFetch = simpleMock({ + status: 200, + body: { scriptResult: { code: 0, resultParameter: "ok" } }, + }); + const connection = new FMServerConnection({ + serverUrl: "https://api.example.com", + auth: { apiKey: "test-key" }, + fetchClientOptions: { fetchHandler: mockFetch }, + }); + const db = connection.database("TestDB.fmp12"); + + const result = await db.runScript("MyScript", { scriptParam: "hello" }); + expect(result.resultCode).toBe(0); + }); + + it("runs script with object param", async () => { + const mockFetch = simpleMock({ + status: 200, + body: { scriptResult: { code: 0, resultParameter: "ok" } }, + }); + const connection = new FMServerConnection({ + serverUrl: "https://api.example.com", + auth: { apiKey: "test-key" }, + fetchClientOptions: { fetchHandler: mockFetch }, + }); + const db = connection.database("TestDB.fmp12"); + + const result = await db.runScript("MyScript", { scriptParam: { key: "value" } }); + expect(result.resultCode).toBe(0); + }); +}); diff --git a/packages/fmodata/tests/cli/commands/webhook.test.ts b/packages/fmodata/tests/cli/commands/webhook.test.ts new file mode 100644 index 00000000..185f804f --- /dev/null +++ b/packages/fmodata/tests/cli/commands/webhook.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { FMServerConnection } from "../../../src/client/filemaker-odata"; +import { simpleMock } from "../../utils/mock-fetch"; + +function createTestDb() { + const connection = new FMServerConnection({ + serverUrl: "https://api.example.com", + auth: { apiKey: "test-key" }, + }); + return connection.database("TestDB.fmp12"); +} + +describe("webhook commands (unit)", () => { + it("list returns webhook list response", async () => { + const db = createTestDb(); + const mockFetch = simpleMock({ + status: 200, + body: { Status: "OK", WebHook: [{ webHookID: 1, tableName: "contacts", url: "https://example.com" }] }, + }); + + const result = await db.webhook.list({ fetchHandler: mockFetch }); + expect(result.Status).toBe("OK"); + expect(result.WebHook).toHaveLength(1); + expect(result.WebHook[0]?.webHookID).toBe(1); + }); + + it("get returns webhook by id", async () => { + const db = createTestDb(); + const mockFetch = simpleMock({ + status: 200, + body: { webHookID: 42, tableName: "contacts", url: "https://example.com", notifySchemaChanges: false, select: "", filter: "", pendingOperations: [] }, + }); + + const result = await db.webhook.get(42, { fetchHandler: mockFetch }); + expect(result.webHookID).toBe(42); + }); + + it("remove calls delete endpoint", async () => { + const db = createTestDb(); + const mockFetch = simpleMock({ status: 204 }); + + await expect(db.webhook.remove(1, { fetchHandler: mockFetch })).resolves.toBeUndefined(); + }); + + it("add creates webhook with string tableName via proxy", async () => { + const db = createTestDb(); + const mockFetch = simpleMock({ + status: 200, + body: { webHookResult: { webHookID: 99 } }, + }); + + // Test the table proxy approach used in the CLI + const tableProxy = { + [Symbol.for("fmodata:FMTableName")]: "contacts", + } as unknown as import("../../../src/orm/table").FMTable, string>; + + const result = await db.webhook.add( + { + webhook: "https://example.com/hook", + tableName: tableProxy, + }, + { fetchHandler: mockFetch }, + ); + expect(result.webHookResult.webHookID).toBe(99); + }); +}); diff --git a/packages/fmodata/tests/cli/integration/binary.test.ts b/packages/fmodata/tests/cli/integration/binary.test.ts new file mode 100644 index 00000000..5554731d --- /dev/null +++ b/packages/fmodata/tests/cli/integration/binary.test.ts @@ -0,0 +1,50 @@ +import { execSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +const CLI_PATH = resolve(__dirname, "../../../dist/cli/index.js"); + +describe("CLI binary integration", () => { + it("dist/cli/index.js exists after build", () => { + // This test verifies the binary was built. + // If the file doesn't exist, skip with a hint rather than failing hard. + if (!existsSync(CLI_PATH)) { + console.warn("CLI binary not found at", CLI_PATH, "— run `pnpm build` first"); + return; + } + expect(existsSync(CLI_PATH)).toBe(true); + }); + + it("fmodata --help exits 0 and shows usage", () => { + if (!existsSync(CLI_PATH)) { + console.warn("Skipping: CLI binary not built"); + return; + } + const output = execSync(`node ${CLI_PATH} --help`, { encoding: "utf8" }); + expect(output).toContain("fmodata"); + expect(output).toContain("Usage"); + }); + + it("fmodata records --help shows records subcommands", () => { + if (!existsSync(CLI_PATH)) { + console.warn("Skipping: CLI binary not built"); + return; + } + const output = execSync(`node ${CLI_PATH} records --help`, { encoding: "utf8" }); + expect(output).toContain("list"); + expect(output).toContain("insert"); + expect(output).toContain("update"); + expect(output).toContain("delete"); + }); + + it("fmodata metadata --help shows metadata subcommands", () => { + if (!existsSync(CLI_PATH)) { + console.warn("Skipping: CLI binary not built"); + return; + } + const output = execSync(`node ${CLI_PATH} metadata --help`, { encoding: "utf8" }); + expect(output).toContain("get"); + expect(output).toContain("tables"); + }); +}); diff --git a/packages/fmodata/tests/cli/unit/connection.test.ts b/packages/fmodata/tests/cli/unit/connection.test.ts new file mode 100644 index 00000000..4b7e7d6d --- /dev/null +++ b/packages/fmodata/tests/cli/unit/connection.test.ts @@ -0,0 +1,94 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { buildConnection, ENV_NAMES } from "../../../src/cli/utils/connection"; + +describe("buildConnection", () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it("builds a connection from env vars with api key auth", () => { + process.env[ENV_NAMES.server] = "https://example.com"; + process.env[ENV_NAMES.db] = "MyDB.fmp12"; + process.env[ENV_NAMES.apiKey] = "test-key"; + + const { connection, db } = buildConnection({}); + expect(connection).toBeDefined(); + expect(db).toBeDefined(); + }); + + it("builds a connection from env vars with username/password auth", () => { + process.env[ENV_NAMES.server] = "https://example.com"; + process.env[ENV_NAMES.db] = "MyDB.fmp12"; + process.env[ENV_NAMES.username] = "admin"; + process.env[ENV_NAMES.password] = "secret"; + + const { connection, db } = buildConnection({}); + expect(connection).toBeDefined(); + expect(db).toBeDefined(); + }); + + it("CLI options override env vars", () => { + process.env[ENV_NAMES.server] = "https://env-server.com"; + process.env[ENV_NAMES.db] = "EnvDB.fmp12"; + process.env[ENV_NAMES.apiKey] = "env-key"; + + const { db } = buildConnection({ + server: "https://cli-server.com", + database: "CliDB.fmp12", + apiKey: "cli-key", + }); + expect(db).toBeDefined(); + }); + + it("throws when server is missing", () => { + process.env[ENV_NAMES.db] = "MyDB.fmp12"; + process.env[ENV_NAMES.apiKey] = "test-key"; + delete process.env[ENV_NAMES.server]; + + expect(() => buildConnection({})).toThrow(/server/i); + }); + + it("throws when database is missing", () => { + process.env[ENV_NAMES.server] = "https://example.com"; + process.env[ENV_NAMES.apiKey] = "test-key"; + delete process.env[ENV_NAMES.db]; + + expect(() => buildConnection({})).toThrow(/database/i); + }); + + it("throws when auth is missing", () => { + process.env[ENV_NAMES.server] = "https://example.com"; + process.env[ENV_NAMES.db] = "MyDB.fmp12"; + delete process.env[ENV_NAMES.apiKey]; + delete process.env[ENV_NAMES.username]; + delete process.env[ENV_NAMES.password]; + + expect(() => buildConnection({})).toThrow(/auth/i); + }); + + it("throws when username is set but password is missing", () => { + process.env[ENV_NAMES.server] = "https://example.com"; + process.env[ENV_NAMES.db] = "MyDB.fmp12"; + process.env[ENV_NAMES.username] = "admin"; + delete process.env[ENV_NAMES.password]; + + expect(() => buildConnection({})).toThrow(/password/i); + }); + + it("prefers api key over username auth when both are set", () => { + const { db } = buildConnection({ + server: "https://example.com", + database: "MyDB.fmp12", + apiKey: "api-key", + username: "admin", + password: "secret", + }); + expect(db).toBeDefined(); + }); +}); diff --git a/packages/fmodata/tests/cli/unit/output.test.ts b/packages/fmodata/tests/cli/unit/output.test.ts new file mode 100644 index 00000000..3d76575b --- /dev/null +++ b/packages/fmodata/tests/cli/unit/output.test.ts @@ -0,0 +1,65 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { printError, printResult } from "../../../src/cli/utils/output"; + +describe("printResult", () => { + let stdoutSpy: ReturnType; + + beforeEach(() => { + stdoutSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + stdoutSpy.mockRestore(); + }); + + it("prints JSON by default", () => { + const data = [{ name: "Alice", age: 30 }]; + printResult(data, { pretty: false }); + expect(stdoutSpy).toHaveBeenCalledWith(JSON.stringify(data, null, 2)); + }); + + it("prints table for array of objects", () => { + const data = [ + { name: "Alice", age: 30 }, + { name: "Bob", age: 25 }, + ]; + printResult(data, { pretty: true }); + const output = stdoutSpy.mock.calls[0]?.[0] as string; + expect(output).toContain("name"); + expect(output).toContain("age"); + expect(output).toContain("Alice"); + expect(output).toContain("Bob"); + }); + + it("prints key-value table for single object", () => { + const data = { status: "ok", count: 42 }; + printResult(data, { pretty: true }); + const output = stdoutSpy.mock.calls[0]?.[0] as string; + expect(output).toContain("Key"); + expect(output).toContain("Value"); + expect(output).toContain("status"); + expect(output).toContain("ok"); + }); + + it("falls back to JSON for non-object data in table mode", () => { + printResult(["a", "b", "c"], { pretty: true }); + const output = stdoutSpy.mock.calls[0]?.[0] as string; + expect(output).toContain('"a"'); + }); +}); + +describe("printError", () => { + it("writes to stderr and exits", () => { + const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit called"); + }); + + expect(() => printError(new Error("something went wrong"))).toThrow("process.exit called"); + expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining("something went wrong")); + expect(exitSpy).toHaveBeenCalledWith(1); + + stderrSpy.mockRestore(); + exitSpy.mockRestore(); + }); +}); diff --git a/packages/fmodata/tsdown.config.ts b/packages/fmodata/tsdown.config.ts new file mode 100644 index 00000000..d8afda25 --- /dev/null +++ b/packages/fmodata/tsdown.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["src/cli/index.ts"], + format: ["esm"], + target: "esnext", + outDir: "dist/cli", + clean: false, + banner: { + js: "#!/usr/bin/env node", + }, +}); diff --git a/packages/fmodata/vitest.config.ts b/packages/fmodata/vitest.config.ts index ee5ce02e..d0ce52d3 100644 --- a/packages/fmodata/vitest.config.ts +++ b/packages/fmodata/vitest.config.ts @@ -13,7 +13,7 @@ export default defineConfig({ // When you pass a file path directly (e.g., vitest run tests/e2e.test.ts), // vitest will run it regardless of the exclude pattern // Run E2E tests with: pnpm test:e2e - exclude: ["**/node_modules/**", "**/dist/**", "tests/e2e/**"], + exclude: ["**/node_modules/**", "**/dist/**", "tests/e2e/**", "tests/cli/e2e/**"], typecheck: { enabled: true, include: ["src/**/*.ts", "tests/**/*.test.ts", "tests/**/*.test-d.ts"], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e5772007..6a5d9bbf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -517,6 +517,12 @@ importers: '@fetchkit/ffetch': specifier: ^4.2.0 version: 4.2.0 + cli-table3: + specifier: ^0.6.5 + version: 0.6.5 + commander: + specifier: ^14.0.2 + version: 14.0.2 dotenv: specifier: ^16.6.1 version: 16.6.1 @@ -545,6 +551,9 @@ importers: publint: specifier: ^0.3.16 version: 0.3.16 + tsdown: + specifier: ^0.14.2 + version: 0.14.2(oxc-resolver@11.16.2)(publint@0.3.16)(typescript@5.9.3) tsx: specifier: ^4.21.0 version: 4.21.0 @@ -2381,8 +2390,8 @@ packages: resolution: {integrity: sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA==} engines: {node: '>= 20.0.0'} - '@oxc-project/types@0.111.0': - resolution: {integrity: sha512-bh54LJMafgRGl2cPQ/QM+tI5rWaShm/wK9KywEj/w36MhiPKXYM67H2y3q+9pr4YO7ufwg2AKdBAZkhHBD8ClA==} + '@oxc-project/types@0.115.0': + resolution: {integrity: sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==} '@oxc-resolver/binding-android-arm-eabi@11.16.2': resolution: {integrity: sha512-lVJbvydLQIDZHKUb6Zs9Rq80QVTQ9xdCQE30eC9/cjg4wsMoEOg65QZPymUAIVJotpUAWJD0XYcwE7ugfxx5kQ==} @@ -3303,79 +3312,91 @@ packages: peerDependencies: react: '>=18.2.0' - '@rolldown/binding-android-arm64@1.0.0-rc.2': - resolution: {integrity: sha512-AGV80viZ4Hil4C16GFH+PSwq10jclV9oyRFhD+5HdowPOCJ+G+99N5AClQvMkUMIahTY8cX0SQpKEEWcCg6fSA==} + '@rolldown/binding-android-arm64@1.0.0-rc.9': + resolution: {integrity: sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.0.0-rc.2': - resolution: {integrity: sha512-PYR+PQu1mMmQiiKHN2JiOctvH32Xc/Mf+Su2RSmWtC9BbIqlqsVWjbulnShk0imjRim0IsbkMMCN5vYQwiuqaA==} + '@rolldown/binding-darwin-arm64@1.0.0-rc.9': + resolution: {integrity: sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.0.0-rc.2': - resolution: {integrity: sha512-X2G36Z6oh5ynoYpE2JAyG+uQ4kO/3N7XydM/I98FNk8VVgDKjajFF+v7TXJ2FMq6xa7Xm0UIUKHW2MRQroqoUA==} + '@rolldown/binding-darwin-x64@1.0.0-rc.9': + resolution: {integrity: sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.0.0-rc.2': - resolution: {integrity: sha512-XpiFTsl9qjiDfrmJF6CE3dgj1nmSbxUIT+p2HIbXV6WOj/32btO8FKkWSsOphUwVinEt3R8HVkVrcLtFNruMMQ==} + '@rolldown/binding-freebsd-x64@1.0.0-rc.9': + resolution: {integrity: sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.2': - resolution: {integrity: sha512-zjYZ99e47Wlygs4hW+sQ+kshlO8ake9OoY2ecnJ9cwpDGiiIB9rQ3LgP3kt8j6IeVyMSksu//VEhc8Mrd1lRIw==} + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.9': + resolution: {integrity: sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.2': - resolution: {integrity: sha512-Piso04EZ9IHV1aZSsLQVMOPTiCq4Ps2UPL3pchjNXHGJGFiB9U42s22LubPaEBFS+i6tCawS5EarIwex1zC4BA==} + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.9': + resolution: {integrity: sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.2': - resolution: {integrity: sha512-OwJCeMZlmjKsN9pfJfTmqYpe3JC+L6RO87+hu9ajRLr1Lh6cM2FRQ8e48DLRyRDww8Ti695XQvqEANEMmsuzLw==} + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.9': + resolution: {integrity: sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.2': - resolution: {integrity: sha512-uQqBmA8dTWbKvfqbeSsXNUssRGfdgQCc0hkGfhQN7Pf85wG2h0Fd/z2d+ykyT4YbcsjQdgEGxBNsg3v4ekOuEA==} + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.9': + resolution: {integrity: sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.9': + resolution: {integrity: sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.9': + resolution: {integrity: sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@rolldown/binding-linux-x64-musl@1.0.0-rc.2': - resolution: {integrity: sha512-ItZabVsICCYWHbP+jcAgNzjPAYg5GIVQp/NpqT6iOgWctaMYtobClc5m0kNtxwqfNrLXoyt998xUey4AvcxnGQ==} + '@rolldown/binding-linux-x64-musl@1.0.0-rc.9': + resolution: {integrity: sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@rolldown/binding-openharmony-arm64@1.0.0-rc.2': - resolution: {integrity: sha512-U4UYANwafcMXSUC0VqdrqTAgCo2v8T7SiuTYwVFXgia0KOl8jiv3okwCFqeZNuw/G6EWDiqhT8kK1DLgyLsxow==} + '@rolldown/binding-openharmony-arm64@1.0.0-rc.9': + resolution: {integrity: sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-wasm32-wasi@1.0.0-rc.2': - resolution: {integrity: sha512-ZIWCjQsMon4tqRoao0Vzowjwx0cmFT3kublh2nNlgeasIJMWlIGHtr0d4fPypm57Rqx4o1h4L8SweoK2q6sMGA==} + '@rolldown/binding-wasm32-wasi@1.0.0-rc.9': + resolution: {integrity: sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g==} engines: {node: '>=14.0.0'} cpu: [wasm32] - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.2': - resolution: {integrity: sha512-NIo7vwRUPEzZ4MuZGr5YbDdjJ84xdiG+YYf8ZBfTgvIsk9wM0sZamJPEXvaLkzVIHpOw5uqEHXS85Gqqb7aaqQ==} + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.9': + resolution: {integrity: sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.2': - resolution: {integrity: sha512-bLKzyLFbvngeNPZocuLo3LILrKwCrkyMxmRXs6fZYDrvh7cyZRw9v56maDL9ipPas0OOmQK1kAKYwvTs30G21Q==} + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.9': + resolution: {integrity: sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -3383,8 +3404,8 @@ packages: '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} - '@rolldown/pluginutils@1.0.0-rc.2': - resolution: {integrity: sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==} + '@rolldown/pluginutils@1.0.0-rc.9': + resolution: {integrity: sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==} '@rollup/plugin-replace@6.0.3': resolution: {integrity: sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==} @@ -5419,6 +5440,7 @@ packages: glob@11.1.0: resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} engines: {node: 20 || >=22} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true globby@11.1.0: @@ -6691,6 +6713,7 @@ packages: prebuild-install@7.1.3: resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. hasBin: true prettier@2.8.8: @@ -6986,8 +7009,8 @@ packages: vue-tsc: optional: true - rolldown@1.0.0-rc.2: - resolution: {integrity: sha512-1g/8Us9J8sgJGn3hZfBecX1z4U3y5KO7V/aV2U1M/9UUzLNqHA8RfFQ/NPT7HLxOIldyIgrcjaYTRvA81KhJIg==} + rolldown@1.0.0-rc.9: + resolution: {integrity: sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -9476,7 +9499,7 @@ snapshots: '@orama/orama@3.1.18': {} - '@oxc-project/types@0.111.0': {} + '@oxc-project/types@0.115.0': {} '@oxc-resolver/binding-android-arm-eabi@11.16.2': optional: true @@ -10386,50 +10409,56 @@ snapshots: dependencies: react: 19.2.3 - '@rolldown/binding-android-arm64@1.0.0-rc.2': + '@rolldown/binding-android-arm64@1.0.0-rc.9': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-rc.9': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-rc.9': optional: true - '@rolldown/binding-darwin-arm64@1.0.0-rc.2': + '@rolldown/binding-freebsd-x64@1.0.0-rc.9': optional: true - '@rolldown/binding-darwin-x64@1.0.0-rc.2': + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.9': optional: true - '@rolldown/binding-freebsd-x64@1.0.0-rc.2': + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.9': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.2': + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.9': optional: true - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.2': + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.9': optional: true - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.2': + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.9': optional: true - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.2': + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.9': optional: true - '@rolldown/binding-linux-x64-musl@1.0.0-rc.2': + '@rolldown/binding-linux-x64-musl@1.0.0-rc.9': optional: true - '@rolldown/binding-openharmony-arm64@1.0.0-rc.2': + '@rolldown/binding-openharmony-arm64@1.0.0-rc.9': optional: true - '@rolldown/binding-wasm32-wasi@1.0.0-rc.2': + '@rolldown/binding-wasm32-wasi@1.0.0-rc.9': dependencies: '@napi-rs/wasm-runtime': 1.1.1 optional: true - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.2': + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.9': optional: true - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.2': + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.9': optional: true '@rolldown/pluginutils@1.0.0-beta.27': {} - '@rolldown/pluginutils@1.0.0-rc.2': {} + '@rolldown/pluginutils@1.0.0-rc.9': {} '@rollup/plugin-replace@6.0.3(rollup@4.55.1)': dependencies: @@ -14630,7 +14659,7 @@ snapshots: rfdc@1.4.1: {} - rolldown-plugin-dts@0.15.10(oxc-resolver@11.16.2)(rolldown@1.0.0-rc.2)(typescript@5.9.3): + rolldown-plugin-dts@0.15.10(oxc-resolver@11.16.2)(rolldown@1.0.0-rc.9)(typescript@5.9.3): dependencies: '@babel/generator': 7.28.5 '@babel/parser': 7.28.5 @@ -14640,31 +14669,33 @@ snapshots: debug: 4.4.3(supports-color@5.5.0) dts-resolver: 2.1.3(oxc-resolver@11.16.2) get-tsconfig: 4.13.0 - rolldown: 1.0.0-rc.2 + rolldown: 1.0.0-rc.9 optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: - oxc-resolver - supports-color - rolldown@1.0.0-rc.2: - dependencies: - '@oxc-project/types': 0.111.0 - '@rolldown/pluginutils': 1.0.0-rc.2 - optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.0-rc.2 - '@rolldown/binding-darwin-arm64': 1.0.0-rc.2 - '@rolldown/binding-darwin-x64': 1.0.0-rc.2 - '@rolldown/binding-freebsd-x64': 1.0.0-rc.2 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.2 - '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.2 - '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.2 - '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.2 - '@rolldown/binding-linux-x64-musl': 1.0.0-rc.2 - '@rolldown/binding-openharmony-arm64': 1.0.0-rc.2 - '@rolldown/binding-wasm32-wasi': 1.0.0-rc.2 - '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.2 - '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.2 + rolldown@1.0.0-rc.9: + dependencies: + '@oxc-project/types': 0.115.0 + '@rolldown/pluginutils': 1.0.0-rc.9 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.9 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.9 + '@rolldown/binding-darwin-x64': 1.0.0-rc.9 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.9 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.9 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.9 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.9 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.9 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.9 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.9 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.9 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.9 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.9 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.9 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.9 rollup-plugin-preserve-directives@0.4.0(rollup@4.55.1): dependencies: @@ -15244,8 +15275,8 @@ snapshots: diff: 8.0.2 empathic: 2.0.0 hookable: 5.5.3 - rolldown: 1.0.0-rc.2 - rolldown-plugin-dts: 0.15.10(oxc-resolver@11.16.2)(rolldown@1.0.0-rc.2)(typescript@5.9.3) + rolldown: 1.0.0-rc.9 + rolldown-plugin-dts: 0.15.10(oxc-resolver@11.16.2)(rolldown@1.0.0-rc.9)(typescript@5.9.3) semver: 7.7.3 tinyexec: 1.0.2 tinyglobby: 0.2.15 diff --git a/skills/proofkit-fmodata/SKILL.md b/skills/proofkit-fmodata/SKILL.md index abddc6c1..676c9588 100644 --- a/skills/proofkit-fmodata/SKILL.md +++ b/skills/proofkit-fmodata/SKILL.md @@ -1,6 +1,6 @@ --- name: proofkit-fmodata -description: Type-safe FileMaker OData client with Drizzle-inspired ORM and TypeScript code generation. Use when working with FileMaker databases in TypeScript projects, querying FM data, defining typed schemas, generating types from FM layouts, or troubleshooting fmodata/typegen issues. Triggers on FileMaker + TypeScript integration tasks. +description: Type-safe FileMaker OData client with Drizzle-inspired ORM, TypeScript code generation, and a CLI binary. Use when working with FileMaker databases in TypeScript projects, querying FM data, defining typed schemas, generating types from FM layouts, scripting FM operations from the shell or CI, or troubleshooting fmodata/typegen issues. --- # ProofKit FMOData @@ -195,6 +195,42 @@ if (result.error) { - Use `.select()` to fetch only needed fields - Avoid expanding large related record sets +## CLI (`fmodata` binary) + +`@proofkit/fmodata` ships a `fmodata` binary for shell scripting, CI pipelines, and one-off operations. All commands accept `--server/--database/--api-key` flags or the same env vars used by the library (`FM_SERVER`, `FM_DATABASE`, `OTTO_API_KEY` / `FM_USERNAME` + `FM_PASSWORD`). + +```bash +# Quick reference +fmodata records list --table [--top N] [--skip N] [--select f1,f2] [--where ] [--order-by field:asc] +fmodata records insert --table --data '' +fmodata records update --table --data '' [--where ] +fmodata records delete --table [--where ] + +fmodata script run [--param ''] + +fmodata webhook list +fmodata webhook get +fmodata webhook add --table --url [--select f1,f2] [--header k=v ...] +fmodata webhook remove + +fmodata metadata get [--format json|xml] +fmodata metadata tables + +fmodata schema list-tables +fmodata schema create-table --name --fields '' [--confirm] +fmodata schema add-fields --table --fields '' [--confirm] +``` + +Key behaviors: +- Output is **JSON** by default; `--pretty` renders an ASCII table +- Errors go to **stderr**, exit code 1 +- `schema create-table` / `schema add-fields` are **dry-run** without `--confirm` +- Auth precedence: `--api-key` / `OTTO_API_KEY` wins over username/password auth; if API key is present, missing `FM_PASSWORD` does not block auth +- Query option values for `--select`, `--where`, and `--order-by` are URL-encoded before request dispatch + +For AI agent usage, MCP tool wrapping, and full option tables see the docs: +- `https://proofkit.dev/llms/fmodata` (search for "CLI") + ## References - **[fmodata-api.md](references/fmodata-api.md)** - Complete API reference: field builders, operators, query methods