Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 51 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -149,8 +171,8 @@ four layers, in this order — the first one that applies wins:
1. **header** — an explicit `X-Laqi-Response: <name>` (or
`X-Laqi-Scenario: <name>`) 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,
Expand Down Expand Up @@ -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=<name>&lang=<language>)
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`
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion apps/site/public/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions apps/site/src/content/docs/docs/data-generators.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion apps/site/src/content/docs/docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down
2 changes: 1 addition & 1 deletion apps/site/src/content/docs/docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 2 additions & 2 deletions apps/site/src/content/docs/docs/panel.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions apps/site/src/content/docs/docs/quick-start.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
93 changes: 93 additions & 0 deletions packages/mcp/src/documented-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
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'
Expand All @@ -30,10 +31,23 @@
* 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'
Expand Down Expand Up @@ -79,6 +93,20 @@
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`)
Expand All @@ -102,6 +130,7 @@

let client: Client
let registered: string[]
let languageCount: number
let root: string

beforeAll(async () => {
Expand All @@ -113,7 +142,9 @@
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)])

const listed = await client.listTools()
registered = listed.tools.map((tool) => tool.name).sort()

Check warning on line 145 in packages/mcp/src/documented-tools.test.ts

View workflow job for this annotation

GitHub Actions / Validate

unicorn(no-array-sort)

packages/mcp/src/documented-tools.test.ts:145:54: Use `Array#toSorted()` instead of `Array#sort()`.

languageCount = (await supportedLanguages()).length
})

afterAll(async () => {
Expand All @@ -123,7 +154,7 @@

describe('the documented MCP tool list matches the registered one', () => {
it(`${AI_AGENTS_PAGE} tabulates exactly the registered tools`, () => {
const documented = toolNamesInTable(read(AI_AGENTS_PAGE)).sort()

Check warning on line 157 in packages/mcp/src/documented-tools.test.ts

View workflow job for this annotation

GitHub Actions / Validate

unicorn(no-array-sort)

packages/mcp/src/documented-tools.test.ts:157:63: Use `Array#toSorted()` instead of `Array#sort()`.
expect(documented).toEqual(registered)
})

Expand Down Expand Up @@ -152,6 +183,68 @@
start,
)

expect(backtickedNames(llms.slice(start, end)).sort()).toEqual(registered)

Check warning on line 186 in packages/mcp/src/documented-tools.test.ts

View workflow job for this annotation

GitHub Actions / Validate

unicorn(no-array-sort)

packages/mcp/src/documented-tools.test.ts:186:52: Use `Array#toSorted()` instead of `Array#sort()`.
})
})

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 "<names> and N more"').not.toBeNull()
const named = (match?.[1] ?? '').split(', ').length
expect(match?.[2]).toBe(numberWord(languageCount - named))
})
})
Loading