diff --git a/README.md b/README.md index 3972c8d..8215731 100644 --- a/README.md +++ b/README.md @@ -26,13 +26,18 @@ responses in the panel while the app is open. ``` packages/schema — Zod schemas: config, endpoint, response, scenarios, state +packages/config — the TypeScript compiler settings every package extends packages/core — loading mocks from disk, route table, resolution, state store packages/server — the Hono app that actually serves mock responses packages/editor — the web control panel (React + Vite), served at /__laqi packages/mcp — the MCP server, for coding agents -apps/cli — the `laqi` CLI: serve, watch, migrate, mcp +packages/generate — types from data and data from types (quicktype, seeded generation) +packages/tui — the terminal screens the CLI prints (start banner, goodbye report) +packages/tokens — the color and font tokens shared by the panel and the site +apps/cli — the `laqi` CLI: start (the default), init, mcp, migrate +apps/site — the public site, laqi.dev (Astro) +apps/documentation — the internal docs (Astro + Starlight); never deployed examples/todo-app — a TanStack Start frontend built against laqi -apps/documentation — the docs site (Astro + Starlight) ``` ## Running it @@ -43,6 +48,10 @@ From inside a project that has mock files (see below): npx laqi ``` +Starting a project from nothing? `npx laqi init` scaffolds `./laqi/` with an +example mock API and scenarios first (`npx laqi init --help` lists the +options, including `--from openapi`). + To run the working tree instead of the published version — which is what you want while developing laqi itself — build it from this repo and use the binary it produces: @@ -84,8 +93,21 @@ Useful flags: ``` Configuration can also live in a `laqi.config.json` file at your project -root, with the same keys (`port`, `host`, `dir`, `file`, plus `cors`, -`density`, `showDescriptions`). +root, with the same keys (`port`, `host`, `dir`, `file`), plus these, which +have no flag: + +- `cors` — `"*"` (the default) or a list of allowed origins. Outside share + mode the default really is `*`; `--share` never uses it (see below). +- `density` (`regular` or `compact`) and `showDescriptions` — panel + preferences. +- `schemaSources.root` — the folder, relative to the project root, that + laqi may read a schema source from when it generates from your own code. + It bounds reads only; it never lets laqi write there. Default `.`. +- `mcp.modules` — the allowlist of project modules the MCP server may + execute, each entry a `{ "file", "exportName", "side" }` object. It is + empty by default, so an agent cannot run any of your code until you list + it here by hand; an agent has no tool that edits it. The panel asks a + person instead of reading this list. ### Migrating from v1 @@ -149,8 +171,8 @@ four layers, in this order — the first one that applies wins: 1. **header** — an explicit `X-Laqi-Response: ` (or `X-Laqi-Scenario: `) request header. Doesn't persist anything. 2. **state** — a per-endpoint override, written to `.laqi/state.json` via - the control plane API below (a web panel or MCP server, in a later - plan, will write there too). + the control plane API below (the web panel and the MCP server both write + there). 3. **scenario** — the currently active scenario from `scenarios.json`, if one is set in state. 4. **default** — the endpoint's own `default` response. Always available, @@ -201,6 +223,26 @@ GET /__laqi/api/status what's being watched, endpoint count, load GET /__laqi/events live SSE stream: request | endpoints-changed | error ``` +Type and data generation adds the routes below. `:name` is a response name +within the endpoint `:id`. + +``` +GET /__laqi/api/generate/languages the languages types can be exported in +GET /__laqi/api/endpoints/:id/types types for a response (?response=&lang=) +POST /__laqi/api/schema/import read a schema source into a schema snapshot +POST /__laqi/api/schema/preview generate a body and its evidence from a snapshot, saving nothing +POST /__laqi/api/schema/export print types from a snapshot +GET /__laqi/api/schema/capabilities which inputs can be imported and which export targets exist +POST /__laqi/api/schema/module/prepare name a project module to run; returns a token +POST /__laqi/api/schema/module/confirm redeem that token to read the approved module +POST /__laqi/api/endpoints/:id/responses/:name/regenerate regenerate a response body from its stored schema +PUT /__laqi/api/endpoints/:id/responses/:name/body write a generated body together with its evidence +GET /__laqi/api/endpoints/:id/responses/:name/revision the response's current revision +GET /__laqi/api/endpoints/:id/responses/:name/model a TypeScript model drafted from the response +PUT /__laqi/api/endpoints/:id/responses/:name/schema replace the response's stored schema with a snapshot +POST /__laqi/api/endpoints/:id/responses/:name/schema/refresh re-read the schema's source and replace the stored schema (body untouched) +``` + Every write goes through the same file it would if you'd hand-edited it, and reloads immediately — no restart, no waiting on the file watcher. This API has no authentication yet; it's withheld automatically unless `--host` @@ -276,8 +318,9 @@ the built binary instead (`node`, with the path to `dist/index.mjs` and `mcp` as args). Tools: `list_endpoints`, `get_state`, `set_response`, `set_scenario`, -`reset_state`, `create_endpoint`, `update_endpoint`, `delete_endpoint`, -`import_openapi`. +`reset_state`, `create_endpoint`, `scaffold_responses`, `update_endpoint`, +`delete_endpoint`, `import_openapi`, `get_types`, `generate_data`, +`regenerate_response`, `apply_generated_body`, `refresh_schema`. `import_openapi` turns an OpenAPI 3.x document into mocks, generating example bodies from the schemas. It takes JSON (convert YAML first), never overwrites diff --git a/apps/site/public/llms.txt b/apps/site/public/llms.txt index ec281ba..0621e45 100644 --- a/apps/site/public/llms.txt +++ b/apps/site/public/llms.txt @@ -31,7 +31,7 @@ serves (default 8000). Fastest path: - [Realistic data and types](https://laqi.dev/docs/data-generators/): paste a TypeScript model and get seeded fake data back (`email` fields get emails, `createdAt` gets dates); derive types from a live response - in twenty-five languages, including TypeScript, Python, Go, Rust, + in twenty-seven languages, including TypeScript, Python, Go, Rust, Swift, Kotlin, and Zod/Effect Schema validators. ## For AI agents diff --git a/apps/site/src/content/docs/docs/data-generators.md b/apps/site/src/content/docs/docs/data-generators.md index 021ee7b..e9f6456 100644 --- a/apps/site/src/content/docs/docs/data-generators.md +++ b/apps/site/src/content/docs/docs/data-generators.md @@ -89,12 +89,12 @@ a language picker. Types are derived from the live response body on demand, so they can never go stale against a body you changed by hand. The same operation works from an existing endpoint or from a pasted -model, and covers 25 languages: +model, and covers 27 languages: C (cJSON), C++, C#, Crystal, Dart, Elixir, Elm, Flow, Go, Haskell, Java, JavaScript, JavaScript PropTypes, JSON Schema, Kotlin, Objective-C, PHP, -Pike, Python, Ruby, Rust, Scala 3, Smithy, Swift, TypeScript — plus -TypeScript with Zod or Effect Schema validators built in. +Pike, Python, Ruby, Rust, Scala 3, Smithy, Swift, TypeScript, TypeScript +with Effect Schema, TypeScript with Zod. ```ts // TypeScript diff --git a/apps/site/src/content/docs/docs/index.md b/apps/site/src/content/docs/docs/index.md index dd7cc20..b5a7c6d 100644 --- a/apps/site/src/content/docs/docs/index.md +++ b/apps/site/src/content/docs/docs/index.md @@ -24,7 +24,7 @@ response flipped in the panel in under a minute. - [Mock files](/docs/mock-files/) — the JSON format laqi reads. - [Realistic data and types](/docs/data-generators/) — generate seeded - fake data from a model, or derive types in twenty-five languages from + fake data from a model, or derive types in twenty-seven languages from a live response. **AI agents** diff --git a/apps/site/src/content/docs/docs/installation.md b/apps/site/src/content/docs/docs/installation.md index efe91f9..bba9b1f 100644 --- a/apps/site/src/content/docs/docs/installation.md +++ b/apps/site/src/content/docs/docs/installation.md @@ -33,7 +33,7 @@ Add a script so nobody has to remember the command: } ``` -Then `npm run mock` serves `./laqi/` and opens the panel, using the version +Then `npm run mock` serves `./laqi/` (the panel is at `http://127.0.0.1:8000/__laqi`), using the version the lockfile pins. A teammate who clones the repo and runs `npm install` gets the same laqi you have, which a global install cannot promise. diff --git a/apps/site/src/content/docs/docs/panel.md b/apps/site/src/content/docs/docs/panel.md index ec3edb8..768f0e5 100644 --- a/apps/site/src/content/docs/docs/panel.md +++ b/apps/site/src/content/docs/docs/panel.md @@ -49,7 +49,7 @@ The endpoint detail view edits the definition itself — status, delay, body, response names — and writes it back to the file it came from. It also hands you a ready-made `curl` carrying `X-Laqi-Response`, and the [data generation](/docs/data-generators/) tools: paste a model for a -realistic body, or copy the current response's types in twenty-five +realistic body, or copy the current response's types in twenty-seven languages. ## Three ways to say what it returns @@ -59,7 +59,7 @@ path, the response name and the status mean the same thing in all three and never move; only the source of the body changes. Blank writes a placeholder, a model generates realistic data from your types, and JSON takes the body you paste, exactly as pasted. Once it exists, the endpoint detail turns that -body back into types in twenty-five languages, and regenerates it. +body back into types in twenty-seven languages, and regenerates it. The status field is also the catalogue's search box, so it accepts whatever you type; what it will not accept is a code that is not one. `201e44` reads diff --git a/apps/site/src/content/docs/docs/quick-start.md b/apps/site/src/content/docs/docs/quick-start.md index 15eedbb..54c4674 100644 --- a/apps/site/src/content/docs/docs/quick-start.md +++ b/apps/site/src/content/docs/docs/quick-start.md @@ -27,8 +27,8 @@ see [Mock files](/docs/mock-files/) — or import an OpenAPI document. laqi ``` -That serves the folder at `http://127.0.0.1:8000` and opens the control -panel at `http://127.0.0.1:8000/__laqi`. (`laqi start` is the same +That serves the folder at `http://127.0.0.1:8000`. The control panel is at +`http://127.0.0.1:8000/__laqi`; open it in your browser. (`laqi start` is the same command, spelled out.) The folder is watched: edit a file and the server picks it up, no restart. diff --git a/packages/mcp/src/documented-tools.test.ts b/packages/mcp/src/documented-tools.test.ts index 83f0361..ccea92e 100644 --- a/packages/mcp/src/documented-tools.test.ts +++ b/packages/mcp/src/documented-tools.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' +import { supportedLanguages } from '@laqi/generate' import { ConfigSchema } from '@laqi/schema' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { createMcpServer } from './server' @@ -30,10 +31,23 @@ import { createMcpServer } from './server' * What this guarantees: registering or removing a tool in `server.ts` fails * CI until every surface below is updated. What it does not: that the prose * describes the tool correctly. Nothing automatic can check that. + * + * The same reasoning covers two more things the docs transcribe by hand and + * that drifted the same way: the root README's list of control plane routes + * (it listed nine; `control-plane-app.ts` mounts twenty-three) and the number + * of languages the type generator targets (the site said twenty-five, the + * generator reports twenty-seven). They live in this file because it is + * already the one place that compares the docs with the code. */ +const REPO = new URL('../../../', import.meta.url) const SITE = new URL('../../../apps/site/', import.meta.url) +const README = 'README.md' +const CONTROL_PLANE_SOURCE = 'packages/server/src/control-plane-app.ts' +const DATA_GENERATORS_PAGE = 'apps/site/src/content/docs/docs/data-generators.md' +const PANEL_PAGE = 'apps/site/src/content/docs/docs/panel.md' + const AI_AGENTS_PAGE = 'apps/site/src/content/docs/docs/ai-agents.md' const DOCS_INDEX = 'apps/site/src/content/docs/docs/index.md' const LLMS_TXT = 'apps/site/public/llms.txt' @@ -79,6 +93,20 @@ function read(relative: string): string { return readFileSync(fileURLToPath(new URL(relative.replace('apps/site/', ''), SITE)), 'utf8') } +function readRepo(relative: string): string { + return readFileSync(fileURLToPath(new URL(relative, REPO)), 'utf8') +} + +/** The body of one `## ` section of a Markdown file, up to the next one. */ +function section(markdown: string, heading: string): string { + const start = markdown.indexOf(`\n## ${heading}\n`) + if (start === -1) throw new Error(`no "## ${heading}" section`) + const body = markdown.slice(start + 1) + const next = body.indexOf('\n## ', 1) + return next === -1 ? body : body.slice(0, next) +} + +/** Spells a count the way the prose does: "seven", "twenty-seven". */ function numberWord(count: number): string { const word = NUMBER_WORDS[count] if (!word) throw new Error(`no English word for ${count}; extend NUMBER_WORDS`) @@ -102,6 +130,7 @@ function backtickedNames(text: string): string[] { let client: Client let registered: string[] +let languageCount: number let root: string beforeAll(async () => { @@ -114,6 +143,8 @@ beforeAll(async () => { const listed = await client.listTools() registered = listed.tools.map((tool) => tool.name).sort() + + languageCount = (await supportedLanguages()).length }) afterAll(async () => { @@ -155,3 +186,65 @@ describe('the documented MCP tool list matches the registered one', () => { expect(backtickedNames(llms.slice(start, end)).sort()).toEqual(registered) }) }) + +describe('the README lists the MCP tools the server registers', () => { + it(`${README} names exactly the registered tools`, () => { + const paragraph = /\nTools: ([^]*?)\n\n/.exec( + section(readRepo(README), 'Using it from a coding agent (MCP)'), + ) + expect(paragraph, 'the README no longer has a "Tools:" paragraph').not.toBeNull() + expect(backtickedNames(paragraph?.[1] ?? '').sort()).toEqual(registered) + }) +}) + +describe('the README lists the control plane routes the server mounts', () => { + /** `GET /__laqi/api/state` for every `app.get('/api/state', …)` in the source. */ + function mountedRoutes(): string[] { + const source = readRepo(CONTROL_PLANE_SOURCE) + return [...source.matchAll(/\bapp\.(get|post|put|patch|delete)\(\s*'([^']+)'/g)] + .map((match) => `${(match[1] ?? '').toUpperCase()} /__laqi${match[2]}`) + .sort() + } + + /** Every `METHOD /__laqi/…` line of the README's "Control plane API" block. */ + function documentedRoutes(): string[] { + return section(readRepo(README), 'Control plane API') + .split('\n') + .map((line) => /^(GET|POST|PUT|PATCH|DELETE)\s+(\/__laqi\/\S+)/.exec(line)) + .filter((match): match is RegExpExecArray => match !== null) + .map((match) => `${match[1]} ${match[2]}`) + .sort() + } + + it(`${README} lists exactly the routes ${CONTROL_PLANE_SOURCE} mounts`, () => { + // Guards the extraction itself: a refactor that mounts routes some other + // way would otherwise turn this into two empty lists comparing equal. + expect(mountedRoutes().length).toBeGreaterThan(0) + expect(documentedRoutes()).toEqual(mountedRoutes()) + }) +}) + +describe('the documented language count matches the type generator', () => { + it(`${DATA_GENERATORS_PAGE} states the count and lists that many languages`, () => { + const page = readRepo(DATA_GENERATORS_PAGE) + expect(page).toContain(`covers ${languageCount} languages:`) + + const list = /covers \d+ languages:\n\n([^]*?)\n\n/.exec(page)?.[1] ?? '' + expect(list.replace(/\s+/g, ' ').split(', ')).toHaveLength(languageCount) + }) + + it.each([PANEL_PAGE, DOCS_INDEX, LLMS_TXT])('%s spells the count in words', (surface) => { + const text = readRepo(surface).replace(/\s+/g, ' ') + expect(text).toContain(`in ${numberWord(languageCount)} languages`) + }) + + it(`${README} counts the languages it does not name`, () => { + // "TypeScript, Zod, Swift, Kotlin, Dart, Python, Go and twenty more": + // seven named plus the spelled-out remainder must add up to the total. + const text = readRepo(README).replace(/\s+/g, ' ') + const match = /language dropdown — (.+?) and (\S+) more/.exec(text) + expect(match, 'the README no longer says " and N more"').not.toBeNull() + const named = (match?.[1] ?? '').split(', ').length + expect(match?.[2]).toBe(numberWord(languageCount - named)) + }) +})