diff --git a/.changeset/first-version.md b/.changeset/first-version.md index 78f5768..650f4c6 100644 --- a/.changeset/first-version.md +++ b/.changeset/first-version.md @@ -9,9 +9,11 @@ First version of `meili`, the Meilisearch CLI built for coding agents. - Indexes: `meili get indexes [--id ]`; `meili add indexes --id [--primary-key ]`, `meili update indexes --id [--primary-key ] [--new-id ]`, which renames the index, and `meili delete indexes --id --yes`. - Tasks: `meili get tasks`, narrowed by `--ids`, `--statuses`, `--types`, `--index-ids`, `--batch-ids`, `--canceled-by` and the date filters of the route, paging with `--limit` and `--from` and following the `next` cursor with `--all`; `meili cancel tasks` and `meili delete tasks --yes`, which take the same filters and answer a task. - Search: `meili search --index --q `, on `POST /indexes/{index_uid}/search`, with every non-experimental search parameter of the route as a flag, from `--filter` and `--sort` to `--hybrid` and `--vector`; `--data` or `--file` carries a whole search body, which a flag of the same field overrides. +- Cloud projects: `meili get projects [--id ]`, `meili add projects --name --team-id --resource-id `, `meili update projects --id --name ` and `meili delete projects --id --yes`, the first commands of the Management API, reached with `--management-token` and `--management-url` or with the session of `meili auth login`. Its errors are passed through like Meilisearch's, same shape, same exit codes. - `meili schema` prints the index of the command tree as JSON, one entry per command with its routes, and `meili schema --command "get indexes"` prints one command in full, with its key actions and whether it returns a task. - Connection through `--project-url` and `--project-api-key`, or `MEILI_PROJECT_URL` and `MEILI_PROJECT_API_KEY`; `MEILI_MANAGEMENT_TOKEN` and `MEILI_MANAGEMENT_URL` for the Management API. - Several projects in a config file, `~/.config/meili/config.json` or `MEILI_CONFIG`, chosen with `--project` on the command line and never deduced; `meili config list`, `config set` and `config unset` manage the file. +- A project's `cloudId` in the file gives its id to the management commands, `meili update projects --project prod`; `meili config sync-with-cloud` fills it from Meilisearch Cloud on the entries that have the project's URL, and `meili add projects --add-to-config` adds the project it creates, with the master key creation returns once. - `meili auth login` logs in to Meilisearch Cloud with a browser, through the OAuth2 authorization code flow with PKCE on a loopback redirect, and stores the session in `credentials.json` next to the config file, mode `600`; the management commands refresh it on their own, `meili auth status` says whether that session is still accepted by the server, `meili auth refresh` rotates it on demand, and `meili auth logout` revokes it and forgets it. - A command of the verb `delete` requires `--yes` on the command line: without it the command refuses to run, `cli_usage` and exit 2, before any network call. The CLI still never asks a question. - `json` output by default, `ndjson` for lists, `--all` to follow the pagination, `--wait` and `--timeout` for asynchronous tasks. diff --git a/.claude/skills/add-command/SKILL.md b/.claude/skills/add-command/SKILL.md index 67691cb..76f9257 100644 --- a/.claude/skills/add-command/SKILL.md +++ b/.claude/skills/add-command/SKILL.md @@ -1,12 +1,12 @@ --- name: add-command -description: Add exactly one command to the meili CLI (for example `get stats` or `cancel tasks`) from its routes in the OpenAPI document, following the naming rules of docs/contract/commands.md and CLAUDE.md, with its snapshot tests, integration test, manifest and changeset. Use it whenever a command must be added or when someone asks to expose an API route. +description: Add exactly one command to the meili CLI (for example `get stats` or `cancel tasks`) from its routes in the OpenAPI document, following the naming rules of docs/contract/commands.md and CLAUDE.md, with its snapshot tests, its integration test for a Meilisearch command, manifest and changeset. Use it whenever a command must be added or when someone asks to expose an API route. argument-hint: --- # Add the command `meili $ARGUMENTS` -You ship exactly one command in this change: `meili $ARGUMENTS`, with every route of its verb and resource. One file for the command, one test file, one integration test, the regenerated manifest, one changeset line. Nothing else, unless a shared piece of `src/lib/` is missing for this kind of command (say so in the report). +You ship exactly one command in this change: `meili $ARGUMENTS`, with every route of its verb and resource. One file for the command, one test file, one integration test for a Meilisearch command, the regenerated manifest, one changeset line. Nothing else, unless a shared piece of `src/lib/` is missing for this kind of command (say so in the report). When `$ARGUMENTS` names several commands of the same resource, `get add update delete documents`, rule 10 of `CLAUDE.md` allows them in one change: run every step below once per command, and ship them complete or not at all. Commands of different resources stay in different changes. @@ -15,7 +15,7 @@ Work through the steps in order. Every check below is enforced by `pnpm test` th ## 1. Read before you write - `CLAUDE.md`, fully. The non-negotiable rules are the review checklist. -- `docs/contract/commands.md`: the grammar, the eleven naming rules, the exceptions table, the shared flag groups and the commands without an API. +- `docs/contract/commands.md`: the grammar, the eleven naming rules, the exceptions table, the shared flag groups and the commands that do not derive from a route. - `docs/contract/output.md`: output modes, pass-through, errors, exit codes, asynchronous operations, destructive commands, input, pagination. The sections that match your routes are the specification of your command. - `docs/contract/testing.md`: what the three test levels must contain. - `docs/adr/`: skim the titles, read the ones your change touches. The contract says what the CLI promises; the ADRs say what was already decided, and which alternatives were refused and why. Contradicting one is a new ADR, not a silent change. @@ -35,7 +35,7 @@ Compare the derived command with `$ARGUMENTS`: - different name because the user's request contradicts the rules for no such reason: tell the user, propose the derived name, and follow what they decide; if they keep their name, it is an exception with their reason. - no route in the document, or a parameter the user mentions that the document does not have: stop and ask the user. Do not guess a route and do not read it in Meilisearch's source code. - a route behind an experimental feature: it is out of the contract (rule 10). Stop and tell the user; opening it is an exception they decide. -- a command that calls no API (`schema`): it must be in the "Commands without an API" table of `commands.md`; if it is not, stop and ask. +- a command that does not derive from a route (`schema`, the `config` commands): it must be in the "Commands that do not derive from a route" table of `commands.md`; if it is not, stop and ask. From the routes, write down the flags of each: the path flags (rule 4: `--id` for the resource itself, the singular collection for a parent, `--index`), the query parameters and body fields (rule 6), the identifier flags named by what they identify and their API-spelling aliases (rule 5), and which flag combination selects which route (rule 3). @@ -47,7 +47,7 @@ The command id is the file path. `meili get stats` gives: |---|---| | command | `src/commands/get/stats.ts` | | unit test | `test/commands/get/stats.test.ts` | -| integration test | `test/integration/meilisearch/get-stats.test.ts` | +| integration test, Meilisearch commands only | `test/integration/meilisearch/get-stats.test.ts` | | snapshot (generated) | `test/commands/get/__snapshots__/stats.test.ts.snap` | The class name is the command id in PascalCase (`GetStats`). The first command of a new verb (`add`, `update`, `delete`, `cancel`, ...) also needs the topic description in `package.json`, under `oclif.topics..description`, otherwise `meili --help` does not list the verb. A verb-only command (`search`, `export`) is a file at the root of `src/commands/`. @@ -93,7 +93,7 @@ Run `pnpm test` once to write the snapshots, then read the `.snap` file: the JSO ## 5. Integration test -Copy `test/integration/meilisearch/get-health.test.ts` or `get-indexes.test.ts`. The file starts with `const meilisearchUp = await meilisearchAvailable()` and wraps everything in `describe.skipIf(!meilisearchUp)`. It runs the built binary with `runBinary([...])` against `MEILISEARCH_URL` and `MASTER_KEY` from `setup.ts`, never against the shell's `MEILI_PROJECT_URL`. Fixtures (an index, documents) are created with the SDK under a random suffix and deleted in `afterAll`, so the suite can run in parallel and never depends on existing data. A management command needs `test/integration/management/`, whose bootstrap (`docs/contract/testing.md`, section 2) does not exist yet: stop and tell the user it must be built first, in its own change. +Copy `test/integration/meilisearch/get-health.test.ts` or `get-indexes.test.ts`. The file starts with `const meilisearchUp = await meilisearchAvailable()` and wraps everything in `describe.skipIf(!meilisearchUp)`. It runs the built binary with `runBinary([...])` against `MEILISEARCH_URL` and `MASTER_KEY` from `setup.ts`, never against the shell's `MEILI_PROJECT_URL`. Fixtures (an index, documents) are created with the SDK under a random suffix and deleted in `afterAll`, so the suite can run in parallel and never depends on existing data. A management command has no integration test (`docs/contract/testing.md`, section 2): its handlers in `test/helpers/management-handlers.ts` follow `openapi/management.json`, and what the document does not say is reported to the user as a documentation gap, never guessed. ## 6. Manifest, changeset diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9eb039b..d8b1038 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,9 @@ jobs: run: pnpm openapi env: MEILI_BOT_GH_PAT: ${{ secrets.MEILI_BOT_GH_PAT }} + - run: pnpm gen:management + - name: The committed Management API client must match the document + run: git diff --exit-code src/lib/clients/management/generated - run: pnpm lint - run: pnpm typecheck - run: pnpm gen:manifest diff --git a/CLAUDE.md b/CLAUDE.md index 5bf85f6..1b3b57b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,8 +37,8 @@ Package: `@meilisearch/cli` on npm. Binary: `meili`. - TypeScript, strict mode, `noUncheckedIndexedAccess` on. Node 22 or newer. - oclif v5 (`@oclif/core` 5) for the command tree, topic separator is a space (`meili get indexes`). See ADR 0001. - `meilisearch-js` for the Meilisearch API. -- `@hey-api/openapi-ts` generates the Management API client from `openapi/management.json`, downloaded by `pnpm openapi:management` and never committed, into `src/lib/clients/management/generated/`. Never edit generated files. Run `pnpm gen:management` to refresh them (the generator and the script arrive with the first management command). In code, commands, flags and environment variables, `management` is the name for everything that touches the Management API. `Cloud` only appears in prose, as the product name. -- vitest for tests, with snapshots. `msw` mocks HTTP in unit tests. Integration tests run against a local Meilisearch and a local Management API, see `docs/contract/testing.md`. +- `@hey-api/openapi-ts` generates the Management API client from `openapi/management.json`, downloaded by `pnpm openapi:management` and never committed, into `src/lib/clients/management/generated/`. Never edit generated files. Run `pnpm gen:management` to refresh them: it runs the generator through `pnpm dlx` with TypeScript 5 beside it, which the generator needs, from `openapi-ts.config.mjs`, and CI fails when the committed files differ. In code, flags and environment variables, `management` names the Management API itself, `--management-token`; `cloud` names the product, Meilisearch Cloud, where a command or a field speaks of it, `config sync-with-cloud`, `cloudId`. +- vitest for tests, with snapshots. `msw` mocks HTTP in unit tests. Integration tests run against a local Meilisearch; the Management API has none, see `docs/contract/testing.md`. - Biome for lint and format. - changesets for versioning and the changelog. - pnpm as the package manager. @@ -63,7 +63,7 @@ Use the skill: `/add-command `, for example `/add-command get stats`. I 4. Call the client. Read commands end with `await this.output(result)`. Write commands end with `await this.outputTask(task, flags)`. 5. Map errors. Do nothing for API errors, `BaseCommand` maps them. Add a `hint` only when you know the next command the user should run. 6. Create `test/commands/.test.ts` from the sibling test. It must snapshot the `--help` output and the JSON output for one success case and one error case, with `msw` mocks. -7. Add one integration test in `test/integration/meilisearch/` or `test/integration/management/`. Meilisearch runs locally from the `docker compose` file of this repository. The Management API needs the bootstrap described in `docs/contract/testing.md`, which does not exist yet: build it in its own pull request before the first management command. Unit tests still mock HTTP with `msw`; integration tests hit the real thing. +7. For a Meilisearch command, add one integration test in `test/integration/meilisearch/`: Meilisearch runs locally from the `docker compose` file of this repository. A management command has none: Meilisearch Cloud does not run locally, its OpenAPI document is the source of truth, and the unit tests mock it from that document (`docs/contract/testing.md`). What the document does not say is a gap to report to the Cloud team, not something to guess. 8. Run `pnpm gen:manifest` so `oclif.manifest.json` and `meili schema` know the command. 9. Add the command to the changeset. Until the first release, `.changeset/first-version.md` is the only one and describes everything: add your line to it. After it, write a new file in `.changeset/` by hand with type `minor`: `pnpm changeset` is interactive. 10. Run the "Before you finish" list. @@ -95,9 +95,9 @@ Then read the diff of your pull request twice. Once for simplicity: in Claude Co ## Where to read more -- `docs/contract/commands.md`: the naming rules that turn an API route into a command, the exceptions, the global and shared flags, the commands without an API +- `docs/contract/commands.md`: the naming rules that turn an API route into a command, the exceptions, the global and shared flags, the commands that do not derive from a route - `docs/contract/output.md`: output modes, error format, exit codes, async tasks, pagination -- `docs/contract/config.md`: projects, the config file, connection resolution, environment variables, secrets +- `docs/contract/config.md`: projects, the config file, connection resolution, Cloud projects, environment variables, secrets - `openapi/`: the OpenAPI documents downloaded by `pnpm openapi`, the reference for every command name - `docs/contract/testing.md`: test levels, how the two local APIs start, what runs in pull request CI - `docs/adr/`: the decisions and the reasons diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index eb4b9eb..ec1864c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,7 +25,7 @@ export MEILI_PROJECT_API_KEY=masterKey # only if the instance has a master key ./bin/run.js get indexes --debug # HTTP trace on stderr, keys masked ``` -The management commands (not shipped yet) will read `MEILI_MANAGEMENT_TOKEN`, and `MEILI_MANAGEMENT_URL` to target something else than production. +The management commands read `MEILI_MANAGEMENT_TOKEN`, and `MEILI_MANAGEMENT_URL` to target something else than production; `pnpm openapi:management` needs a GitHub token that reads `meilisearch/meilisearch-cloud`, and `pnpm gen:management` regenerates the client from the document. `pnpm link --global` puts a `meili` command on your PATH that points at your checkout. diff --git a/README.md b/README.md index b98ff6a..3b13f81 100644 --- a/README.md +++ b/README.md @@ -17,10 +17,12 @@ export MEILI_PROJECT_API_KEY=masterKey # only if the instance has a master key meili get health ``` -**Management API**, for the commands that manage Meilisearch Cloud, `meili get projects` or `meili add teams` (not shipped yet): +**Management API**, for the commands that manage Meilisearch Cloud, `meili get projects`, `meili add projects`, `meili update projects` and `meili delete projects`: ```sh export MEILI_MANAGEMENT_TOKEN=... # a personal access token from the Meilisearch Cloud dashboard + +meili add projects --name demo --team-id 12 --resource-id 3 ``` The URL, the key and the token also exist as flags with the same name: `--project-url`, `--project-api-key`, and `--management-token` with the management commands. The flag wins over the variable. @@ -31,17 +33,20 @@ The URL, the key and the token also exist as flags with the same name: `--projec { "projects": { "local": { "url": "http://localhost:7700" }, - "prod": { "url": "https://ms-abc.sfo.meilisearch.io", "apiKey": "..." } + "prod": { "url": "https://ms-abc.sfo.meilisearch.io", "apiKey": "...", "cloudId": 343 } } } ``` ```sh meili get indexes --project prod +meili update projects --project prod --name renamed # a management command uses the cloudId ``` With several projects in the file, `--project` is required: the CLI never guesses, and it refuses to run when the file and the `MEILI_PROJECT_URL` variable both define a project. `MEILI_CONFIG` names another file. For a coding agent, give it its own file through `MEILI_CONFIG`, read-only: the file is shared state, and an agent must not be able to change where another one's commands go. The file holds your keys: keep it readable by you alone, the CLI warns on stderr otherwise, and an agent changes nothing in it, neither a project nor its permissions, unless you asked for it explicitly. `meili config list`, `meili config set` and `meili config unset` manage the file. +`cloudId` is the project's id in Meilisearch Cloud, which the dashboard does not show. `meili config sync-with-cloud` fills it on the entries whose URL is a project your token sees; `meili add projects --add-to-config` adds the project it creates, with its URL, its master key and its `cloudId`: creation is the one moment the key is returned, `sync-with-cloud` never writes one. + ## Documentation ```sh diff --git a/biome.json b/biome.json index ecfb213..e746309 100644 --- a/biome.json +++ b/biome.json @@ -12,6 +12,7 @@ "!oclif.manifest.json", "!**/__snapshots__", "!openapi", + "!src/lib/clients/management/generated", "!CHANGELOG.md" ] }, diff --git a/docs/adr/0006-cloud-projects-in-the-config-file.md b/docs/adr/0006-cloud-projects-in-the-config-file.md new file mode 100644 index 0000000..2a2acd3 --- /dev/null +++ b/docs/adr/0006-cloud-projects-in-the-config-file.md @@ -0,0 +1,33 @@ +# ADR 0006: Cloud projects in the config file, the Management API consulted only on request + +Status: accepted. Date: 2026-10-15. + +## Context + +A management command acts on a Meilisearch Cloud project identified by its integer `id`, `meili update projects --id 343`. That id is not shown in the Cloud dashboard, only in the URL of a page. The config file of ADR 0005 names projects for the Meilisearch commands, with a URL and a key, and `--project ` is the handy way to target one; on a management command it was ignored in silence, and the user had to find an id. + +Many users create their projects in the dashboard and want the CLI, and the agent behind it, to act on them. ADR 0005 rejected resolving a project through the Management API because not everyone has Cloud, and because a resolution that runs before a command chooses its target in the user's back. + +The Management API returns a project's master key at its creation only: `GET /projects` does not carry it. + +## Decision + +- **`cloudId` in the config file.** An entry has `url`, optionally `apiKey`, optionally `cloudId`, the project's `id` in Meilisearch Cloud. On a management command, `--project ` supplies the id from the entry: `--id` of the `projects` commands and `--project-id` of their sub-resources are optional. `--project` with `--id` is `cli_usage`; an entry without `cloudId` is `cli_config` with the ways to set it; a command that reaches no project, `add projects`, does not offer `--project` at all (ADR 0010). The id is never resolved by the CLI itself, from the URL or otherwise. +- **The Management API is consulted only when the command line asks for it**, never to resolve the target of a command. The rule of ADR 0005 stays for the ambient resolution; explicit requests are not deduction. Three of them set `cloudId`: `add projects --add-to-config`, which adds the created project as an entry named after it; `config set --project-cloud-id`, the id by hand; `config sync-with-cloud`, which lists the projects the token sees and sets `cloudId` on the entries that have a project's URL. +- **`--add-to-config` writes the master key**, as the entry's `apiKey`: creation is the one moment the API returns it, and an entry without its key serves no Meilisearch command. `sync-with-cloud` never writes a key, the API does not return it afterwards; a key written later is the human's choice, through `config set` or the editor. +- **`sync-with-cloud` never adds or removes an entry.** A project the file does not know is ignored: without its key, which the API does not return, an entry would serve no Meilisearch command. The entries whose project it no longer sees are reported as `missing` and kept. +- **The name `cloud`** is allowed in commands and fields where the product is meant, `sync-with-cloud`, `cloudId`: the product is Meilisearch Cloud, at cloud.meilisearch.com. `management` names the API, `--management-token`. ADR 0004's rule that kept `Cloud` to prose is dropped. +- **A parent parameter of the Management API keeps its API name**, `{projectId}` is `--project-id`, because `--project` is the global flag that names an entry. + +Alternatives considered: + +- **Refusing `--project` on management commands.** Honest and minimal, but it gives up the one way to name a project without its id. +- **Resolving the id from the entry's URL at command time.** One hidden request before each command, a URL match that can be wrong in silence: the ambient deduction ADR 0005 refuses. +- **A `--add-to-config ` that names the entry.** One more thing to type; the entry is named after the project. +- **`sync-with-cloud` adding an entry for every project the file does not know**, named after the project. Rejected: without the key, those entries are of no use to the Meilisearch commands, and the file would fill with half entries. + +## Consequences + +- A user who created projects in the dashboard writes their entries with `config set`, URL and key, then runs `meili config sync-with-cloud` once to get their `cloudId`, and names them with `--project` on every command, Meilisearch or management. +- `--id` and `--project-id` are not required flags on the Management API, and `test/contract/api.test.ts` knows it. +- `commands.md` gains the `--add-to-config` flag, the `--project-cloud-id` flag of `config set`, the row of `config sync-with-cloud`, and the `--project-id` rule; `config.md` gains the `cloudId` field and the "Cloud projects" section. diff --git a/docs/contract/commands.md b/docs/contract/commands.md index 1fee422..72930e8 100644 --- a/docs/contract/commands.md +++ b/docs/contract/commands.md @@ -22,9 +22,9 @@ Every API command declares its routes, `static routes = ["GET /indexes", "GET /i 1. **Verb.** The HTTP method gives it: `GET` is `get`, `POST` is `add`, `PUT` and `PATCH` are `update`, `DELETE` is `delete`. A trailing literal segment that is an action replaces it: `POST /tasks/cancel` gives `cancel`, `POST /indexes/{index_uid}/compact` gives `compact`, `POST /indexes/{index_uid}/documents/delete` gives `delete`. The literal verbs are a closed list: `delete`, `cancel`, `compact`, `swap`, `search`, `similar`, `facet-search`, `multi-search`, `export`. Adding one is a contract change. 2. **Resource.** The last literal segment of the path that names a resource, plural, in kebab-case of the API name, without the `/api/v2` prefix of the Management API: `indexes`, `documents`, `team-members`, `stats`, `health`. A singleton keeps its name: `get health`, `get version`, `get stats`. Five commands have no resource word, because they are the most typed: `search`, `similar`, `facet-search`, `multi-search`, `export`. They are listed in the exceptions. 3. **Several routes, one command.** Every route with the same verb and resource belongs to one command: `GET /indexes` and `GET /indexes/{index_uid}` are `get indexes`, `GET /stats` and `GET /indexes/{index_uid}/stats` are `get stats`, the three deletion routes of documents are `delete documents`. The flags choose the route: first the route whose path parameters are exactly the identifier flags given, then the route whose parameters cover the other flags given. Two flags that call for different routes, `--ids` and `--filter` on `delete documents` for example, are a usage error, `cli_usage`, exit 2, before any network call. A flag that belongs to the list route only, `--limit` or `--offset`, is ignored and not sent when `--id` selects the unit route. -4. **Identifiers.** The path parameter of the resource itself is `--id` when the unit route is implemented: `get indexes --id movies`, `delete keys --id `, `get projects --id 343`. When the list route can filter by identifiers, the unit route is not implemented and the list takes `--ids`, with `--id` accepted as an alias: `get documents --index movies --ids 42` calls the fetch route, `get tasks --ids 42` filters `GET /tasks`, `get batches --ids 7` filters `GET /batches`. The path parameter of a parent resource is a flag named after that collection, singular: `--index movies`. The verb-only commands take the index the same way: `search --index movies`. +4. **Identifiers.** The path parameter of the resource itself is `--id` when the unit route is implemented: `get indexes --id movies`, `delete keys --id `, `get projects --id 343`. When the list route can filter by identifiers, the unit route is not implemented and the list takes `--ids`, with `--id` accepted as an alias: `get documents --index movies --ids 42` calls the fetch route, `get tasks --ids 42` filters `GET /tasks`, `get batches --ids 7` filters `GET /batches`. The path parameter of a parent resource is a flag named after that collection, singular: `--index movies`. The verb-only commands take the index the same way: `search --index movies`. On the Management API, a parent parameter keeps its API name in kebab-case, `{projectId}` is `--project-id`, because `--project` is the global flag that names an entry of the config file; and that entry can supply the project: `--id` of the `projects` commands and `--project-id` of their sub-resources are optional, `--project ` fills them with the entry's `cloudId` (`config.md`, "Cloud projects"). 5. **Identifiers are named by what they identify.** A query or body parameter that carries identifiers, `uid`, `uids`, `batchUids`, `indexUids`, is named after the resource it identifies: the command's own resource gives `--id` or `--ids`, another resource gives `---ids`. So `add indexes --id movies` and `add keys --id ` carry `uid`; `get tasks --ids 1,2 --batch-ids 7 --index-ids movies` carries `uids`, `batchUids` and `indexUids`; `get batches --ids 7 --task-ids 1,2` carries `batchUids` and `uids`, because bare `uids` are task identifiers everywhere. The API spellings, `--uid`, `--uids`, `--batch-uids`, `--index-uids`, are accepted as aliases, absent from `--help` and `meili schema`. One exception: on `update indexes`, `--id` is the index itself, so the new `uid` is `--new-id`, alias `--uid`. Every other body field keeps its API name, `--primary-key`, `--name`. -6. **Other flags.** The query parameters and the first-level fields of the JSON body, in kebab-case of the API name: `retrieveVectors` is `--retrieve-vectors`, `teamId` is `--team-id`, `primaryKey` is `--primary-key`. When a body has a single object property named after the resource, `{"project": {"name": …}}` in the Management API, the fields of that object are the flags. The only flags that do not come from a route are the global flags and the CLI-owned flags: `--wait`, `--timeout`, `--all`, `--file`, `--data`, `--yes`. +6. **Other flags.** The query parameters and the first-level fields of the JSON body, in kebab-case of the API name: `retrieveVectors` is `--retrieve-vectors`, `teamId` is `--team-id`, `primaryKey` is `--primary-key`. The only flags that do not come from a route are the global flags and the CLI-owned flags: `--wait`, `--timeout`, `--all`, `--file`, `--data`, `--yes`, `--add-to-config`. 7. **Values.** A scalar is passed as it is. A boolean is a bare flag, `--retrieve-vectors`. An array of scalars is comma-separated, `--fields id,title`, as the API's own GET syntax. An object or a nested array is JSON text, `--typo-tolerance '{"enabled":false}'`. The value `null` sends a JSON null, `--expires-at null`, `--stop-words null`. 8. **Body input.** On every route with a body, `--file ` or `--data ''` replaces the body fields; `--file -` reads stdin. Path and query flags stay valid next to them. A body-field flag given together with `--file` or `--data` overrides that field, as a flag overrides its environment variable. When a route's body is not an object of named fields but the value itself, the array of identifiers of `delete documents --ids` or the documents of `add documents`, the body is that value, and its shape selects the route among the ones the command covers: `delete documents --data '[1,2]'` deletes by identifiers, `--data '{"filter":…}'` deletes by filter. Documents are sent as JSON, the only content type the document declares. 9. **Routes that are not implemented.** The `GET` twin of a `POST` route that takes the same parameters in a body (search, similar, the list of documents); the unit route of a resource whose list filters by identifiers (documents, tasks, batches); the settings sub-routes, since `update settings` sends any subset and `null` resets one setting; `GET /api/v2/meilisearch-versions`. Each is recorded without command in the exceptions. @@ -52,8 +52,10 @@ Every API command declares its routes, `static routes = ["GET /indexes", "GET /i | `DELETE /tasks` | `meili delete tasks --ids --yes` | | `GET /keys`, `GET /keys/{key}` | `meili get keys [--id ]` | | `POST /dumps` | `meili add dumps` | -| `GET /api/v2/projects`, `GET /api/v2/projects/{id}` | `meili get projects [--id ] [--team-id ]` | -| `POST /api/v2/projects` | `meili add projects --name --team-id --resource-id ` | +| `GET /api/v2/projects`, `GET /api/v2/projects/{id}` | `meili get projects [--id \| --project ] [--team-id ]` | +| `POST /api/v2/projects` | `meili add projects --name --team-id --resource-id [--ms-version ] [--add-to-config]` | +| `PATCH /api/v2/projects/{id}` | `meili update projects (--id \| --project ) --name ` | +| `DELETE /api/v2/projects/{id}` | `meili delete projects (--id \| --project ) --yes` | | `GET /api/v2/team-members` | `meili get team-members --team-id ` | ## Exceptions @@ -106,7 +108,7 @@ These two flags work on every command, whatever it talks to. | `-o, --output ` | `json` (default) or `ndjson`. See `output.md`. | | `--debug` | Print every HTTP request and response on stderr, secrets redacted. | -`--management-token ` and `--management-url `, the pairs of `MEILI_MANAGEMENT_TOKEN` and `MEILI_MANAGEMENT_URL`, join the management commands with the first of them. +`--management-token ` and `--management-url ` are the pair of flags of the Management group below: they reach the Management API, not every command. ## Shared flag groups @@ -118,10 +120,23 @@ Commands reuse these groups. A command that matches a group uses the whole group |---|---| | `--project-url ` | Project URL, the Meilisearch instance to talk to. | | `--project-api-key ` | Project API key. `-` reads it from stdin. | -| `--project ` | A project of the config file. Cannot be combined with `--project-url`. Required when the file has several projects. See `config.md`. | +| `--project ` | A project of the config file: its URL and key for a Meilisearch command, its `cloudId` for a management command. Cannot be combined with `--project-url`, nor with `--id`. Required when the file has several projects. See `config.md`. | `meili config set` writes these three values rather than connecting with them, so it declares its own, with its own descriptions. See ADR 0010. +**Management** (every command that talks to the Management API, and no other): + +| Flag | Meaning | +|---|---| +| `--management-token ` | Management API personal access token. The pair of `MEILI_MANAGEMENT_TOKEN`; without either, the session of `meili auth login` is used. | +| `--management-url ` | Management API base URL. The pair of `MEILI_MANAGEMENT_URL`. Default `https://api.meilisearch.com`. | + +**Cloud project** (every management command that acts on one project, next to its `--id`, and no other: `add projects` creates one rather than reaching one, so it does not offer it): + +| Flag | Meaning | +|---|---| +| `--project ` | The entry of the config file whose `cloudId` names the project, in place of `--id`. Cannot be combined with `--id`. See `config.md`. | + **Write commands** (any command whose API response is a task): | Flag | Meaning | @@ -150,16 +165,23 @@ Commands reuse these groups. A command that matches a group uses the whole group |---|---| | `--yes` | Confirm the deletion. Required: the command refuses to run without it, and no environment variable stands for it. | -## Commands without an API +**Config file** (`add projects`): + +| Flag | Meaning | +|---|---| +| `--add-to-config` | Add the created project to the config file, as an entry named after it with its URL, its master key and its `cloudId`. See `config.md`. | + +## Commands that do not derive from a route -These commands call neither of the two APIs of this contract, so no route derives them. They are the only ones listed here; their `--help` defines their shape. Their name is never the name of an API resource. The `config` and `auth` commands are for humans: an agent works with the file and the token it was given, see `config.md`. The four `auth` commands do speak HTTP, to the authorization server of Meilisearch Cloud, which no OpenAPI document of this repository describes; ADR 0009 says why they live here. +These commands are the only ones listed here; their `--help` defines their shape. Their name is never the name of an API resource. The `config` and `auth` commands are for humans: an agent works with the file and the token it was given, see `config.md`. The four `auth` commands do speak HTTP, to the authorization server of Meilisearch Cloud, which no OpenAPI document of this repository describes; ADR 0009 says why they live here. `config sync-with-cloud` speaks to the Management API itself, with `static api = "management"` and no route of its own. | Command | Effect | |---|---| | `meili schema` | Print the index of the command tree as JSON: the global flags once, then one entry per command with its description and its routes. `meili schema --command "get indexes"` prints that one command in full: its API, its routes, its key actions, whether it returns a task, the flags it declares itself and its examples. Generated from the oclif manifest. This is how an agent learns the CLI: the index in one call, a command when it needs it. See ADR 0008. | | `meili config list` | Print the config file path and its projects as JSON, keys masked. | -| `meili config set --project --project-url [--project-api-key ]` | Set a project in the config file, creating the file in mode `600`. `--project-api-key -` reads the key from stdin. Refuses a URL that is not HTTP(S) with `cli_usage`, and a name that already exists with `cli_config`: changing an entry is an `unset` then a `set`, on purpose. Nothing on stdout. | +| `meili config set --project --project-url [--project-api-key ] [--project-cloud-id ]` | Set a project in the config file, creating the file in mode `600`. `--project-api-key -` reads the key from stdin. Refuses a URL that is not HTTP(S) with `cli_usage`, and a name that already exists with `cli_config`: changing an entry is an `unset` then a `set`, on purpose. Nothing on stdout. | | `meili config unset --project ` | Remove a project from the config file. Nothing on stdout. | +| `meili config sync-with-cloud [--team-id ]` | List the Meilisearch Cloud projects the token sees and set `cloudId` on the config file entries that have a project's URL. Never writes a key, never adds or removes an entry. Prints `{"updated", "unchanged", "missing"}`, lists of entry names; `missing` is empty with `--team-id`. See `config.md`. | | `meili auth login` | Run the OAuth2 flow with PKCE against Meilisearch Cloud, as the OAuth application of this CLI, which `--management-client-id` replaces on another Cloud: print the consent URL on stderr, open it in a browser unless `--no-browser`, receive the code on a loopback server, and write the session to the credentials file in mode `600`. Nothing on stdout: the session is the data and it goes to the file, like `config set`. One line on stderr when stderr is a TTY. The one command that waits for a human: five minutes, then `cli_timeout` and exit 5. See `config.md`. | | `meili auth status` | Say whether a session exists and whether the authorization server still accepts it, through `GET /oauth/token/info`. On a live session, exit 0 and the Cloud it was issued by, the `scope`, the `expiresAt` the server reports and the path of the credentials file on stdout. Otherwise one error and nothing on stdout, as everywhere else: `cli_auth_missing` when nobody is logged in, `cli_auth_failed` when the server no longer accepts the session, both exit 3. A spent access token is refreshed first, like any management command would. | | `meili auth refresh` | Trade the refresh token of the stored session for a new access token and write it back, without waiting for the old one to expire. Nothing on stdout, one line on stderr when stderr is a TTY. `cli_auth_missing` when nobody is logged in, `cli_auth_failed` when the session carries no refresh token or the server refuses it, both exit 3. | diff --git a/docs/contract/config.md b/docs/contract/config.md index 0de4d4b..6ddab8f 100644 --- a/docs/contract/config.md +++ b/docs/contract/config.md @@ -4,11 +4,11 @@ This file defines how `meili` finds the APIs to talk to and how it treats secret ## Principle -An agent must be able to run `meili` with environment variables only, in a fresh container, with no `HOME`. A config file exists for the people and the agents who work with several projects, and nothing is ever deduced from it: a project is named on the command line, or there is exactly one to choose from, or the command fails and says what it found. +An agent must be able to run `meili` with environment variables only, in a fresh container, with no `HOME`. A config file exists for the people and the agents who work with several projects, and nothing is ever deduced from it: a project is named on the command line, or there is exactly one to choose from, or the command fails and says what it found. The Management API is consulted only when the command line asks for it, never to resolve the target of a command. ## Projects -A project is a Meilisearch instance: the one you run, or a project on Meilisearch Cloud. The CLI needs its URL and, when the instance is protected, one of its API keys. A project comes from one of three sources: +A project is a Meilisearch instance: the one you run, or a project on Meilisearch Cloud. A Meilisearch command needs its URL and, when the instance is protected, one of its API keys; a management command needs its id in Meilisearch Cloud, see "Cloud projects". A project comes from one of three sources: 1. the command line: `--project-url ` and `--project-api-key `, or `--project `, an entry of the config file 2. the environment: `MEILI_PROJECT_URL` and `MEILI_PROJECT_API_KEY` @@ -22,16 +22,16 @@ The path is `$MEILI_CONFIG` if set, else `$XDG_CONFIG_HOME/meili/config.json`, e { "projects": { "local": { "url": "http://localhost:7700" }, - "prod": { "url": "https://ms-abc.sfo.meilisearch.io", "apiKey": "…" } + "prod": { "url": "https://ms-abc.sfo.meilisearch.io", "apiKey": "…", "cloudId": 343 } } } ``` -- `url` is required, `apiKey` is optional. Nothing else: no current project, no default entry. +- `url` is required, `apiKey` and `cloudId` are optional. `cloudId` is the project's `id` in Meilisearch Cloud, an integer. Nothing else: no current project, no default entry. - The file holds API keys, so it belongs to one user and is readable by that user alone: mode `600`, or `400` for a file an agent must not change. When the file is readable by others, the CLI warns on stderr with the `chmod` to run (`output.md`, "Warnings") and goes on. On Windows, where file modes do not exist, there is no check. A file that does not exist is not an error: the CLI behaves as if there were no file. - Invalid JSON, or a shape other than the one above, fails with `cli_config` and the path of the file. - The file is shared state: every process that reads it is affected by whoever writes it. Give an agent its own file through `MEILI_CONFIG`, prepared by a human and mounted read-only in its sandbox, never the human's file. An agent adds, removes or changes a project, or the permissions of the file, only when the human asked for it explicitly: the file decides where every later command goes, for every process that reads it. `meili --help`, the help of the `config` commands and `meili schema` say so. -- `meili config list`, `meili config set` and `meili config unset` manage the file, see `commands.md`. `config set` refuses a name that already exists: changing an entry is an `unset` then a `set`, on purpose. +- `meili config list`, `meili config set`, `meili config unset` and `meili config sync-with-cloud` manage the file, see `commands.md`. `config set` refuses a name that already exists: changing an entry is an `unset` then a `set`, on purpose. ## Resolution: nothing is deduced @@ -50,6 +50,20 @@ The CLI resolves the project of a command in this order, and stops at the first A missing key is not an error at resolution time; the API answers 401 and the CLI exits 3 with a hint. The CLI never falls back to `http://localhost:7700` silently. +## Cloud projects + +A management command acts on a project of Meilisearch Cloud, identified by its integer `id`, `meili update projects --id 343`. The id is not shown in the Cloud dashboard, so the config file can carry it, as `cloudId`, and `--project ` gives it to the command: `meili update projects --project prod --name renamed`. + +- `--project` and `--id` together are a `cli_usage` error, exit 2: the name and the id contradict each other. Neither is `cli_usage` too. +- An entry without `cloudId` is a `cli_config` error, exit 2, whose hint names the ways to set it. The CLI never resolves the id by itself, from the URL or otherwise. +- A management command that reaches no project, `add projects`, does not offer `--project` at all: the flag is not ignored in silence, it does not exist there. + +Three ways set `cloudId`, all explicit, the only moments the CLI consults the Management API on the config file's behalf: + +1. `meili add projects … --add-to-config`: the created project joins the file, as an entry named after it, with its URL, its master key as `apiKey` and its `cloudId`. Creation is the one moment the API returns the key. A name the file already has is a `cli_config` error before the project is created. +2. `meili config set --project --project-url --project-cloud-id `: the id, written by hand. +3. `meili config sync-with-cloud [--team-id ]`: lists the projects the token sees, and sets `cloudId` on every entry without one whose URL is one of theirs, same scheme and host. The other projects are ignored: an entry without its key would serve no Meilisearch command, and the key is not returned. The entries whose `cloudId` the token no longer sees are reported as `missing` and kept. No key is ever written, no entry is added or removed: a key in the file is always the human's choice. + ## Management API connection: resolution order The credential is a bearer token: either a personal access token (PAT) created in the Meilisearch Cloud dashboard, or the OAuth access token of `meili auth login`. The API takes both, on the same header, `Authorization: Bearer `. @@ -60,7 +74,7 @@ The credential is a bearer token: either a personal access token (PAT) created i If nothing is found, the CLI fails before any network call with `cli_auth_missing` and exit code 3. The hint names both ways in. -The base URL comes from `--management-url`, then `MEILI_MANAGEMENT_URL`, and defaults to `https://api.meilisearch.com`. Every path starts with `/api/v2`. `--management-token` arrives with the first management command; `--management-url` already exists on `meili auth login`. +The base URL comes from `--management-url`, then `MEILI_MANAGEMENT_URL`, and defaults to `https://api.meilisearch.com`. Every path starts with `/api/v2`. ## The credentials file @@ -123,10 +137,10 @@ A command cannot build a client by itself. Commands with `api = "none"` (`schema Every variable is documented in `meili --help`. Adding a variable is a contract change. -A variable that doubles a flag takes its name from the flag: `MEILI_` followed by the flag name in upper snake case. `--project-url` and `MEILI_PROJECT_URL`, `--timeout` and `MEILI_TIMEOUT`, `--management-token` and `MEILI_MANAGEMENT_TOKEN`. `MEILI_MANAGEMENT_TOKEN` and `MEILI_MANAGEMENT_URL` get their flags with the first management command. `MEILI_CONFIG` has no flag, the file is chosen per process, and `--project` has no variable, a project is chosen per command. The contract tests check the pairs. +A variable that doubles a flag takes its name from the flag: `MEILI_` followed by the flag name in upper snake case. `--project-url` and `MEILI_PROJECT_URL`, `--timeout` and `MEILI_TIMEOUT`, `--management-token` and `MEILI_MANAGEMENT_TOKEN`. `MEILI_CONFIG` has no flag, the file is chosen per process, and `--project` has no variable, a project is chosen per command. The contract tests check the pairs. ## Secrets - API keys and tokens never appear on stdout, on stderr, in `--debug` traces, in error messages or in snapshots. When a secret must be shown at all, only its last four characters are: `****1a2b`. `meili config list` masks keys the same way. -- `--debug` redacts the `Authorization`, `Cookie` and `Set-Cookie` headers, a session cookie of the authorization server being a credential like any other, and every field named `apiKey`, `apikey`, `key`, `token`, `access_token`, `refresh_token` in request and response bodies. `apikey` is the Management API's spelling of a project's master key, `access_token` and `refresh_token` the spelling of the authorization server. A form-encoded body, which is what the token endpoint takes, is never printed. The redaction lives in `src/lib/config.ts` and is tested. +- `--debug` redacts the `Authorization`, `Cookie` and `Set-Cookie` headers, a session cookie of the authorization server being a credential like any other, and every field named `apiKey`, `apikey`, `masterKey`, `key`, `token`, `access_token`, `refresh_token` in request and response bodies. `apikey` is the Management API's spelling of a project's master key, `masterKey` the spelling of the same key when that API returns a project, `access_token` and `refresh_token` the spelling of the authorization server. A form-encoded body, which is what the token endpoint takes, is never printed. The redaction lives in `src/lib/config.ts` and is tested. - The CLI reads keys from the config file, and from nowhere else on disk, and warns when the file is readable by others. Passing a key in a flag is allowed and documented as visible in the process list and in the shell history; `--project-api-key -` reads it from stdin instead. diff --git a/docs/contract/output.md b/docs/contract/output.md index 6896882..9da8920 100644 --- a/docs/contract/output.md +++ b/docs/contract/output.md @@ -28,9 +28,9 @@ One exception, documented in `commands.md`: commands that do not call an API (`s Three consequences of pass-through: -- Both APIs name their fields in camelCase, but they do not spell everything alike: the Meilisearch API says `apiKey`, the Management API says `apikey`. The CLI does not normalize. An agent reads each API's documentation and gets exactly that. +- Both APIs name their fields in camelCase, but they do not name everything alike: a key is `apiKey` in a Meilisearch config file and `masterKey` on a Cloud project. The CLI does not normalize. An agent reads each API's documentation and gets exactly that. - A command that filters a list instead of calling a unit route returns the list envelope: `get tasks --ids 42` prints `{"results": [the task], …}`, and the agent reads `results[0]`. -- Some API responses contain secrets by design, for example `apikey` on a Meilisearch Cloud project. They are data the agent needs and they go to stdout unchanged. Redaction applies to stderr, `--debug` traces and logs, not to the data stream. +- Some API responses contain secrets by design, for example `masterKey` on a Meilisearch Cloud project. They are data the agent needs and they go to stdout unchanged. Redaction applies to stderr, `--debug` traces and logs, not to the data stream. ## Errors @@ -48,10 +48,10 @@ Errors go to stderr, as one JSON document: compact when stderr is not a TTY, ind } ``` -- API errors keep the API fields as they are: `code`, `type`, `message`, `link`. The CLI never rewrites an API message. +- API errors keep the API fields as they are: `code`, `type`, `message`, `link`. The CLI never rewrites an API message. Both APIs answer that shape, so both are passed through alike, and the CLI invents no code for either. - `hint` is optional and CLI owned. It names the next command to run. It is a full sentence, without markdown other than backticks around commands. On an unknown command, the hint names the closest existing command, "Did you mean `meili delete indexes`?", computed from the manifest: the CLI suggests, it never asks. - Errors that come from the CLI itself use codes prefixed with `cli_` and `type` set to `cli`. Current list: `cli_usage`, `cli_config`, `cli_auth_missing`, `cli_auth_failed`, `cli_timeout`, `cli_network`, `cli_task_failed`, `cli_input_invalid`, `cli_internal`. `cli_auth_missing` is a credential the CLI does not have, `cli_auth_failed` one the authorization server refused to give or to accept. `cli_internal` is an unexpected error inside the CLI itself, a bug to report, and exits 1. Adding a code is a contract change. -- The Management API does not define an error body in its spec, and the running API answers with `{"error": "Forbidden"}`: a message, no code. For its errors the CLI builds the object itself: `type` is `management`, `code` comes from the HTTP status (`management_unauthorized` 401, `management_forbidden` 403, `management_not_found` 404, `management_unprocessable` 422, `management_error` otherwise), `message` is the API's `error` field when the body has one and the spec's response description otherwise, `link` is absent. When the spec gains an error schema with codes, this rule is replaced by pass-through. +- An HTTP error that carries no error body is not the API speaking: a proxy or a wrong URL answered. It is `cli_network`, exit 6, naming the server and the status, for either API. - Stack traces appear only with `--debug`. ## Warnings @@ -111,6 +111,7 @@ Destructive commands are those of the verb `delete`, as `commands.md` derives th - `--limit` and `--offset` (or `--from` for tasks and batches) are passed to the API as they are. When `--id` selects a unit route, they are ignored and not sent. - `--all` follows the API pagination until the end. With `ndjson` the items stream. With `json` the CLI returns the last page envelope with `results` replaced by the full list and the fields that describe one page removed: `limit` and `offset`, or `limit` and `from` on the routes that page with a cursor. +- `meili get projects` offers none of them yet: the Management API numbers its pages instead of counting an offset, and the flags wait for the two APIs to page the same way. Its first page goes out as the API sends it, envelope included, and `ndjson` streams the `results` of that page. ## Help diff --git a/docs/contract/testing.md b/docs/contract/testing.md index 2cb25c5..9922701 100644 --- a/docs/contract/testing.md +++ b/docs/contract/testing.md @@ -20,12 +20,11 @@ A command whose output carries a path or a date, as the `auth` commands do, snap ## 2. Integration tests, `test/integration/` -They hit real local instances of both APIs, with no mock, through the built binary and the same scrubbed environment as the unit tests. +They hit a real local Meilisearch, with no mock, through the built binary and the same scrubbed environment as the unit tests: `test/integration/meilisearch/`, against the instance started by the `docker-compose.yml` of this repository on `http://localhost:7700` with the master key `masterKey`. -- `test/integration/meilisearch/`: against Meilisearch, started by the `docker-compose.yml` of this repository on `http://localhost:7700` with the master key `masterKey`. -- `test/integration/management/`: against the Management API from the `meilisearch-cloud` repository. Known facts: it starts with `docker compose up` in that repository; its images are private, so CI needs a registry token to pull them; the seed creates no user and no personal access token, so the test bootstrap must create both before the suite runs (a script in `test/integration/management/bootstrap/`, run once by CI and by `pnpm test:integration`). The base URL, the bootstrap command and the token it produces are documented in `test/integration/management/README.md`, which is the only place where they live. Building this bootstrap is a task of its own, before the first management command. +The Management API has no integration test. Meilisearch Cloud does not run locally, and `openapi/management.json` is the source of truth of what the API does: the unit tests mock it from that document, `test/contract/api.test.ts` checks every declared route against it, and the client is generated from it. What the document does not say, a required field, a status, an error body, is a documentation gap reported to the Cloud team, never guessed in the CLI. -Integration tests create their own fixtures (an index, a team, a project) with a random suffix and delete them at the end. They never depend on pre-existing data, so they can run in parallel. +Integration tests create their own fixtures (an index, documents) with a random suffix and delete them at the end. They never depend on pre-existing data, so they can run in parallel. ## 3. Contract tests, `test/contract/` @@ -35,7 +34,7 @@ Not per command. They protect the promises of `docs/contract/`: - every command declares `static api` - every command has a description and at least two examples; every flag has a description, and a flag with a value documents it - every command in `src/commands/` has its test file in `test/commands/` -- exit codes match the table in `output.md` for each `cli_` and `management_` code +- exit codes match the table in `output.md` for each `cli_` code, and the CLI invents none for either API - `--help` never opens a network connection - redaction removes every secret from `--debug` traces @@ -58,6 +57,5 @@ They read the OpenAPI documents in `openapi/`, downloaded by `pnpm openapi` and | Unit | yes | yes | | Contract | yes | yes | | Integration Meilisearch | when Meilisearch is up on :7700, skipped otherwise with a visible notice | yes | -| Integration management | when the Management API is up locally, skipped otherwise with a visible notice | yes | Skipped is printed, never silent. A test that passes because the server is absent is a bug. diff --git a/oclif.manifest.json b/oclif.manifest.json index 9d2c452..25527bc 100644 --- a/oclif.manifest.json +++ b/oclif.manifest.json @@ -161,6 +161,31 @@ "type": "option" } }, + "managementFlags": { + "management-token": { + "description": "Management API personal access token.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "management-url": { + "description": "Management API base URL. Default https://api.meilisearch.com.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "cloudProjectFlags": { + "project": { + "description": "A project of the config file, whose cloudId names the Cloud project, in place of --id.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, "waitFlags": { "wait": { "description": "Poll the task until it reaches a final status, then print the final task.", @@ -403,6 +428,31 @@ "type": "option" } }, + "managementFlags": { + "management-token": { + "description": "Management API personal access token.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "management-url": { + "description": "Management API base URL. Default https://api.meilisearch.com.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "cloudProjectFlags": { + "project": { + "description": "A project of the config file, whose cloudId names the Cloud project, in place of --id.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, "waitFlags": { "wait": { "description": "Poll the task until it reaches a final status, then print the final task.", @@ -497,14 +547,17 @@ "indexes.js" ] }, - "auth:login": { + "add:projects": { "aliases": [], "args": {}, - "description": "Log in to Meilisearch Cloud with a browser, and store the token for the management commands.", + "description": "Create a Meilisearch Cloud project in a team, on a resource.", "examples": [ - "<%= config.bin %> <%= command.id %>", - "<%= config.bin %> <%= command.id %> --output json", - "<%= config.bin %> <%= command.id %> --no-browser" + "<%= config.bin %> <%= command.id %> --name demo --team-id 12 --resource-id 3", + "<%= config.bin %> <%= command.id %> --name demo --team-id 12 --resource-id 3 --output json", + "<%= config.bin %> <%= command.id %> --name demo --team-id 12 --resource-id 3 --add-to-config", + "<%= config.bin %> <%= command.id %> --name demo --team-id 12 --resource-id 3 --ms-version v1.54.0", + "<%= config.bin %> <%= command.id %> --data '{\"name\":\"demo\",\"teamId\":12,\"resourceId\":3}'", + "<%= config.bin %> <%= command.id %> --file project.json --name other-name" ], "flags": { "output": { @@ -526,47 +579,92 @@ "allowNo": false, "type": "boolean" }, - "management-client-id": { - "description": "The OAuth application to log in with, on a Cloud that is not Meilisearch Cloud. Also MEILI_MANAGEMENT_CLIENT_ID.", - "name": "management-client-id", + "management-token": { + "description": "Management API personal access token.", + "name": "management-token", "hasDynamicHelp": false, - "helpValue": "", + "helpValue": "", "multiple": false, "type": "option" }, "management-url": { - "description": "Management API base URL, which holds the token endpoint.", + "description": "Management API base URL. Default https://api.meilisearch.com.", "name": "management-url", "hasDynamicHelp": false, "helpValue": "", "multiple": false, "type": "option" }, - "management-dashboard-url": { - "description": "Dashboard URL, which hosts the consent page.", - "name": "management-dashboard-url", + "name": { + "description": "Name of the project.", + "name": "name", "hasDynamicHelp": false, - "helpValue": "", + "helpValue": "", "multiple": false, "type": "option" }, - "browser": { - "description": "Open the consent page in a browser. --no-browser only prints the URL.", - "name": "browser", - "allowNo": true, + "team-id": { + "description": "Team that owns the project.", + "name": "team-id", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "resource-id": { + "description": "Resource of the project: its plan and its region.", + "name": "resource-id", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "ms-version": { + "description": "Meilisearch version of the project, the latest by default.", + "name": "ms-version", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "add-to-config": { + "description": "Add the project to the config file, as an entry named after it with its URL, its master key and its cloudId.", + "name": "add-to-config", + "allowNo": false, "type": "boolean" + }, + "file": { + "description": "Read the body from a file. `-` reads from stdin.", + "exclusive": [ + "data" + ], + "name": "file", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "data": { + "description": "Inline JSON body.", + "name": "data", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" } }, "hasDynamicHelp": false, "hiddenAliases": [], - "id": "auth:login", + "id": "add:projects", "pluginAlias": "@meilisearch/cli", "pluginName": "@meilisearch/cli", "pluginType": "core", "strict": true, "enableJsonFlag": false, - "api": "none", - "routes": [], + "api": "management", + "routes": [ + "POST /api/v2/projects" + ], "keyActions": [], "connectionFlags": { "project-url": { @@ -591,6 +689,31 @@ "type": "option" } }, + "managementFlags": { + "management-token": { + "description": "Management API personal access token.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "management-url": { + "description": "Management API base URL. Default https://api.meilisearch.com.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "cloudProjectFlags": { + "project": { + "description": "A project of the config file, whose cloudId names the Cloud project, in place of --id.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, "waitFlags": { "wait": { "description": "Poll the task until it reaches a final status, then print the final task.", @@ -681,17 +804,18 @@ "relativePath": [ "dist", "commands", - "auth", - "login.js" + "add", + "projects.js" ] }, - "auth:logout": { + "auth:login": { "aliases": [], "args": {}, - "description": "Revoke the token of `auth login` and forget it. Does nothing when nobody is logged in.", + "description": "Log in to Meilisearch Cloud with a browser, and store the token for the management commands.", "examples": [ "<%= config.bin %> <%= command.id %>", - "<%= config.bin %> <%= command.id %> --output json" + "<%= config.bin %> <%= command.id %> --output json", + "<%= config.bin %> <%= command.id %> --no-browser" ], "flags": { "output": { @@ -712,11 +836,41 @@ "name": "debug", "allowNo": false, "type": "boolean" + }, + "management-client-id": { + "description": "The OAuth application to log in with, on a Cloud that is not Meilisearch Cloud. Also MEILI_MANAGEMENT_CLIENT_ID.", + "name": "management-client-id", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "management-url": { + "description": "Management API base URL, which holds the token endpoint.", + "name": "management-url", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "management-dashboard-url": { + "description": "Dashboard URL, which hosts the consent page.", + "name": "management-dashboard-url", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "browser": { + "description": "Open the consent page in a browser. --no-browser only prints the URL.", + "name": "browser", + "allowNo": true, + "type": "boolean" } }, "hasDynamicHelp": false, "hiddenAliases": [], - "id": "auth:logout", + "id": "auth:login", "pluginAlias": "@meilisearch/cli", "pluginName": "@meilisearch/cli", "pluginType": "core", @@ -748,6 +902,31 @@ "type": "option" } }, + "managementFlags": { + "management-token": { + "description": "Management API personal access token.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "management-url": { + "description": "Management API base URL. Default https://api.meilisearch.com.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "cloudProjectFlags": { + "project": { + "description": "A project of the config file, whose cloudId names the Cloud project, in place of --id.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, "waitFlags": { "wait": { "description": "Poll the task until it reaches a final status, then print the final task.", @@ -839,13 +1018,13 @@ "dist", "commands", "auth", - "logout.js" + "login.js" ] }, - "auth:refresh": { + "auth:logout": { "aliases": [], "args": {}, - "description": "Trade the refresh token of the stored session for a new access token, before it expires.", + "description": "Revoke the token of `auth login` and forget it. Does nothing when nobody is logged in.", "examples": [ "<%= config.bin %> <%= command.id %>", "<%= config.bin %> <%= command.id %> --output json" @@ -873,7 +1052,7 @@ }, "hasDynamicHelp": false, "hiddenAliases": [], - "id": "auth:refresh", + "id": "auth:logout", "pluginAlias": "@meilisearch/cli", "pluginName": "@meilisearch/cli", "pluginType": "core", @@ -905,6 +1084,31 @@ "type": "option" } }, + "managementFlags": { + "management-token": { + "description": "Management API personal access token.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "management-url": { + "description": "Management API base URL. Default https://api.meilisearch.com.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "cloudProjectFlags": { + "project": { + "description": "A project of the config file, whose cloudId names the Cloud project, in place of --id.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, "waitFlags": { "wait": { "description": "Poll the task until it reaches a final status, then print the final task.", @@ -996,13 +1200,13 @@ "dist", "commands", "auth", - "refresh.js" + "logout.js" ] }, - "auth:status": { + "auth:refresh": { "aliases": [], "args": {}, - "description": "Say whether a session of `auth login` exists and whether the authorization server still accepts it.", + "description": "Trade the refresh token of the stored session for a new access token, before it expires.", "examples": [ "<%= config.bin %> <%= command.id %>", "<%= config.bin %> <%= command.id %> --output json" @@ -1030,7 +1234,7 @@ }, "hasDynamicHelp": false, "hiddenAliases": [], - "id": "auth:status", + "id": "auth:refresh", "pluginAlias": "@meilisearch/cli", "pluginName": "@meilisearch/cli", "pluginType": "core", @@ -1062,6 +1266,31 @@ "type": "option" } }, + "managementFlags": { + "management-token": { + "description": "Management API personal access token.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "management-url": { + "description": "Management API base URL. Default https://api.meilisearch.com.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "cloudProjectFlags": { + "project": { + "description": "A project of the config file, whose cloudId names the Cloud project, in place of --id.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, "waitFlags": { "wait": { "description": "Poll the task until it reaches a final status, then print the final task.", @@ -1153,18 +1382,16 @@ "dist", "commands", "auth", - "status.js" + "refresh.js" ] }, - "cancel:tasks": { + "auth:status": { "aliases": [], "args": {}, - "description": "Cancel the enqueued and processing tasks matched by --ids, --statuses, --types and the other filters of the route.", + "description": "Say whether a session of `auth login` exists and whether the authorization server still accepts it.", "examples": [ - "<%= config.bin %> <%= command.id %> --ids 42,1344", - "<%= config.bin %> <%= command.id %> --ids 42 --output json", - "<%= config.bin %> <%= command.id %> --statuses enqueued --index-ids movies", - "<%= config.bin %> <%= command.id %> --statuses processing --wait" + "<%= config.bin %> <%= command.id %>", + "<%= config.bin %> <%= command.id %> --output json" ], "flags": { "output": { @@ -1185,26 +1412,210 @@ "name": "debug", "allowNo": false, "type": "boolean" - }, + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [], + "id": "auth:status", + "pluginAlias": "@meilisearch/cli", + "pluginName": "@meilisearch/cli", + "pluginType": "core", + "strict": true, + "enableJsonFlag": false, + "api": "none", + "routes": [], + "keyActions": [], + "connectionFlags": { "project-url": { "description": "Project URL, the Meilisearch instance to talk to.", - "name": "project-url", - "hasDynamicHelp": false, "helpValue": "", + "input": [], "multiple": false, "type": "option" }, "project-api-key": { "description": "Project API key. `-` reads it from stdin.", - "name": "project-api-key", - "hasDynamicHelp": false, "helpValue": "", + "input": [], "multiple": false, "type": "option" }, "project": { "description": "A project of the config file. Cannot be combined with --project-url. Required when the file has several projects.", - "name": "project", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "managementFlags": { + "management-token": { + "description": "Management API personal access token.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "management-url": { + "description": "Management API base URL. Default https://api.meilisearch.com.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "cloudProjectFlags": { + "project": { + "description": "A project of the config file, whose cloudId names the Cloud project, in place of --id.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "waitFlags": { + "wait": { + "description": "Poll the task until it reaches a final status, then print the final task.", + "allowNo": false, + "type": "boolean" + }, + "timeout": { + "description": "Maximum wait time, for example 30s, 2m. Default 60s. Only with --wait.", + "helpValue": "", + "dependsOn": [ + "wait" + ], + "input": [], + "multiple": false, + "type": "option" + } + }, + "paginationFlags": { + "limit": { + "description": "Passed to the API.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "offset": { + "description": "Passed to the API.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "all": { + "description": "Fetch every page. Streams with ndjson, buffers into one array with json.", + "allowNo": false, + "type": "boolean" + } + }, + "fromPaginationFlags": { + "limit": { + "description": "Passed to the API.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "from": { + "description": "Passed to the API: the uid the page starts at, going down.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "all": { + "description": "Fetch every page. Streams with ndjson, buffers into one array with json.", + "allowNo": false, + "type": "boolean" + } + }, + "bodyInputFlags": { + "file": { + "description": "Read the body from a file. `-` reads from stdin.", + "helpValue": "", + "exclusive": [ + "data" + ], + "input": [], + "multiple": false, + "type": "option" + }, + "data": { + "description": "Inline JSON body.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "confirmFlags": { + "yes": { + "required": true, + "description": "Confirm the deletion. Nothing is sent to the API without it.", + "allowNo": false, + "type": "boolean" + } + }, + "isESM": true, + "relativePath": [ + "dist", + "commands", + "auth", + "status.js" + ] + }, + "cancel:tasks": { + "aliases": [], + "args": {}, + "description": "Cancel the enqueued and processing tasks matched by --ids, --statuses, --types and the other filters of the route.", + "examples": [ + "<%= config.bin %> <%= command.id %> --ids 42,1344", + "<%= config.bin %> <%= command.id %> --ids 42 --output json", + "<%= config.bin %> <%= command.id %> --statuses enqueued --index-ids movies", + "<%= config.bin %> <%= command.id %> --statuses processing --wait" + ], + "flags": { + "output": { + "char": "o", + "description": "json (default): one document, the API body as it is, indented in a terminal, compact when piped. ndjson: one item per line, list commands only, streams with --all.", + "name": "output", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "options": [ + "json", + "ndjson" + ], + "type": "option" + }, + "debug": { + "description": "Print every HTTP request and response on stderr, secrets redacted.", + "name": "debug", + "allowNo": false, + "type": "boolean" + }, + "project-url": { + "description": "Project URL, the Meilisearch instance to talk to.", + "name": "project-url", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "project-api-key": { + "description": "Project API key. `-` reads it from stdin.", + "name": "project-api-key", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "project": { + "description": "A project of the config file. Cannot be combined with --project-url. Required when the file has several projects.", + "name": "project", "hasDynamicHelp": false, "helpValue": "", "multiple": false, @@ -1372,6 +1783,31 @@ "type": "option" } }, + "managementFlags": { + "management-token": { + "description": "Management API personal access token.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "management-url": { + "description": "Management API base URL. Default https://api.meilisearch.com.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "cloudProjectFlags": { + "project": { + "description": "A project of the config file, whose cloudId names the Cloud project, in place of --id.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, "waitFlags": { "wait": { "description": "Poll the task until it reaches a final status, then print the final task.", @@ -1530,6 +1966,31 @@ "type": "option" } }, + "managementFlags": { + "management-token": { + "description": "Management API personal access token.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "management-url": { + "description": "Management API base URL. Default https://api.meilisearch.com.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "cloudProjectFlags": { + "project": { + "description": "A project of the config file, whose cloudId names the Cloud project, in place of --id.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, "waitFlags": { "wait": { "description": "Poll the task until it reaches a final status, then print the final task.", @@ -1631,6 +2092,7 @@ "examples": [ "<%= config.bin %> <%= command.id %> --project local --project-url http://localhost:7700", "<%= config.bin %> <%= command.id %> --project prod --project-url https://ms-abc.sfo.meilisearch.io --project-api-key - < key.txt", + "<%= config.bin %> <%= command.id %> --project prod --project-url https://ms-abc.sfo.meilisearch.io --project-cloud-id 343", "<%= config.bin %> <%= command.id %> --project local --project-url http://localhost:7700 --output json" ], "flags": { @@ -1678,6 +2140,14 @@ "helpValue": "", "multiple": false, "type": "option" + }, + "project-cloud-id": { + "description": "Id of the project in Meilisearch Cloud, for the management commands.", + "name": "project-cloud-id", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" } }, "hasDynamicHelp": false, @@ -1714,6 +2184,31 @@ "type": "option" } }, + "managementFlags": { + "management-token": { + "description": "Management API personal access token.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "management-url": { + "description": "Management API base URL. Default https://api.meilisearch.com.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "cloudProjectFlags": { + "project": { + "description": "A project of the config file, whose cloudId names the Cloud project, in place of --id.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, "waitFlags": { "wait": { "description": "Poll the task until it reaches a final status, then print the final task.", @@ -1808,13 +2303,14 @@ "set.js" ] }, - "config:unset": { + "config:sync-with-cloud": { "aliases": [], "args": {}, - "description": "Remove a project from the config file.\n\nThe file holds API keys and decides where every later command goes, for every process that reads it: an agent runs this only when the human asked for it explicitly.", + "description": "Set the cloudId of the config file entries whose URL is a Meilisearch Cloud project the token sees. Never writes a key, never adds or removes an entry. Prints what changed.\n\nThe file holds API keys and decides where every later command goes, for every process that reads it: an agent runs this only when the human asked for it explicitly.", "examples": [ - "<%= config.bin %> <%= command.id %> --project prod", - "<%= config.bin %> <%= command.id %> --project prod --output json" + "<%= config.bin %> <%= command.id %>", + "<%= config.bin %> <%= command.id %> --team-id 12", + "<%= config.bin %> <%= command.id %> --output json" ], "flags": { "output": { @@ -1836,25 +2332,40 @@ "allowNo": false, "type": "boolean" }, - "project": { - "description": "Name of the project to remove.", - "name": "project", - "required": true, + "management-token": { + "description": "Management API personal access token.", + "name": "management-token", "hasDynamicHelp": false, - "helpValue": "", + "helpValue": "", + "multiple": false, + "type": "option" + }, + "management-url": { + "description": "Management API base URL. Default https://api.meilisearch.com.", + "name": "management-url", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "team-id": { + "description": "Only the projects of this team; missing entries are then not reported.", + "name": "team-id", + "hasDynamicHelp": false, + "helpValue": "", "multiple": false, "type": "option" } }, "hasDynamicHelp": false, "hiddenAliases": [], - "id": "config:unset", + "id": "config:sync-with-cloud", "pluginAlias": "@meilisearch/cli", "pluginName": "@meilisearch/cli", "pluginType": "core", "strict": true, "enableJsonFlag": false, - "api": "none", + "api": "management", "routes": [], "keyActions": [], "connectionFlags": { @@ -1880,13 +2391,229 @@ "type": "option" } }, - "waitFlags": { - "wait": { - "description": "Poll the task until it reaches a final status, then print the final task.", - "allowNo": false, - "type": "boolean" - }, - "timeout": { + "managementFlags": { + "management-token": { + "description": "Management API personal access token.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "management-url": { + "description": "Management API base URL. Default https://api.meilisearch.com.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "cloudProjectFlags": { + "project": { + "description": "A project of the config file, whose cloudId names the Cloud project, in place of --id.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "waitFlags": { + "wait": { + "description": "Poll the task until it reaches a final status, then print the final task.", + "allowNo": false, + "type": "boolean" + }, + "timeout": { + "description": "Maximum wait time, for example 30s, 2m. Default 60s. Only with --wait.", + "helpValue": "", + "dependsOn": [ + "wait" + ], + "input": [], + "multiple": false, + "type": "option" + } + }, + "paginationFlags": { + "limit": { + "description": "Passed to the API.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "offset": { + "description": "Passed to the API.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "all": { + "description": "Fetch every page. Streams with ndjson, buffers into one array with json.", + "allowNo": false, + "type": "boolean" + } + }, + "fromPaginationFlags": { + "limit": { + "description": "Passed to the API.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "from": { + "description": "Passed to the API: the uid the page starts at, going down.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "all": { + "description": "Fetch every page. Streams with ndjson, buffers into one array with json.", + "allowNo": false, + "type": "boolean" + } + }, + "bodyInputFlags": { + "file": { + "description": "Read the body from a file. `-` reads from stdin.", + "helpValue": "", + "exclusive": [ + "data" + ], + "input": [], + "multiple": false, + "type": "option" + }, + "data": { + "description": "Inline JSON body.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "confirmFlags": { + "yes": { + "required": true, + "description": "Confirm the deletion. Nothing is sent to the API without it.", + "allowNo": false, + "type": "boolean" + } + }, + "isESM": true, + "relativePath": [ + "dist", + "commands", + "config", + "sync-with-cloud.js" + ] + }, + "config:unset": { + "aliases": [], + "args": {}, + "description": "Remove a project from the config file.\n\nThe file holds API keys and decides where every later command goes, for every process that reads it: an agent runs this only when the human asked for it explicitly.", + "examples": [ + "<%= config.bin %> <%= command.id %> --project prod", + "<%= config.bin %> <%= command.id %> --project prod --output json" + ], + "flags": { + "output": { + "char": "o", + "description": "json (default): one document, the API body as it is, indented in a terminal, compact when piped. ndjson: one item per line, list commands only, streams with --all.", + "name": "output", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "options": [ + "json", + "ndjson" + ], + "type": "option" + }, + "debug": { + "description": "Print every HTTP request and response on stderr, secrets redacted.", + "name": "debug", + "allowNo": false, + "type": "boolean" + }, + "project": { + "description": "Name of the project to remove.", + "name": "project", + "required": true, + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [], + "id": "config:unset", + "pluginAlias": "@meilisearch/cli", + "pluginName": "@meilisearch/cli", + "pluginType": "core", + "strict": true, + "enableJsonFlag": false, + "api": "none", + "routes": [], + "keyActions": [], + "connectionFlags": { + "project-url": { + "description": "Project URL, the Meilisearch instance to talk to.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "project-api-key": { + "description": "Project API key. `-` reads it from stdin.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "project": { + "description": "A project of the config file. Cannot be combined with --project-url. Required when the file has several projects.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "managementFlags": { + "management-token": { + "description": "Management API personal access token.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "management-url": { + "description": "Management API base URL. Default https://api.meilisearch.com.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "cloudProjectFlags": { + "project": { + "description": "A project of the config file, whose cloudId names the Cloud project, in place of --id.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "waitFlags": { + "wait": { + "description": "Poll the task until it reaches a final status, then print the final task.", + "allowNo": false, + "type": "boolean" + }, + "timeout": { "description": "Maximum wait time, for example 30s, 2m. Default 60s. Only with --wait.", "helpValue": "", "dependsOn": [ @@ -2169,6 +2896,31 @@ "type": "option" } }, + "managementFlags": { + "management-token": { + "description": "Management API personal access token.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "management-url": { + "description": "Management API base URL. Default https://api.meilisearch.com.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "cloudProjectFlags": { + "project": { + "description": "A project of the config file, whose cloudId names the Cloud project, in place of --id.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, "waitFlags": { "wait": { "description": "Poll the task until it reaches a final status, then print the final task.", @@ -2388,6 +3140,31 @@ "type": "option" } }, + "managementFlags": { + "management-token": { + "description": "Management API personal access token.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "management-url": { + "description": "Management API base URL. Default https://api.meilisearch.com.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "cloudProjectFlags": { + "project": { + "description": "A project of the config file, whose cloudId names the Cloud project, in place of --id.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, "waitFlags": { "wait": { "description": "Poll the task until it reaches a final status, then print the final task.", @@ -2482,15 +3259,14 @@ "indexes.js" ] }, - "delete:tasks": { + "delete:projects": { "aliases": [], "args": {}, - "description": "Delete the finished tasks matched by --ids, --statuses, --types and the other filters of the route.", + "description": "Delete a Meilisearch Cloud project, with its data. Prints nothing on success.", "examples": [ - "<%= config.bin %> <%= command.id %> --ids 42,1344 --yes", - "<%= config.bin %> <%= command.id %> --ids 42 --yes --output json", - "<%= config.bin %> <%= command.id %> --statuses succeeded --before-enqueued-at 2026-01-01T00:00:00Z --yes", - "<%= config.bin %> <%= command.id %> --index-ids movies --yes --wait" + "<%= config.bin %> <%= command.id %> --id 343 --yes", + "<%= config.bin %> <%= command.id %> --id 343 --yes --output json", + "<%= config.bin %> <%= command.id %> --project staging --yes" ], "flags": { "output": { @@ -2512,16 +3288,241 @@ "allowNo": false, "type": "boolean" }, - "project-url": { - "description": "Project URL, the Meilisearch instance to talk to.", - "name": "project-url", + "management-token": { + "description": "Management API personal access token.", + "name": "management-token", "hasDynamicHelp": false, - "helpValue": "", + "helpValue": "", "multiple": false, "type": "option" }, - "project-api-key": { - "description": "Project API key. `-` reads it from stdin.", + "management-url": { + "description": "Management API base URL. Default https://api.meilisearch.com.", + "name": "management-url", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "project": { + "description": "A project of the config file, whose cloudId names the Cloud project, in place of --id.", + "name": "project", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "id": { + "description": "The project to delete, or give --project and its cloudId from the config file.", + "name": "id", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "yes": { + "description": "Confirm the deletion. Nothing is sent to the API without it.", + "name": "yes", + "required": true, + "allowNo": false, + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [], + "id": "delete:projects", + "pluginAlias": "@meilisearch/cli", + "pluginName": "@meilisearch/cli", + "pluginType": "core", + "strict": true, + "enableJsonFlag": false, + "api": "management", + "routes": [ + "DELETE /api/v2/projects/{id}" + ], + "keyActions": [], + "connectionFlags": { + "project-url": { + "description": "Project URL, the Meilisearch instance to talk to.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "project-api-key": { + "description": "Project API key. `-` reads it from stdin.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "project": { + "description": "A project of the config file. Cannot be combined with --project-url. Required when the file has several projects.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "managementFlags": { + "management-token": { + "description": "Management API personal access token.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "management-url": { + "description": "Management API base URL. Default https://api.meilisearch.com.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "cloudProjectFlags": { + "project": { + "description": "A project of the config file, whose cloudId names the Cloud project, in place of --id.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "waitFlags": { + "wait": { + "description": "Poll the task until it reaches a final status, then print the final task.", + "allowNo": false, + "type": "boolean" + }, + "timeout": { + "description": "Maximum wait time, for example 30s, 2m. Default 60s. Only with --wait.", + "helpValue": "", + "dependsOn": [ + "wait" + ], + "input": [], + "multiple": false, + "type": "option" + } + }, + "paginationFlags": { + "limit": { + "description": "Passed to the API.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "offset": { + "description": "Passed to the API.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "all": { + "description": "Fetch every page. Streams with ndjson, buffers into one array with json.", + "allowNo": false, + "type": "boolean" + } + }, + "fromPaginationFlags": { + "limit": { + "description": "Passed to the API.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "from": { + "description": "Passed to the API: the uid the page starts at, going down.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "all": { + "description": "Fetch every page. Streams with ndjson, buffers into one array with json.", + "allowNo": false, + "type": "boolean" + } + }, + "bodyInputFlags": { + "file": { + "description": "Read the body from a file. `-` reads from stdin.", + "helpValue": "", + "exclusive": [ + "data" + ], + "input": [], + "multiple": false, + "type": "option" + }, + "data": { + "description": "Inline JSON body.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "confirmFlags": { + "yes": { + "required": true, + "description": "Confirm the deletion. Nothing is sent to the API without it.", + "allowNo": false, + "type": "boolean" + } + }, + "isESM": true, + "relativePath": [ + "dist", + "commands", + "delete", + "projects.js" + ] + }, + "delete:tasks": { + "aliases": [], + "args": {}, + "description": "Delete the finished tasks matched by --ids, --statuses, --types and the other filters of the route.", + "examples": [ + "<%= config.bin %> <%= command.id %> --ids 42,1344 --yes", + "<%= config.bin %> <%= command.id %> --ids 42 --yes --output json", + "<%= config.bin %> <%= command.id %> --statuses succeeded --before-enqueued-at 2026-01-01T00:00:00Z --yes", + "<%= config.bin %> <%= command.id %> --index-ids movies --yes --wait" + ], + "flags": { + "output": { + "char": "o", + "description": "json (default): one document, the API body as it is, indented in a terminal, compact when piped. ndjson: one item per line, list commands only, streams with --all.", + "name": "output", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "options": [ + "json", + "ndjson" + ], + "type": "option" + }, + "debug": { + "description": "Print every HTTP request and response on stderr, secrets redacted.", + "name": "debug", + "allowNo": false, + "type": "boolean" + }, + "project-url": { + "description": "Project URL, the Meilisearch instance to talk to.", + "name": "project-url", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "project-api-key": { + "description": "Project API key. `-` reads it from stdin.", "name": "project-api-key", "hasDynamicHelp": false, "helpValue": "", @@ -2705,6 +3706,31 @@ "type": "option" } }, + "managementFlags": { + "management-token": { + "description": "Management API personal access token.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "management-url": { + "description": "Management API base URL. Default https://api.meilisearch.com.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "cloudProjectFlags": { + "project": { + "description": "A project of the config file, whose cloudId names the Cloud project, in place of --id.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, "waitFlags": { "wait": { "description": "Poll the task until it reaches a final status, then print the final task.", @@ -2984,6 +4010,31 @@ "type": "option" } }, + "managementFlags": { + "management-token": { + "description": "Management API personal access token.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "management-url": { + "description": "Management API base URL. Default https://api.meilisearch.com.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "cloudProjectFlags": { + "project": { + "description": "A project of the config file, whose cloudId names the Cloud project, in place of --id.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, "waitFlags": { "wait": { "description": "Poll the task until it reaches a final status, then print the final task.", @@ -3168,11 +4219,36 @@ "type": "option" } }, - "waitFlags": { - "wait": { - "description": "Poll the task until it reaches a final status, then print the final task.", - "allowNo": false, - "type": "boolean" + "managementFlags": { + "management-token": { + "description": "Management API personal access token.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "management-url": { + "description": "Management API base URL. Default https://api.meilisearch.com.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "cloudProjectFlags": { + "project": { + "description": "A project of the config file, whose cloudId names the Cloud project, in place of --id.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "waitFlags": { + "wait": { + "description": "Poll the task until it reaches a final status, then print the final task.", + "allowNo": false, + "type": "boolean" }, "timeout": { "description": "Maximum wait time, for example 30s, 2m. Default 60s. Only with --wait.", @@ -3387,6 +4463,31 @@ "type": "option" } }, + "managementFlags": { + "management-token": { + "description": "Management API personal access token.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "management-url": { + "description": "Management API base URL. Default https://api.meilisearch.com.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "cloudProjectFlags": { + "project": { + "description": "A project of the config file, whose cloudId names the Cloud project, in place of --id.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, "waitFlags": { "wait": { "description": "Poll the task until it reaches a final status, then print the final task.", @@ -3481,6 +4582,235 @@ "indexes.js" ] }, + "get:projects": { + "aliases": [], + "args": {}, + "description": "List the Meilisearch Cloud projects the token sees, or get one with --id or --project.", + "examples": [ + "<%= config.bin %> <%= command.id %>", + "<%= config.bin %> <%= command.id %> --output json", + "<%= config.bin %> <%= command.id %> --team-id 12", + "<%= config.bin %> <%= command.id %> --id 343", + "<%= config.bin %> <%= command.id %> --project prod", + "<%= config.bin %> <%= command.id %> --output ndjson" + ], + "flags": { + "output": { + "char": "o", + "description": "json (default): one document, the API body as it is, indented in a terminal, compact when piped. ndjson: one item per line, list commands only, streams with --all.", + "name": "output", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "options": [ + "json", + "ndjson" + ], + "type": "option" + }, + "debug": { + "description": "Print every HTTP request and response on stderr, secrets redacted.", + "name": "debug", + "allowNo": false, + "type": "boolean" + }, + "management-token": { + "description": "Management API personal access token.", + "name": "management-token", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "management-url": { + "description": "Management API base URL. Default https://api.meilisearch.com.", + "name": "management-url", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "project": { + "description": "A project of the config file, whose cloudId names the Cloud project, in place of --id.", + "name": "project", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "id": { + "description": "The project to get, or give --project and its cloudId from the config file. Without either, the command lists the projects.", + "name": "id", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "team-id": { + "description": "Only the projects of this team. Ignored with --id.", + "name": "team-id", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [], + "id": "get:projects", + "pluginAlias": "@meilisearch/cli", + "pluginName": "@meilisearch/cli", + "pluginType": "core", + "strict": true, + "enableJsonFlag": false, + "api": "management", + "routes": [ + "GET /api/v2/projects", + "GET /api/v2/projects/{id}" + ], + "keyActions": [], + "connectionFlags": { + "project-url": { + "description": "Project URL, the Meilisearch instance to talk to.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "project-api-key": { + "description": "Project API key. `-` reads it from stdin.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "project": { + "description": "A project of the config file. Cannot be combined with --project-url. Required when the file has several projects.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "managementFlags": { + "management-token": { + "description": "Management API personal access token.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "management-url": { + "description": "Management API base URL. Default https://api.meilisearch.com.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "cloudProjectFlags": { + "project": { + "description": "A project of the config file, whose cloudId names the Cloud project, in place of --id.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "waitFlags": { + "wait": { + "description": "Poll the task until it reaches a final status, then print the final task.", + "allowNo": false, + "type": "boolean" + }, + "timeout": { + "description": "Maximum wait time, for example 30s, 2m. Default 60s. Only with --wait.", + "helpValue": "", + "dependsOn": [ + "wait" + ], + "input": [], + "multiple": false, + "type": "option" + } + }, + "paginationFlags": { + "limit": { + "description": "Passed to the API.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "offset": { + "description": "Passed to the API.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "all": { + "description": "Fetch every page. Streams with ndjson, buffers into one array with json.", + "allowNo": false, + "type": "boolean" + } + }, + "fromPaginationFlags": { + "limit": { + "description": "Passed to the API.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "from": { + "description": "Passed to the API: the uid the page starts at, going down.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "all": { + "description": "Fetch every page. Streams with ndjson, buffers into one array with json.", + "allowNo": false, + "type": "boolean" + } + }, + "bodyInputFlags": { + "file": { + "description": "Read the body from a file. `-` reads from stdin.", + "helpValue": "", + "exclusive": [ + "data" + ], + "input": [], + "multiple": false, + "type": "option" + }, + "data": { + "description": "Inline JSON body.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "confirmFlags": { + "yes": { + "required": true, + "description": "Confirm the deletion. Nothing is sent to the API without it.", + "allowNo": false, + "type": "boolean" + } + }, + "isESM": true, + "relativePath": [ + "dist", + "commands", + "get", + "projects.js" + ] + }, "get:tasks": { "aliases": [], "args": {}, @@ -3710,6 +5040,31 @@ "type": "option" } }, + "managementFlags": { + "management-token": { + "description": "Management API personal access token.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "management-url": { + "description": "Management API base URL. Default https://api.meilisearch.com.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "cloudProjectFlags": { + "project": { + "description": "A project of the config file, whose cloudId names the Cloud project, in place of --id.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, "waitFlags": { "wait": { "description": "Poll the task until it reaches a final status, then print the final task.", @@ -3877,6 +5232,31 @@ "type": "option" } }, + "managementFlags": { + "management-token": { + "description": "Management API personal access token.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "management-url": { + "description": "Management API base URL. Default https://api.meilisearch.com.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "cloudProjectFlags": { + "project": { + "description": "A project of the config file, whose cloudId names the Cloud project, in place of --id.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, "waitFlags": { "wait": { "description": "Poll the task until it reaches a final status, then print the final task.", @@ -4302,6 +5682,31 @@ "type": "option" } }, + "managementFlags": { + "management-token": { + "description": "Management API personal access token.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "management-url": { + "description": "Management API base URL. Default https://api.meilisearch.com.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "cloudProjectFlags": { + "project": { + "description": "A project of the config file, whose cloudId names the Cloud project, in place of --id.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, "waitFlags": { "wait": { "description": "Poll the task until it reaches a final status, then print the final task.", @@ -4556,6 +5961,31 @@ "type": "option" } }, + "managementFlags": { + "management-token": { + "description": "Management API personal access token.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "management-url": { + "description": "Management API base URL. Default https://api.meilisearch.com.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "cloudProjectFlags": { + "project": { + "description": "A project of the config file, whose cloudId names the Cloud project, in place of --id.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, "waitFlags": { "wait": { "description": "Poll the task until it reaches a final status, then print the final task.", @@ -4807,6 +6237,31 @@ "type": "option" } }, + "managementFlags": { + "management-token": { + "description": "Management API personal access token.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "management-url": { + "description": "Management API base URL. Default https://api.meilisearch.com.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "cloudProjectFlags": { + "project": { + "description": "A project of the config file, whose cloudId names the Cloud project, in place of --id.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, "waitFlags": { "wait": { "description": "Poll the task until it reaches a final status, then print the final task.", @@ -4900,6 +6355,251 @@ "update", "indexes.js" ] + }, + "update:projects": { + "aliases": [], + "args": {}, + "description": "Rename a Meilisearch Cloud project.", + "examples": [ + "<%= config.bin %> <%= command.id %> --id 343 --name renamed", + "<%= config.bin %> <%= command.id %> --id 343 --name renamed --output json", + "<%= config.bin %> <%= command.id %> --project prod --name renamed", + "<%= config.bin %> <%= command.id %> --id 343 --data '{\"name\":\"renamed\"}'" + ], + "flags": { + "output": { + "char": "o", + "description": "json (default): one document, the API body as it is, indented in a terminal, compact when piped. ndjson: one item per line, list commands only, streams with --all.", + "name": "output", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "options": [ + "json", + "ndjson" + ], + "type": "option" + }, + "debug": { + "description": "Print every HTTP request and response on stderr, secrets redacted.", + "name": "debug", + "allowNo": false, + "type": "boolean" + }, + "management-token": { + "description": "Management API personal access token.", + "name": "management-token", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "management-url": { + "description": "Management API base URL. Default https://api.meilisearch.com.", + "name": "management-url", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "project": { + "description": "A project of the config file, whose cloudId names the Cloud project, in place of --id.", + "name": "project", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "id": { + "description": "The project to update, or give --project and its cloudId from the config file.", + "name": "id", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "name": { + "description": "New name of the project.", + "name": "name", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "file": { + "description": "Read the body from a file. `-` reads from stdin.", + "exclusive": [ + "data" + ], + "name": "file", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "data": { + "description": "Inline JSON body.", + "name": "data", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [], + "id": "update:projects", + "pluginAlias": "@meilisearch/cli", + "pluginName": "@meilisearch/cli", + "pluginType": "core", + "strict": true, + "enableJsonFlag": false, + "api": "management", + "routes": [ + "PATCH /api/v2/projects/{id}" + ], + "keyActions": [], + "connectionFlags": { + "project-url": { + "description": "Project URL, the Meilisearch instance to talk to.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "project-api-key": { + "description": "Project API key. `-` reads it from stdin.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "project": { + "description": "A project of the config file. Cannot be combined with --project-url. Required when the file has several projects.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "managementFlags": { + "management-token": { + "description": "Management API personal access token.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "management-url": { + "description": "Management API base URL. Default https://api.meilisearch.com.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "cloudProjectFlags": { + "project": { + "description": "A project of the config file, whose cloudId names the Cloud project, in place of --id.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "waitFlags": { + "wait": { + "description": "Poll the task until it reaches a final status, then print the final task.", + "allowNo": false, + "type": "boolean" + }, + "timeout": { + "description": "Maximum wait time, for example 30s, 2m. Default 60s. Only with --wait.", + "helpValue": "", + "dependsOn": [ + "wait" + ], + "input": [], + "multiple": false, + "type": "option" + } + }, + "paginationFlags": { + "limit": { + "description": "Passed to the API.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "offset": { + "description": "Passed to the API.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "all": { + "description": "Fetch every page. Streams with ndjson, buffers into one array with json.", + "allowNo": false, + "type": "boolean" + } + }, + "fromPaginationFlags": { + "limit": { + "description": "Passed to the API.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "from": { + "description": "Passed to the API: the uid the page starts at, going down.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "all": { + "description": "Fetch every page. Streams with ndjson, buffers into one array with json.", + "allowNo": false, + "type": "boolean" + } + }, + "bodyInputFlags": { + "file": { + "description": "Read the body from a file. `-` reads from stdin.", + "helpValue": "", + "exclusive": [ + "data" + ], + "input": [], + "multiple": false, + "type": "option" + }, + "data": { + "description": "Inline JSON body.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "confirmFlags": { + "yes": { + "required": true, + "description": "Confirm the deletion. Nothing is sent to the API without it.", + "allowNo": false, + "type": "boolean" + } + }, + "isESM": true, + "relativePath": [ + "dist", + "commands", + "update", + "projects.js" + ] } }, "version": "0.0.0" diff --git a/openapi-ts.config.mjs b/openapi-ts.config.mjs new file mode 100644 index 0000000..ac0f519 --- /dev/null +++ b/openapi-ts.config.mjs @@ -0,0 +1,23 @@ +/** + * Read by openapi-ts, which `pnpm gen:management` runs through `pnpm dlx` with + * TypeScript 5 beside it: the generator drives the TypeScript compiler API, + * which TypeScript 7 no longer ships, and pnpm resolves a peer dependency from + * the project, so the generator cannot be a dev dependency here. Hence no + * import below either. The client throws on an HTTP error and the SDK functions + * return the body; src/lib/clients/management.ts maps the errors. + * @type {import("@hey-api/openapi-ts").UserConfig} + */ +export default { + input: "./openapi/management.json", + output: { + path: "src/lib/clients/management/generated", + importFileExtension: ".js", + format: false, + lint: false, + }, + plugins: [ + { name: "@hey-api/client-fetch", throwOnError: true }, + "@hey-api/typescript", + { name: "@hey-api/sdk", responseStyle: "data" }, + ], +}; diff --git a/package.json b/package.json index 010fbd7..a63846b 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "packageManager": "pnpm@11.21.0", "scripts": { "build": "tsc -p tsconfig.build.json", + "gen:management": "pnpm --package=typescript@5.9.3 --package=@hey-api/openapi-ts@0.99.0 dlx openapi-ts", "gen:manifest": "pnpm build && oclif manifest && node scripts/sort-manifest.mjs", "lint": "biome check .", "lint:fix": "biome check --write .", diff --git a/src/commands/add/projects.ts b/src/commands/add/projects.ts new file mode 100644 index 0000000..8434da3 --- /dev/null +++ b/src/commands/add/projects.ts @@ -0,0 +1,87 @@ +import { Flags } from "@oclif/core"; +import { type Api, BaseCommand } from "../../lib/base-command.js"; +import { postApiV2Projects } from "../../lib/clients/management/generated/sdk.gen.js"; +import type { PostApiV2ProjectsData } from "../../lib/clients/management/generated/types.gen.js"; +import { assertNoProject, writeConfigFile } from "../../lib/config.js"; +import { MeiliCliError } from "../../lib/errors.js"; +import { readJsonObjectBody } from "../../lib/input.js"; + +export default class AddProjects extends BaseCommand { + static api: Api = "management"; + + static routes = ["POST /api/v2/projects"]; + + static override description = "Create a Meilisearch Cloud project in a team, on a resource."; + + static override examples = [ + "<%= config.bin %> <%= command.id %> --name demo --team-id 12 --resource-id 3", + "<%= config.bin %> <%= command.id %> --name demo --team-id 12 --resource-id 3 --output json", + "<%= config.bin %> <%= command.id %> --name demo --team-id 12 --resource-id 3 --add-to-config", + "<%= config.bin %> <%= command.id %> --name demo --team-id 12 --resource-id 3 --ms-version v1.54.0", + '<%= config.bin %> <%= command.id %> --data \'{"name":"demo","teamId":12,"resourceId":3}\'', + "<%= config.bin %> <%= command.id %> --file project.json --name other-name", + ]; + + static override flags = { + ...BaseCommand.managementFlags, + name: Flags.string({ description: "Name of the project.", helpValue: "" }), + "team-id": Flags.integer({ description: "Team that owns the project.", helpValue: "" }), + "resource-id": Flags.integer({ + description: "Resource of the project: its plan and its region.", + helpValue: "", + }), + "ms-version": Flags.string({ + description: "Meilisearch version of the project, the latest by default.", + helpValue: "", + }), + "add-to-config": Flags.boolean({ + description: + "Add the project to the config file, as an entry named after it with its URL, its master key and its cloudId.", + }), + ...BaseCommand.bodyInputFlags, + }; + + async run(): Promise { + const { flags } = await this.parse(AddProjects); + // The body goes out whole, so a field this command declares no flag for + // reaches the API, which refuses it rather than the CLI dropping it. + const body = { ...readJsonObjectBody(flags) } as NonNullable; + if (flags.name !== undefined) body.name = flags.name; + if (flags["team-id"] !== undefined) body.teamId = flags["team-id"]; + if (flags["resource-id"] !== undefined) body.resourceId = flags["resource-id"]; + if (flags["ms-version"] !== undefined) body.msVersion = flags["ms-version"]; + + const file = flags["add-to-config"] ? this.configFile() : undefined; + if (file !== undefined) { + if (typeof body.name !== "string" || body.name === "") { + throw new MeiliCliError( + "cli_usage", + "`--add-to-config` names the entry after the project: give `--name`.", + ); + } + assertNoProject( + file, + body.name, + ": nothing was created.", + `Run \`meili config unset --project ${body.name}\` first.`, + ); + } + const project = await postApiV2Projects({ client: this.managementClient, body }); + await this.output(project); + if (file === undefined) return; + if (project.url == null) { + throw new MeiliCliError( + "cli_config", + `Project ${project.id} is created but has no URL yet, so it was not added to ${file.path}.`, + { hint: "Run `meili config sync-with-cloud` once it has one." }, + ); + } + writeConfigFile({ + path: file.path, + projects: { + ...file.projects, + [project.name]: { url: project.url, apiKey: project.masterKey, cloudId: project.id }, + }, + }); + } +} diff --git a/src/commands/config/set.ts b/src/commands/config/set.ts index 538af55..82b56ab 100644 --- a/src/commands/config/set.ts +++ b/src/commands/config/set.ts @@ -1,6 +1,6 @@ import { Flags } from "@oclif/core"; import { type Api, BaseCommand } from "../../lib/base-command.js"; -import { writeConfigFile } from "../../lib/config.js"; +import { assertNoProject, CONFIG_FILE_WARNING, writeConfigFile } from "../../lib/config.js"; import { MeiliCliError } from "../../lib/errors.js"; import { readStdin } from "../../lib/input.js"; @@ -10,11 +10,12 @@ export default class ConfigSet extends BaseCommand { static override description = `Set a project in the config file, creating the file owner-only; refuses a name that exists. -The file holds API keys and decides where every later command goes, for every process that reads it: an agent runs this only when the human asked for it explicitly.`; +${CONFIG_FILE_WARNING}`; static override examples = [ "<%= config.bin %> <%= command.id %> --project local --project-url http://localhost:7700", "<%= config.bin %> <%= command.id %> --project prod --project-url https://ms-abc.sfo.meilisearch.io --project-api-key - < key.txt", + "<%= config.bin %> <%= command.id %> --project prod --project-url https://ms-abc.sfo.meilisearch.io --project-cloud-id 343", "<%= config.bin %> <%= command.id %> --project local --project-url http://localhost:7700 --output json", ]; @@ -34,6 +35,10 @@ The file holds API keys and decides where every later command goes, for every pr helpValue: "", parse: async (value) => (value === "-" ? readStdin() : value), }), + "project-cloud-id": Flags.integer({ + description: "Id of the project in Meilisearch Cloud, for the management commands.", + helpValue: "", + }), }; async run(): Promise { @@ -44,18 +49,21 @@ The file holds API keys and decides where every later command goes, for every pr throw new MeiliCliError("cli_usage", `Invalid project URL \`${flags["project-url"]}\`.`); } const file = this.configFile(); - if (file.projects[flags.project] !== undefined) { - throw new MeiliCliError( - "cli_config", - `Project \`${flags.project}\` already exists in ${file.path}.`, - { hint: `Run \`meili config unset --project ${flags.project}\` first.` }, - ); - } + assertNoProject( + file, + flags.project, + ".", + `Run \`meili config unset --project ${flags.project}\` first.`, + ); writeConfigFile({ path: file.path, projects: { ...file.projects, - [flags.project]: { url: flags["project-url"], apiKey: flags["project-api-key"] }, + [flags.project]: { + url: flags["project-url"], + apiKey: flags["project-api-key"], + cloudId: flags["project-cloud-id"], + }, }, }); } diff --git a/src/commands/config/sync-with-cloud.ts b/src/commands/config/sync-with-cloud.ts new file mode 100644 index 0000000..a121cb3 --- /dev/null +++ b/src/commands/config/sync-with-cloud.ts @@ -0,0 +1,77 @@ +import { Flags } from "@oclif/core"; +import { type Api, BaseCommand } from "../../lib/base-command.js"; +import { getApiV2Projects } from "../../lib/clients/management/generated/sdk.gen.js"; +import type { Project } from "../../lib/clients/management/generated/types.gen.js"; +import { CONFIG_FILE_WARNING, writeConfigFile } from "../../lib/config.js"; + +export default class ConfigSyncWithCloud extends BaseCommand { + static api: Api = "management"; + + static override description = + `Set the cloudId of the config file entries whose URL is a Meilisearch Cloud project the token sees. Never writes a key, never adds or removes an entry. Prints what changed. + +${CONFIG_FILE_WARNING}`; + + static override examples = [ + "<%= config.bin %> <%= command.id %>", + "<%= config.bin %> <%= command.id %> --team-id 12", + "<%= config.bin %> <%= command.id %> --output json", + ]; + + static override flags = { + ...BaseCommand.managementFlags, + "team-id": Flags.integer({ + description: "Only the projects of this team; missing entries are then not reported.", + helpValue: "", + }), + }; + + async run(): Promise { + const { flags } = await this.parse(ConfigSyncWithCloud); + // Every page, so that an entry the first page misses is not reported missing. + const projects: Project[] = []; + for (let page = 1; ; page += 1) { + const answer = await getApiV2Projects({ + client: this.managementClient, + query: { teamId: flags["team-id"], page }, + }); + projects.push(...answer.results); + if (page >= answer.totalPages) break; + } + const file = this.configFile(); + const entries = file.projects; + const report = { updated: [] as string[], unchanged: [] as string[], missing: [] as string[] }; + + for (const project of projects) { + const byId = Object.entries(entries).find(([, entry]) => entry.cloudId === project.id); + if (byId !== undefined) { + report.unchanged.push(byId[0]); + continue; + } + const url = project.url; + if (url == null) continue; + const byUrl = Object.entries(entries).find( + ([, entry]) => entry.cloudId === undefined && sameOrigin(entry.url, url), + ); + if (byUrl === undefined) continue; + const [name, entry] = byUrl; + entries[name] = { ...entry, cloudId: project.id }; + report.updated.push(name); + } + + if (flags["team-id"] === undefined) { + const ids = new Set(projects.map((project) => project.id)); + for (const [name, entry] of Object.entries(entries)) { + if (entry.cloudId !== undefined && !ids.has(entry.cloudId)) report.missing.push(name); + } + } + if (report.updated.length > 0) writeConfigFile(file); + await this.output(report); + } +} + +/** The same instance: scheme and host, whatever the path or the case. */ +function sameOrigin(a: string, b: string): boolean { + const left = URL.parse(a)?.origin; + return left !== undefined && left === URL.parse(b)?.origin; +} diff --git a/src/commands/config/unset.ts b/src/commands/config/unset.ts index 563acc3..af3f726 100644 --- a/src/commands/config/unset.ts +++ b/src/commands/config/unset.ts @@ -1,13 +1,13 @@ import { Flags } from "@oclif/core"; import { type Api, BaseCommand } from "../../lib/base-command.js"; -import { projectOf, writeConfigFile } from "../../lib/config.js"; +import { CONFIG_FILE_WARNING, projectOf, writeConfigFile } from "../../lib/config.js"; export default class ConfigUnset extends BaseCommand { static api: Api = "none"; static override description = `Remove a project from the config file. -The file holds API keys and decides where every later command goes, for every process that reads it: an agent runs this only when the human asked for it explicitly.`; +${CONFIG_FILE_WARNING}`; static override examples = [ "<%= config.bin %> <%= command.id %> --project prod", diff --git a/src/commands/delete/projects.ts b/src/commands/delete/projects.ts new file mode 100644 index 0000000..71e4ae3 --- /dev/null +++ b/src/commands/delete/projects.ts @@ -0,0 +1,37 @@ +import { Flags } from "@oclif/core"; +import { type Api, BaseCommand } from "../../lib/base-command.js"; +import { deleteApiV2ProjectsById } from "../../lib/clients/management/generated/sdk.gen.js"; + +export default class DeleteProjects extends BaseCommand { + static api: Api = "management"; + + static routes = ["DELETE /api/v2/projects/{id}"]; + + static override description = + "Delete a Meilisearch Cloud project, with its data. Prints nothing on success."; + + static override examples = [ + "<%= config.bin %> <%= command.id %> --id 343 --yes", + "<%= config.bin %> <%= command.id %> --id 343 --yes --output json", + "<%= config.bin %> <%= command.id %> --project staging --yes", + ]; + + static override flags = { + ...BaseCommand.managementFlags, + ...BaseCommand.cloudProjectFlags, + id: Flags.integer({ + description: + "The project to delete, or give --project and its cloudId from the config file.", + helpValue: "", + }), + ...BaseCommand.confirmFlags, + }; + + async run(): Promise { + const { flags } = await this.parse(DeleteProjects); + await deleteApiV2ProjectsById({ + client: this.managementClient, + path: { id: this.cloudProjectId(flags.id) }, + }); + } +} diff --git a/src/commands/get/projects.ts b/src/commands/get/projects.ts new file mode 100644 index 0000000..931de76 --- /dev/null +++ b/src/commands/get/projects.ts @@ -0,0 +1,61 @@ +import { Flags } from "@oclif/core"; +import { type Api, BaseCommand } from "../../lib/base-command.js"; +import { + getApiV2Projects, + getApiV2ProjectsById, +} from "../../lib/clients/management/generated/sdk.gen.js"; + +export default class GetProjects extends BaseCommand { + static api: Api = "management"; + + static routes = ["GET /api/v2/projects", "GET /api/v2/projects/{id}"]; + + static override description = + "List the Meilisearch Cloud projects the token sees, or get one with --id or --project."; + + static override examples = [ + "<%= config.bin %> <%= command.id %>", + "<%= config.bin %> <%= command.id %> --output json", + "<%= config.bin %> <%= command.id %> --team-id 12", + "<%= config.bin %> <%= command.id %> --id 343", + "<%= config.bin %> <%= command.id %> --project prod", + "<%= config.bin %> <%= command.id %> --output ndjson", + ]; + + static override flags = { + ...BaseCommand.managementFlags, + ...BaseCommand.cloudProjectFlags, + id: Flags.integer({ + description: + "The project to get, or give --project and its cloudId from the config file. Without either, the command lists the projects.", + helpValue: "", + }), + "team-id": Flags.integer({ + description: "Only the projects of this team. Ignored with --id.", + helpValue: "", + }), + }; + + async run(): Promise { + const { flags } = await this.parse(GetProjects); + if (flags.id === undefined && this.globalFlags.project === undefined) { + // The list route pages, but by page number rather than by offset as every + // Meilisearch list does. The paging flags wait for the two to agree; the + // envelope goes out as it is, and `ndjson` streams its `results`. + const page = await getApiV2Projects({ + client: this.managementClient, + query: { teamId: flags["team-id"] }, + }); + await this.output(this.outputMode === "ndjson" ? page.results : page, { + supportsNdjson: true, + }); + return; + } + await this.output( + await getApiV2ProjectsById({ + client: this.managementClient, + path: { id: this.cloudProjectId(flags.id) }, + }), + ); + } +} diff --git a/src/commands/update/projects.ts b/src/commands/update/projects.ts new file mode 100644 index 0000000..dfff779 --- /dev/null +++ b/src/commands/update/projects.ts @@ -0,0 +1,49 @@ +import { Flags } from "@oclif/core"; +import { type Api, BaseCommand } from "../../lib/base-command.js"; +import { patchApiV2ProjectsById } from "../../lib/clients/management/generated/sdk.gen.js"; +import type { PatchApiV2ProjectsByIdData } from "../../lib/clients/management/generated/types.gen.js"; +import { readJsonObjectBody } from "../../lib/input.js"; + +export default class UpdateProjects extends BaseCommand { + static api: Api = "management"; + + static routes = ["PATCH /api/v2/projects/{id}"]; + + static override description = "Rename a Meilisearch Cloud project."; + + static override examples = [ + "<%= config.bin %> <%= command.id %> --id 343 --name renamed", + "<%= config.bin %> <%= command.id %> --id 343 --name renamed --output json", + "<%= config.bin %> <%= command.id %> --project prod --name renamed", + '<%= config.bin %> <%= command.id %> --id 343 --data \'{"name":"renamed"}\'', + ]; + + static override flags = { + ...BaseCommand.managementFlags, + ...BaseCommand.cloudProjectFlags, + id: Flags.integer({ + description: + "The project to update, or give --project and its cloudId from the config file.", + helpValue: "", + }), + name: Flags.string({ description: "New name of the project.", helpValue: "" }), + ...BaseCommand.bodyInputFlags, + }; + + async run(): Promise { + const { flags } = await this.parse(UpdateProjects); + // The body goes out whole, so a field this command declares no flag for + // reaches the API, which refuses it rather than the CLI dropping it. + const body = { ...readJsonObjectBody(flags) } as NonNullable< + PatchApiV2ProjectsByIdData["body"] + >; + if (flags.name !== undefined) body.name = flags.name; + await this.output( + await patchApiV2ProjectsById({ + client: this.managementClient, + path: { id: this.cloudProjectId(flags.id) }, + body, + }), + ); + } +} diff --git a/src/lib/api-naming.ts b/src/lib/api-naming.ts index 7933f7e..ccbfc3a 100644 --- a/src/lib/api-naming.ts +++ b/src/lib/api-naming.ts @@ -15,7 +15,15 @@ export interface Route { } /** Flags the CLI owns. Every other flag of an API command comes from a route. */ -export const CLI_OWNED_FLAGS: readonly string[] = ["wait", "timeout", "all", "file", "data", "yes"]; +export const CLI_OWNED_FLAGS: readonly string[] = [ + "wait", + "timeout", + "all", + "file", + "data", + "yes", + "add-to-config", +]; /** Trailing path literals that are verbs (rule 1). A closed list: adding one is a contract change. */ export const LITERAL_VERBS: readonly string[] = [ @@ -53,8 +61,9 @@ const IDENTIFIERS: Record = // OpenAPI, the little the rules read from it interface OpenApiParameter { - name: string; - in: string; + $ref?: string; + name?: string; + in?: string; } interface OpenApiSchema { @@ -75,7 +84,10 @@ type OpenApiPathItem = { parameters?: OpenApiParameter[] } & Partial< export interface OpenApiDocument { paths: Record; - components?: { schemas?: Record }; + components?: { + schemas?: Record; + parameters?: Record; + }; } /** `"GET /indexes"` into a route. */ @@ -404,10 +416,44 @@ export function pathFlagsForRoute(route: Route, commandId: string): string[] { return segments.flatMap((segment, index) => { if (!isParameter(segment)) return []; const collection = toKebabCase(segments[index - 1] ?? ""); - return [collection === resource ? "id" : singular(collection)]; + if (collection === resource) return ["id"]; + // On the Management API a parent parameter keeps its API name, `{projectId}` is + // `--project-id`: `--project` is the global flag that names a config file entry. + return [ + VERSION_PREFIX.test(route.path) ? toKebabCase(segment.slice(1, -1)) : singular(collection), + ]; }); } +/** + * The flag of a Management API route that identifies a Cloud project, which + * `--project ` can fill from the config file (rule 4): `id` on the + * `projects` commands, `project-id` under a project, nothing otherwise. + */ +export function projectFlagForRoute(route: Route): "id" | "project-id" | undefined { + const commandId = VERSION_PREFIX.test(route.path) ? commandIdFor(route) : null; + if (commandId === null) return undefined; + const flags = pathFlagsForRoute(route, commandId); + if (flags.includes("project-id")) return "project-id"; + return resourceOf(commandId) === "projects" && flags.includes("id") ? "id" : undefined; +} + +/** + * The names of the query parameters among `parameters`, the shared ones of + * `components.parameters` resolved: the Management API declares its `page` and + * its `limit` there, once for every list route. + */ +function queryParameters(spec: OpenApiDocument, parameters: readonly OpenApiParameter[]): string[] { + return parameters + .map((parameter) => + parameter.$ref === undefined + ? parameter + : spec.components?.parameters?.[parameter.$ref.split("/").at(-1) ?? ""], + ) + .filter((parameter) => parameter?.in === "query") + .map((parameter) => parameter?.name ?? ""); +} + function resolveSchema( spec: OpenApiDocument, schema: OpenApiSchema | undefined, @@ -427,26 +473,11 @@ function schemaProperties( return Object.assign({}, ...inherited, resolved.properties ?? {}); } -/** - * The first-level fields of a JSON body (rule 6). A body made of one object - * property named after the resource, `{"project": {...}}`, is unwrapped. - */ -function bodyFields( - spec: OpenApiDocument, - operation: OpenApiOperation, - resource: string | undefined, -): string[] { - const fields = schemaProperties( - spec, - operation.requestBody?.content?.["application/json"]?.schema, +/** The first-level fields of a JSON body (rule 6). */ +function bodyFields(spec: OpenApiDocument, operation: OpenApiOperation): string[] { + return Object.keys( + schemaProperties(spec, operation.requestBody?.content?.["application/json"]?.schema), ); - const names = Object.keys(fields); - const wrapper = names[0]; - if (names.length === 1 && wrapper !== undefined && resource !== undefined) { - const inner = schemaProperties(spec, fields[wrapper]); - if (wrapper === singular(resource) && Object.keys(inner).length > 0) return Object.keys(inner); - } - return names; } /** @@ -464,11 +495,12 @@ export function flagsForRoute(spec: OpenApiDocument, route: Route, commandId: st const flags = new Set( exception === null || exception === undefined ? [] : parseCommand(exception).flags, ); - const query = [...(spec.paths[route.path]?.parameters ?? []), ...(operation.parameters ?? [])] - .filter((parameter) => parameter.in === "query") - .map((parameter) => parameter.name); + const query = queryParameters(spec, [ + ...(spec.paths[route.path]?.parameters ?? []), + ...(operation.parameters ?? []), + ]); - for (const name of [...query, ...bodyFields(spec, operation, resource)]) { + for (const name of [...query, ...bodyFields(spec, operation)]) { const identifier = IDENTIFIERS[name]; if (identifier !== undefined) { const own = diff --git a/src/lib/base-command.ts b/src/lib/base-command.ts index 7b03115..f8ed695 100644 --- a/src/lib/base-command.ts +++ b/src/lib/base-command.ts @@ -6,14 +6,17 @@ */ import { Command, Errors, Flags, type Interfaces } from "@oclif/core"; import type { EnqueuedTask, Meilisearch } from "meilisearch"; +import { createManagementClient, type ManagementClient } from "./clients/management.js"; import { createMeilisearchClient } from "./clients/meilisearch.js"; import { installHttpTrace } from "./clients/trace.js"; import { type ConfigFile, configFilePath, + DEFAULT_MANAGEMENT_URL, ENV, type Env, readConfigFile, + resolveCloudProjectId, resolveProjectConnection, } from "./config.js"; import { MeiliCliError, toRenderedError, writeError, writeNotice, writeWarning } from "./errors.js"; @@ -22,7 +25,6 @@ import { type OutputMode, resolveOutputMode, writeData } from "./output.js"; import { iteratePages, type ListPage, mergePages } from "./pagination.js"; import { credentialsFilePath, - type ManagementCredentials, readCredentialsFile, resolveManagementCredentials, type Session, @@ -33,7 +35,10 @@ import { DEFAULT_TIMEOUT, isTaskFailure, parseDuration, waitForTask } from "./ta export type Api = "meilisearch" | "management" | "none"; export type GlobalFlags = Interfaces.InferredFlags< - typeof BaseCommand.baseFlags & typeof BaseCommand.connectionFlags + typeof BaseCommand.baseFlags & + typeof BaseCommand.connectionFlags & + typeof BaseCommand.managementFlags & + typeof BaseCommand.cloudProjectFlags >; /** The two flags a list route declares whichever way it pages, shared by both groups below. */ @@ -106,6 +111,35 @@ export abstract class BaseCommand extends Command { }), }; + /** + * How a command reaches the Management API, the group of + * docs/contract/commands.md. Spread by the commands that talk to it, and by + * no other. + */ + static managementFlags = { + "management-token": Flags.string({ + description: "Management API personal access token.", + helpValue: "", + }), + "management-url": Flags.string({ + description: `Management API base URL. Default ${DEFAULT_MANAGEMENT_URL}.`, + helpValue: "", + }), + }; + + /** + * The Cloud project a management command acts on, when it acts on one: + * `--project` names an entry of the config file for its `cloudId` here, + * rather than for its URL and key (ADR 0006). Spread next to `--id`. + */ + static cloudProjectFlags = { + project: Flags.string({ + description: + "A project of the config file, whose cloudId names the Cloud project, in place of --id.", + helpValue: "", + }), + }; + /** Shared group for write commands whose API response is a task. */ static waitFlags = { wait: Flags.boolean({ @@ -161,8 +195,8 @@ export abstract class BaseCommand extends Command { protected outputMode!: OutputMode; /** Set when `static api` is `meilisearch`. */ protected meilisearch!: Meilisearch; - /** Set when `static api` is `management`. The client is built here once it is generated. */ - protected management: ManagementCredentials | undefined; + /** Set when `static api` is `management`. Passed to the generated SDK functions, which throw contract errors. */ + protected managementClient!: ManagementClient; /** The process environment, the one place `src/` reads it from outside `config.ts`. */ protected get env(): Env { @@ -201,12 +235,25 @@ export abstract class BaseCommand extends Command { this.meilisearch = createMeilisearchClient({ url: connection.url, apiKey: connection.apiKey, - version: this.config.version, + userAgent: this.userAgent, }); return; } - this.management = await resolveManagementCredentials(this.env, writeWarning); + const credentials = await resolveManagementCredentials( + { token: this.globalFlags["management-token"], url: this.globalFlags["management-url"] }, + this.env, + writeWarning, + ); + this.managementClient = createManagementClient({ + ...credentials, + userAgent: this.userAgent, + }); + } + + /** What both APIs see in their client header: `meili-cli (v1.2.3)`. */ + private get userAgent(): string { + return `meili-cli (v${this.config.version})`; } /** Renders every error as the contract says, then exits with its code. */ @@ -220,6 +267,13 @@ export abstract class BaseCommand extends Command { this.exit(rendered.exitCode); } + /** The Cloud project a management command acts on: `--id`, or the `cloudId` of the entry `--project` names. */ + protected cloudProjectId(id: number | undefined): number { + return resolveCloudProjectId({ id, project: this.globalFlags.project }, () => + this.configFile(), + ); + } + /** The config file of docs/contract/config.md, empty when it does not exist. */ protected configFile(): ConfigFile { return readConfigFile(configFilePath(this.env), writeWarning); diff --git a/src/lib/clients/management.ts b/src/lib/clients/management.ts new file mode 100644 index 0000000..c9c796e --- /dev/null +++ b/src/lib/clients/management.ts @@ -0,0 +1,42 @@ +/** + * Builds the generated Management API client from resolved credentials. The + * client throws on any answer that is not 2xx, and the error interceptor + * below turns what it throws into the contract's errors. + */ +import { cannotConnectMessage, ManagementApiError, MeiliCliError } from "../errors.js"; +import { type Client, createClient } from "./management/generated/client/index.js"; + +export type ManagementClient = Client; + +export interface ManagementClientOptions { + url: string; + token: string; + /** `meili-cli (v1.2.3)`, sent in the User-Agent header. */ + userAgent: string; +} + +/** + * A non-2xx answer becomes a ManagementApiError carrying the error body the + * API sent, which the contract passes through (output.md, "Errors"); no answer + * at all is `cli_network`. The SDK functions then return the body. + */ +export function createManagementClient(options: ManagementClientOptions): ManagementClient { + const client = createClient({ + baseUrl: options.url, + auth: options.token, + headers: { "User-Agent": options.userAgent }, + throwOnError: true, + }); + client.interceptors.error.use((error, response, request, options) => + response === undefined + ? new MeiliCliError( + "cli_network", + cannotConnectMessage(client.getConfig().baseUrl ?? "", error), + { + cause: error, + }, + ) + : new ManagementApiError(response.status, error, request?.url ?? options.url), + ); + return client; +} diff --git a/src/lib/clients/management/fetch-types.d.ts b/src/lib/clients/management/fetch-types.d.ts new file mode 100644 index 0000000..e1362b0 --- /dev/null +++ b/src/lib/clients/management/fetch-types.d.ts @@ -0,0 +1,6 @@ +/** + * The generated client names `BodyInit`, a type of the DOM library. Node's types + * declare the same type only through `RequestInit`; this gives it its name + * without adding the DOM library to a CLI. + */ +type BodyInit = NonNullable; diff --git a/src/lib/clients/management/generated/client.gen.ts b/src/lib/clients/management/generated/client.gen.ts new file mode 100644 index 0000000..d78859f --- /dev/null +++ b/src/lib/clients/management/generated/client.gen.ts @@ -0,0 +1,16 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { type Client, type ClientOptions, type Config, createClient, createConfig } from './client/index.js'; +import type { ClientOptions as ClientOptions2 } from './types.gen.js'; + +/** + * The `createClientConfig()` function will be called on client initialization + * and the returned object will become the client's initial configuration. + * + * You may want to initialize your client this way instead of calling + * `setConfig()`. This is useful for example if you're using Next.js + * to ensure your client always has the correct values. + */ +export type CreateClientConfig = (override?: Config) => Config & T>; + +export const client: Client = createClient(createConfig({ throwOnError: true })); diff --git a/src/lib/clients/management/generated/client/client.gen.ts b/src/lib/clients/management/generated/client/client.gen.ts new file mode 100644 index 0000000..f3c1fa9 --- /dev/null +++ b/src/lib/clients/management/generated/client/client.gen.ts @@ -0,0 +1,277 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { createSseClient } from '../core/serverSentEvents.gen.js'; +import type { HttpMethod } from '../core/types.gen.js'; +import { getValidRequestBody } from '../core/utils.gen.js'; +import type { Client, Config, RequestOptions, ResolvedRequestOptions } from './types.gen.js'; +import { + buildUrl, + createConfig, + createInterceptors, + getParseAs, + mergeConfigs, + mergeHeaders, + setAuthParams, +} from './utils.gen.js'; + +type ReqInit = Omit & { + body?: any; + headers: ReturnType; +}; + +export const createClient = (config: Config = {}): Client => { + let _config = mergeConfigs(createConfig(), config); + + const getConfig = (): Config => ({ ..._config }); + + const setConfig = (config: Config): Config => { + _config = mergeConfigs(_config, config); + return getConfig(); + }; + + const interceptors = createInterceptors(); + + const beforeRequest = async < + TData = unknown, + TResponseStyle extends 'data' | 'fields' = 'fields', + ThrowOnError extends boolean = boolean, + Url extends string = string, + >( + options: RequestOptions, + ) => { + const opts = { + ..._config, + ...options, + fetch: options.fetch ?? _config.fetch ?? globalThis.fetch, + headers: mergeHeaders(_config.headers, options.headers), + serializedBody: undefined as string | undefined, + }; + + if (opts.security) { + await setAuthParams(opts); + } + + if (opts.requestValidator) { + await opts.requestValidator(opts); + } + + if (opts.body !== undefined && opts.bodySerializer) { + opts.serializedBody = opts.bodySerializer(opts.body) as string | undefined; + } + + // remove Content-Type header if body is empty to avoid sending invalid requests + if (opts.body === undefined || opts.serializedBody === '') { + opts.headers.delete('Content-Type'); + } + + const resolvedOpts = opts as typeof opts & + ResolvedRequestOptions; + const url = buildUrl(resolvedOpts); + + return { opts: resolvedOpts, url }; + }; + + const request: Client['request'] = async (options) => { + const throwOnError = options.throwOnError ?? _config.throwOnError; + const responseStyle = options.responseStyle ?? _config.responseStyle; + + let request: Request | undefined; + let response: Response | undefined; + + try { + const { opts, url } = await beforeRequest(options); + const requestInit: ReqInit = { + redirect: 'follow', + ...opts, + body: getValidRequestBody(opts), + }; + + request = new Request(url, requestInit); + + for (const fn of interceptors.request.fns) { + if (fn) { + request = await fn(request, opts); + } + } + + // fetch must be assigned here, otherwise it would throw the error: + // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation + const _fetch = opts.fetch!; + + response = await _fetch(request); + + for (const fn of interceptors.response.fns) { + if (fn) { + response = await fn(response, request, opts); + } + } + + const result = { + request, + response, + }; + + if (response.ok) { + const parseAs = + (opts.parseAs === 'auto' + ? getParseAs(response.headers.get('Content-Type')) + : opts.parseAs) ?? 'json'; + + if (response.status === 204 || response.headers.get('Content-Length') === '0') { + let emptyData: any; + switch (parseAs) { + case 'arrayBuffer': + case 'blob': + case 'text': + emptyData = await response[parseAs](); + break; + case 'formData': + emptyData = new FormData(); + break; + case 'stream': + emptyData = response.body; + break; + case 'json': + default: + emptyData = {}; + break; + } + return opts.responseStyle === 'data' + ? emptyData + : { + data: emptyData, + ...result, + }; + } + + let data: any; + switch (parseAs) { + case 'arrayBuffer': + case 'blob': + case 'formData': + case 'text': + data = await response[parseAs](); + break; + case 'json': { + // Some servers return 200 with no Content-Length and empty body. + // response.json() would throw; read as text and parse if non-empty. + const text = await response.text(); + data = text ? JSON.parse(text) : {}; + break; + } + case 'stream': + return opts.responseStyle === 'data' + ? response.body + : { + data: response.body, + ...result, + }; + } + + if (parseAs === 'json') { + if (opts.responseValidator) { + await opts.responseValidator(data); + } + + if (opts.responseTransformer) { + data = await opts.responseTransformer(data); + } + } + + return opts.responseStyle === 'data' + ? data + : { + data, + ...result, + }; + } + + const textError = await response.text(); + let jsonError: unknown; + + try { + jsonError = JSON.parse(textError); + } catch { + // noop + } + + throw jsonError ?? textError; + } catch (error) { + let finalError = error; + + for (const fn of interceptors.error.fns) { + if (fn) { + finalError = await fn(finalError, response, request, options as ResolvedRequestOptions); + } + } + + finalError = finalError || {}; + + if (throwOnError) { + throw finalError; + } + + // TODO: we probably want to return error and improve types + return responseStyle === 'data' + ? undefined + : { + error: finalError, + request, + response, + }; + } + }; + + const makeMethodFn = (method: Uppercase) => (options: RequestOptions) => + request({ ...options, method }); + + const makeSseFn = (method: Uppercase) => async (options: RequestOptions) => { + const { opts, url } = await beforeRequest(options); + return createSseClient({ + ...opts, + body: opts.body as BodyInit | null | undefined, + method, + onRequest: async (url, init) => { + let request = new Request(url, init); + for (const fn of interceptors.request.fns) { + if (fn) { + request = await fn(request, opts); + } + } + return request; + }, + serializedBody: getValidRequestBody(opts) as BodyInit | null | undefined, + url, + }); + }; + + const _buildUrl: Client['buildUrl'] = (options) => buildUrl({ ..._config, ...options }); + + return { + buildUrl: _buildUrl, + connect: makeMethodFn('CONNECT'), + delete: makeMethodFn('DELETE'), + get: makeMethodFn('GET'), + getConfig, + head: makeMethodFn('HEAD'), + interceptors, + options: makeMethodFn('OPTIONS'), + patch: makeMethodFn('PATCH'), + post: makeMethodFn('POST'), + put: makeMethodFn('PUT'), + request, + setConfig, + sse: { + connect: makeSseFn('CONNECT'), + delete: makeSseFn('DELETE'), + get: makeSseFn('GET'), + head: makeSseFn('HEAD'), + options: makeSseFn('OPTIONS'), + patch: makeSseFn('PATCH'), + post: makeSseFn('POST'), + put: makeSseFn('PUT'), + trace: makeSseFn('TRACE'), + }, + trace: makeMethodFn('TRACE'), + } as Client; +}; diff --git a/src/lib/clients/management/generated/client/index.ts b/src/lib/clients/management/generated/client/index.ts new file mode 100644 index 0000000..51b14da --- /dev/null +++ b/src/lib/clients/management/generated/client/index.ts @@ -0,0 +1,27 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type { Auth } from '../core/auth.gen.js'; +export type { QuerySerializerOptions } from '../core/bodySerializer.gen.js'; +export { + formDataBodySerializer, + jsonBodySerializer, + urlSearchParamsBodySerializer, +} from '../core/bodySerializer.gen.js'; +export { buildClientParams } from '../core/params.gen.js'; +export { serializeQueryKeyValue } from '../core/queryKeySerializer.gen.js'; +export type { ServerSentEventsResult } from '../core/serverSentEvents.gen.js'; +export type { ClientMeta } from '../core/types.gen.js'; +export { createClient } from './client.gen.js'; +export type { + Client, + ClientOptions, + Config, + CreateClientConfig, + Options, + RequestOptions, + RequestResult, + ResolvedRequestOptions, + ResponseStyle, + TDataShape, +} from './types.gen.js'; +export { createConfig, mergeHeaders } from './utils.gen.js'; diff --git a/src/lib/clients/management/generated/client/types.gen.ts b/src/lib/clients/management/generated/client/types.gen.ts new file mode 100644 index 0000000..b6d86fe --- /dev/null +++ b/src/lib/clients/management/generated/client/types.gen.ts @@ -0,0 +1,218 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Auth } from '../core/auth.gen.js'; +import type { + ServerSentEventsOptions, + ServerSentEventsResult, +} from '../core/serverSentEvents.gen.js'; +import type { Client as CoreClient, Config as CoreConfig } from '../core/types.gen.js'; +import type { Middleware } from './utils.gen.js'; + +export type ResponseStyle = 'data' | 'fields'; + +export interface Config + extends Omit, CoreConfig { + /** + * Base URL for all requests made by this client. + */ + baseUrl?: T['baseUrl']; + /** + * Fetch API implementation. You can use this option to provide a custom + * fetch instance. + * + * @default globalThis.fetch + */ + fetch?: typeof fetch; + /** + * Please don't use the Fetch client for Next.js applications. The `next` + * options won't have any effect. + * + * Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead. + */ + next?: never; + /** + * Return the response data parsed in a specified format. By default, `auto` + * will infer the appropriate method from the `Content-Type` response header. + * You can override this behavior with any of the {@link Body} methods. + * Select `stream` if you don't want to parse response data at all. + * + * @default 'auto' + */ + parseAs?: 'arrayBuffer' | 'auto' | 'blob' | 'formData' | 'json' | 'stream' | 'text'; + /** + * Should we return only data or multiple fields (data, error, response, etc.)? + * + * @default 'fields' + */ + responseStyle?: ResponseStyle; + /** + * Throw an error instead of returning it in the response? + * + * @default false + */ + throwOnError?: T['throwOnError']; +} + +export interface RequestOptions< + TData = unknown, + TResponseStyle extends ResponseStyle = 'fields', + ThrowOnError extends boolean = boolean, + Url extends string = string, +> + extends + Config<{ + responseStyle: TResponseStyle; + throwOnError: ThrowOnError; + }>, + Pick< + ServerSentEventsOptions, + | 'onRequest' + | 'onSseError' + | 'onSseEvent' + | 'sseDefaultRetryDelay' + | 'sseMaxRetryAttempts' + | 'sseMaxRetryDelay' + > { + /** + * Any body that you want to add to your request. + * + * {@link https://developer.mozilla.org/docs/Web/API/fetch#body} + */ + body?: unknown; + path?: Record; + query?: Record; + /** + * Security mechanism(s) to use for the request. + */ + security?: ReadonlyArray; + url: Url; +} + +export interface ResolvedRequestOptions< + TResponseStyle extends ResponseStyle = 'fields', + ThrowOnError extends boolean = boolean, + Url extends string = string, +> extends RequestOptions { + headers: Headers; + serializedBody?: string; +} + +export type RequestResult< + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = boolean, + TResponseStyle extends ResponseStyle = 'fields', +> = ThrowOnError extends true + ? Promise< + TResponseStyle extends 'data' + ? TData extends Record + ? TData[keyof TData] + : TData + : { + data: TData extends Record ? TData[keyof TData] : TData; + request: Request; + response: Response; + } + > + : Promise< + TResponseStyle extends 'data' + ? (TData extends Record ? TData[keyof TData] : TData) | undefined + : ( + | { + data: TData extends Record ? TData[keyof TData] : TData; + error: undefined; + } + | { + data: undefined; + error: TError extends Record ? TError[keyof TError] : TError; + } + ) & { + /** request may be undefined, because error may be from building the request object itself */ + request?: Request; + /** response may be undefined, because error may be from building the request object itself or from a network error */ + response?: Response; + } + >; + +export interface ClientOptions { + baseUrl?: string; + responseStyle?: ResponseStyle; + throwOnError?: boolean; +} + +type MethodFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = 'fields', +>( + options: Omit, 'method'>, +) => RequestResult; + +type SseFn = < + TData = unknown, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = 'fields', +>( + options: Omit, 'method'>, +) => Promise>; + +type RequestFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = 'fields', +>( + options: Omit, 'method'> & + Pick>, 'method'>, +) => RequestResult; + +type BuildUrlFn = < + TData extends { + body?: unknown; + path?: Record; + query?: Record; + url: string; + }, +>( + options: TData & Options, +) => string; + +export type Client = CoreClient & { + interceptors: Middleware; +}; + +/** + * The `createClientConfig()` function will be called on client initialization + * and the returned object will become the client's initial configuration. + * + * You may want to initialize your client this way instead of calling + * `setConfig()`. This is useful for example if you're using Next.js + * to ensure your client always has the correct values. + */ +export type CreateClientConfig = ( + override?: Config, +) => Config & T>; + +export interface TDataShape { + body?: unknown; + headers?: unknown; + path?: unknown; + query?: unknown; + url: string; +} + +type OmitKeys = Pick>; + +export type Options< + TData extends TDataShape = TDataShape, + ThrowOnError extends boolean = boolean, + TResponse = unknown, + TResponseStyle extends ResponseStyle = 'fields', +> = OmitKeys< + RequestOptions, + 'body' | 'path' | 'query' | 'url' +> & + ([TData] extends [never] ? unknown : Omit); diff --git a/src/lib/clients/management/generated/client/utils.gen.ts b/src/lib/clients/management/generated/client/utils.gen.ts new file mode 100644 index 0000000..af43c0d --- /dev/null +++ b/src/lib/clients/management/generated/client/utils.gen.ts @@ -0,0 +1,316 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { getAuthToken } from '../core/auth.gen.js'; +import type { QuerySerializerOptions } from '../core/bodySerializer.gen.js'; +import { jsonBodySerializer } from '../core/bodySerializer.gen.js'; +import { + serializeArrayParam, + serializeObjectParam, + serializePrimitiveParam, +} from '../core/pathSerializer.gen.js'; +import { getUrl } from '../core/utils.gen.js'; +import type { Client, ClientOptions, Config, RequestOptions } from './types.gen.js'; + +export const createQuerySerializer = ({ + parameters = {}, + ...args +}: QuerySerializerOptions = {}): ((queryParams: T) => string) => { + const querySerializer = (queryParams: T): string => { + const search: string[] = []; + if (queryParams && typeof queryParams === 'object') { + for (const name in queryParams) { + const value = queryParams[name]; + + if (value === undefined || value === null) { + continue; + } + + const options = parameters[name] || args; + + if (Array.isArray(value)) { + const serializedArray = serializeArrayParam({ + allowReserved: options.allowReserved, + explode: true, + name, + style: 'form', + value, + ...options.array, + }); + if (serializedArray) search.push(serializedArray); + } else if (typeof value === 'object') { + const serializedObject = serializeObjectParam({ + allowReserved: options.allowReserved, + explode: true, + name, + style: 'deepObject', + value: value as Record, + ...options.object, + }); + if (serializedObject) search.push(serializedObject); + } else { + const serializedPrimitive = serializePrimitiveParam({ + allowReserved: options.allowReserved, + name, + value: value as string, + }); + if (serializedPrimitive) search.push(serializedPrimitive); + } + } + } + return search.join('&'); + }; + return querySerializer; +}; + +/** + * Infers parseAs value from provided Content-Type header. + */ +export const getParseAs = (contentType: string | null): Exclude => { + if (!contentType) { + // If no Content-Type header is provided, the best we can do is return the raw response body, + // which is effectively the same as the 'stream' option. + return 'stream'; + } + + const cleanContent = contentType.split(';')[0]?.trim(); + + if (!cleanContent) { + return; + } + + if (cleanContent.startsWith('application/json') || cleanContent.endsWith('+json')) { + return 'json'; + } + + if (cleanContent === 'multipart/form-data') { + return 'formData'; + } + + if ( + ['application/', 'audio/', 'image/', 'video/'].some((type) => cleanContent.startsWith(type)) + ) { + return 'blob'; + } + + if (cleanContent.startsWith('text/')) { + return 'text'; + } + + return; +}; + +const checkForExistence = ( + options: Pick & { + headers: Headers; + }, + name?: string, +): boolean => { + if (!name) { + return false; + } + if ( + options.headers.has(name) || + options.query?.[name] || + options.headers.get('Cookie')?.includes(`${name}=`) + ) { + return true; + } + return false; +}; + +export async function setAuthParams( + options: Pick & { + headers: Headers; + }, +): Promise { + for (const auth of options.security ?? []) { + if (checkForExistence(options, auth.name)) { + continue; + } + + const token = await getAuthToken(auth, options.auth); + + if (!token) { + continue; + } + + const name = auth.name ?? 'Authorization'; + + switch (auth.in) { + case 'query': + if (!options.query) { + options.query = {}; + } + options.query[name] = token; + break; + case 'cookie': + options.headers.append('Cookie', `${name}=${token}`); + break; + case 'header': + default: + options.headers.set(name, token); + break; + } + } +} + +export const buildUrl: Client['buildUrl'] = (options) => + getUrl({ + baseUrl: options.baseUrl as string, + path: options.path, + query: options.query, + querySerializer: + typeof options.querySerializer === 'function' + ? options.querySerializer + : createQuerySerializer(options.querySerializer), + url: options.url, + }); + +export const mergeConfigs = (a: Config, b: Config): Config => { + const config = { ...a, ...b }; + if (config.baseUrl?.endsWith('/')) { + config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1); + } + config.headers = mergeHeaders(a.headers, b.headers); + return config; +}; + +const headersEntries = (headers: Headers): Array<[string, string]> => { + const entries: Array<[string, string]> = []; + headers.forEach((value, key) => { + entries.push([key, value]); + }); + return entries; +}; + +export const mergeHeaders = ( + ...headers: Array['headers'] | undefined> +): Headers => { + const mergedHeaders = new Headers(); + for (const header of headers) { + if (!header) { + continue; + } + + const iterator = header instanceof Headers ? headersEntries(header) : Object.entries(header); + + for (const [key, value] of iterator) { + if (value === null) { + mergedHeaders.delete(key); + } else if (Array.isArray(value)) { + for (const v of value) { + mergedHeaders.append(key, v as string); + } + } else if (value !== undefined) { + // assume object headers are meant to be JSON stringified, i.e., their + // content value in OpenAPI specification is 'application/json' + mergedHeaders.set( + key, + typeof value === 'object' ? JSON.stringify(value) : (value as string), + ); + } + } + } + return mergedHeaders; +}; + +type ErrInterceptor = ( + error: Err, + /** response may be undefined due to a network error where no response object is produced */ + response: Res | undefined, + /** request may be undefined, because error may be from building the request object itself */ + request: Req | undefined, + options: Options, +) => Err | Promise; + +type ReqInterceptor = (request: Req, options: Options) => Req | Promise; + +type ResInterceptor = ( + response: Res, + request: Req, + options: Options, +) => Res | Promise; + +class Interceptors { + fns: Array = []; + + clear(): void { + this.fns = []; + } + + eject(id: number | Interceptor): void { + const index = this.getInterceptorIndex(id); + if (this.fns[index]) { + this.fns[index] = null; + } + } + + exists(id: number | Interceptor): boolean { + const index = this.getInterceptorIndex(id); + return Boolean(this.fns[index]); + } + + getInterceptorIndex(id: number | Interceptor): number { + if (typeof id === 'number') { + return this.fns[id] ? id : -1; + } + return this.fns.indexOf(id); + } + + update(id: number | Interceptor, fn: Interceptor): number | Interceptor | false { + const index = this.getInterceptorIndex(id); + if (this.fns[index]) { + this.fns[index] = fn; + return id; + } + return false; + } + + use(fn: Interceptor): number { + this.fns.push(fn); + return this.fns.length - 1; + } +} + +export interface Middleware { + error: Interceptors>; + request: Interceptors>; + response: Interceptors>; +} + +export const createInterceptors = (): Middleware< + Req, + Res, + Err, + Options +> => ({ + error: new Interceptors>(), + request: new Interceptors>(), + response: new Interceptors>(), +}); + +const defaultQuerySerializer = createQuerySerializer({ + allowReserved: false, + array: { + explode: true, + style: 'form', + }, + object: { + explode: true, + style: 'deepObject', + }, +}); + +const defaultHeaders = { + 'Content-Type': 'application/json', +}; + +export const createConfig = ( + override: Config & T> = {}, +): Config & T> => ({ + ...jsonBodySerializer, + headers: defaultHeaders, + parseAs: 'auto', + querySerializer: defaultQuerySerializer, + ...override, +}); diff --git a/src/lib/clients/management/generated/core/auth.gen.ts b/src/lib/clients/management/generated/core/auth.gen.ts new file mode 100644 index 0000000..c663664 --- /dev/null +++ b/src/lib/clients/management/generated/core/auth.gen.ts @@ -0,0 +1,48 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type AuthToken = string | undefined; + +export interface Auth { + /** + * Which part of the request do we use to send the auth? + * + * @default 'header' + */ + in?: 'header' | 'query' | 'cookie'; + /** + * A unique identifier for the security scheme. + * + * Defined only when there are multiple security schemes whose `Auth` + * shape would otherwise be identical. + */ + key?: string; + /** + * Header or query parameter name. + * + * @default 'Authorization' + */ + name?: string; + scheme?: 'basic' | 'bearer'; + type: 'apiKey' | 'http'; +} + +export const getAuthToken = async ( + auth: Auth, + callback: ((auth: Auth) => Promise | AuthToken) | AuthToken, +): Promise => { + const token = typeof callback === 'function' ? await callback(auth) : callback; + + if (!token) { + return; + } + + if (auth.scheme === 'bearer') { + return `Bearer ${token}`; + } + + if (auth.scheme === 'basic') { + return `Basic ${btoa(token)}`; + } + + return token; +}; diff --git a/src/lib/clients/management/generated/core/bodySerializer.gen.ts b/src/lib/clients/management/generated/core/bodySerializer.gen.ts new file mode 100644 index 0000000..71b4bba --- /dev/null +++ b/src/lib/clients/management/generated/core/bodySerializer.gen.ts @@ -0,0 +1,82 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { ArrayStyle, ObjectStyle, SerializerOptions } from './pathSerializer.gen.js'; + +export type QuerySerializer = (query: Record) => string; + +export type BodySerializer = (body: unknown) => unknown; + +type QuerySerializerOptionsObject = { + allowReserved?: boolean; + array?: Partial>; + object?: Partial>; +}; + +export type QuerySerializerOptions = QuerySerializerOptionsObject & { + /** + * Per-parameter serialization overrides. When provided, these settings + * override the global array/object settings for specific parameter names. + */ + parameters?: Record; +}; + +const serializeFormDataPair = (data: FormData, key: string, value: unknown): void => { + if (typeof value === 'string' || value instanceof Blob) { + data.append(key, value); + } else if (value instanceof Date) { + data.append(key, value.toISOString()); + } else { + data.append(key, JSON.stringify(value)); + } +}; + +const serializeUrlSearchParamsPair = (data: URLSearchParams, key: string, value: unknown): void => { + if (typeof value === 'string') { + data.append(key, value); + } else { + data.append(key, JSON.stringify(value)); + } +}; + +export const formDataBodySerializer = { + bodySerializer: (body: unknown): FormData => { + const data = new FormData(); + + Object.entries(body as Record).forEach(([key, value]) => { + if (value === undefined || value === null) { + return; + } + if (Array.isArray(value)) { + value.forEach((v) => serializeFormDataPair(data, key, v)); + } else { + serializeFormDataPair(data, key, value); + } + }); + + return data; + }, +}; + +export const jsonBodySerializer = { + bodySerializer: (body: unknown): string => + JSON.stringify(body, (_key, value) => (typeof value === 'bigint' ? value.toString() : value)), +}; + +export const urlSearchParamsBodySerializer = { + bodySerializer: (body: unknown): string => { + const data = new URLSearchParams(); + + Object.entries(body as Record).forEach(([key, value]) => { + if (value === undefined || value === null) { + return; + } + if (Array.isArray(value)) { + value.forEach((v) => serializeUrlSearchParamsPair(data, key, v)); + } else { + serializeUrlSearchParamsPair(data, key, value); + } + }); + + return data.toString(); + }, +}; diff --git a/src/lib/clients/management/generated/core/params.gen.ts b/src/lib/clients/management/generated/core/params.gen.ts new file mode 100644 index 0000000..5e8908f --- /dev/null +++ b/src/lib/clients/management/generated/core/params.gen.ts @@ -0,0 +1,178 @@ +// This file is auto-generated by @hey-api/openapi-ts + +type Slot = 'body' | 'headers' | 'path' | 'query'; + +export type Field = + | { + in: Exclude; + /** + * Field name. This is the name we want the user to see and use. + */ + key: string; + /** + * Field mapped name. This is the name we want to use in the request. + * If omitted, we use the same value as `key`. + */ + map?: string; + } + | { + in: Extract; + /** + * Key isn't required for bodies. + */ + key?: string; + map?: string; + } + | { + /** + * Field name. This is the name we want the user to see and use. + */ + key: string; + /** + * Field mapped name. This is the name we want to use in the request. + * If `in` is omitted, `map` aliases `key` to the transport layer. + */ + map: Slot; + }; + +export interface Fields { + allowExtra?: Partial>; + args?: ReadonlyArray; +} + +export type FieldsConfig = ReadonlyArray; + +const extraPrefixesMap: Record = { + $body_: 'body', + $headers_: 'headers', + $path_: 'path', + $query_: 'query', +}; +const extraPrefixes = Object.entries(extraPrefixesMap); + +type KeyMap = Map< + string, + | { + in: Slot; + map?: string; + } + | { + in?: never; + map: Slot; + } +>; + +function buildKeyMap(fields: FieldsConfig, map?: KeyMap): KeyMap { + if (!map) { + map = new Map(); + } + + for (const config of fields) { + if ('in' in config) { + if (config.key) { + map.set(config.key, { + in: config.in, + map: config.map, + }); + } + } else if ('key' in config) { + map.set(config.key, { + map: config.map, + }); + } else if (config.args) { + buildKeyMap(config.args, map); + } + } + + return map; +} + +interface Params { + body?: unknown; + headers: Record; + path: Record; + query: Record; +} + +function stripEmptySlots(params: Params): void { + for (const [slot, value] of Object.entries(params)) { + if (slot === 'body') continue; + if (value && typeof value === 'object' && !Array.isArray(value) && !Object.keys(value).length) { + delete params[slot as Slot]; + } + } +} + +export function buildClientParams(args: ReadonlyArray, fields: FieldsConfig): Params { + const params: Params = { + headers: Object.create(null), + path: Object.create(null), + query: Object.create(null), + }; + + const map = buildKeyMap(fields); + + function writeSlot(slot: Slot, key: string, value: unknown): void { + let record = params[slot] as Record | undefined; + if (record === undefined) { + record = Object.create(null) as Record; + params[slot] = record; + } + record[key] = value; + } + + let config: FieldsConfig[number] | undefined; + + for (const [index, arg] of args.entries()) { + if (fields[index]) { + config = fields[index]; + } + + if (!config) { + continue; + } + + if ('in' in config) { + if (config.key) { + const field = map.get(config.key)!; + const name = field.map || config.key; + if (field.in) { + writeSlot(field.in, name, arg); + } + } else { + params.body = arg; + } + } else { + for (const [key, value] of Object.entries(arg ?? {})) { + const field = map.get(key); + + if (field) { + if (field.in) { + const name = field.map || key; + writeSlot(field.in, name, value); + } else { + params[field.map] = value; + } + } else { + const extra = extraPrefixes.find(([prefix]) => key.startsWith(prefix)); + + if (extra) { + const [prefix, slot] = extra; + writeSlot(slot, key.slice(prefix.length), value); + } else if ('allowExtra' in config && config.allowExtra) { + for (const [slot, allowed] of Object.entries(config.allowExtra)) { + if (allowed) { + writeSlot(slot as Slot, key, value); + break; + } + } + } + } + } + } + } + + stripEmptySlots(params); + + return params; +} diff --git a/src/lib/clients/management/generated/core/pathSerializer.gen.ts b/src/lib/clients/management/generated/core/pathSerializer.gen.ts new file mode 100644 index 0000000..fab1ed4 --- /dev/null +++ b/src/lib/clients/management/generated/core/pathSerializer.gen.ts @@ -0,0 +1,171 @@ +// This file is auto-generated by @hey-api/openapi-ts + +interface SerializeOptions extends SerializePrimitiveOptions, SerializerOptions {} + +interface SerializePrimitiveOptions { + allowReserved?: boolean; + name: string; +} + +export interface SerializerOptions { + /** + * @default true + */ + explode: boolean; + style: T; +} + +export type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited'; +export type ArraySeparatorStyle = ArrayStyle | MatrixStyle; +type MatrixStyle = 'label' | 'matrix' | 'simple'; +export type ObjectStyle = 'form' | 'deepObject'; +type ObjectSeparatorStyle = ObjectStyle | MatrixStyle; + +interface SerializePrimitiveParam extends SerializePrimitiveOptions { + value: string; +} + +export const separatorArrayExplode = (style: ArraySeparatorStyle): '.' | ';' | ',' | '&' => { + switch (style) { + case 'label': + return '.'; + case 'matrix': + return ';'; + case 'simple': + return ','; + default: + return '&'; + } +}; + +export const separatorArrayNoExplode = (style: ArraySeparatorStyle): ',' | '|' | '%20' => { + switch (style) { + case 'form': + return ','; + case 'pipeDelimited': + return '|'; + case 'spaceDelimited': + return '%20'; + default: + return ','; + } +}; + +export const separatorObjectExplode = (style: ObjectSeparatorStyle): '.' | ';' | ',' | '&' => { + switch (style) { + case 'label': + return '.'; + case 'matrix': + return ';'; + case 'simple': + return ','; + default: + return '&'; + } +}; + +export const serializeArrayParam = ({ + allowReserved, + explode, + name, + style, + value, +}: SerializeOptions & { + value: unknown[]; +}): string => { + if (!explode) { + const joinedValues = ( + allowReserved ? value : value.map((v) => encodeURIComponent(v as string)) + ).join(separatorArrayNoExplode(style)); + switch (style) { + case 'label': + return `.${joinedValues}`; + case 'matrix': + return `;${name}=${joinedValues}`; + case 'simple': + return joinedValues; + default: + return `${name}=${joinedValues}`; + } + } + + const separator = separatorArrayExplode(style); + const joinedValues = value + .map((v) => { + if (style === 'label' || style === 'simple') { + return allowReserved ? v : encodeURIComponent(v as string); + } + + return serializePrimitiveParam({ + allowReserved, + name, + value: v as string, + }); + }) + .join(separator); + return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues; +}; + +export const serializePrimitiveParam = ({ + allowReserved, + name, + value, +}: SerializePrimitiveParam): string => { + if (value === undefined || value === null) { + return ''; + } + + if (typeof value === 'object') { + throw new Error( + 'Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.', + ); + } + + return `${name}=${allowReserved ? value : encodeURIComponent(value)}`; +}; + +export const serializeObjectParam = ({ + allowReserved, + explode, + name, + style, + value, + valueOnly, +}: SerializeOptions & { + value: Record | Date; + valueOnly?: boolean; +}): string => { + if (value instanceof Date) { + return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`; + } + + if (style !== 'deepObject' && !explode) { + let values: string[] = []; + Object.entries(value).forEach(([key, v]) => { + values = [...values, key, allowReserved ? (v as string) : encodeURIComponent(v as string)]; + }); + const joinedValues = values.join(','); + switch (style) { + case 'form': + return `${name}=${joinedValues}`; + case 'label': + return `.${joinedValues}`; + case 'matrix': + return `;${name}=${joinedValues}`; + default: + return joinedValues; + } + } + + const separator = separatorObjectExplode(style); + const joinedValues = Object.entries(value) + .map(([key, v]) => + serializePrimitiveParam({ + allowReserved, + name: style === 'deepObject' ? `${name}[${key}]` : key, + value: v as string, + }), + ) + .join(separator); + return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues; +}; diff --git a/src/lib/clients/management/generated/core/queryKeySerializer.gen.ts b/src/lib/clients/management/generated/core/queryKeySerializer.gen.ts new file mode 100644 index 0000000..773b065 --- /dev/null +++ b/src/lib/clients/management/generated/core/queryKeySerializer.gen.ts @@ -0,0 +1,117 @@ +// This file is auto-generated by @hey-api/openapi-ts + +/** + * JSON-friendly union that mirrors what Pinia Colada can hash. + */ +export type JsonValue = + | null + | string + | number + | boolean + | JsonValue[] + | { [key: string]: JsonValue }; + +/** + * Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes. + */ +export const queryKeyJsonReplacer = (_key: string, value: unknown): unknown | undefined => { + if (value === undefined || typeof value === 'function' || typeof value === 'symbol') { + return undefined; + } + if (typeof value === 'bigint') { + return value.toString(); + } + if (value instanceof Date) { + return value.toISOString(); + } + return value; +}; + +/** + * Safely stringifies a value and parses it back into a JsonValue. + */ +export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => { + try { + const json = JSON.stringify(input, queryKeyJsonReplacer); + if (json === undefined) { + return undefined; + } + return JSON.parse(json) as JsonValue; + } catch { + return undefined; + } +}; + +/** + * Detects plain objects (including objects with a null prototype). + */ +const isPlainObject = (value: unknown): value is Record => { + if (value === null || typeof value !== 'object') { + return false; + } + const prototype = Object.getPrototypeOf(value as object); + return prototype === Object.prototype || prototype === null; +}; + +/** + * Turns URLSearchParams into a sorted JSON object for deterministic keys. + */ +const serializeSearchParams = (params: URLSearchParams): JsonValue => { + const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b)); + const result: Record = {}; + + for (const [key, value] of entries) { + const existing = result[key]; + if (existing === undefined) { + result[key] = value; + continue; + } + + if (Array.isArray(existing)) { + (existing as string[]).push(value); + } else { + result[key] = [existing, value]; + } + } + + return result; +}; + +/** + * Normalizes any accepted value into a JSON-friendly shape for query keys. + */ +export const serializeQueryKeyValue = (value: unknown): JsonValue | undefined => { + if (value === null) { + return null; + } + + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return value; + } + + if (value === undefined || typeof value === 'function' || typeof value === 'symbol') { + return undefined; + } + + if (typeof value === 'bigint') { + return value.toString(); + } + + if (value instanceof Date) { + return value.toISOString(); + } + + if (Array.isArray(value)) { + return stringifyToJsonValue(value); + } + + if (typeof URLSearchParams !== 'undefined' && value instanceof URLSearchParams) { + return serializeSearchParams(value); + } + + if (isPlainObject(value)) { + return stringifyToJsonValue(value); + } + + return undefined; +}; diff --git a/src/lib/clients/management/generated/core/serverSentEvents.gen.ts b/src/lib/clients/management/generated/core/serverSentEvents.gen.ts new file mode 100644 index 0000000..94fd835 --- /dev/null +++ b/src/lib/clients/management/generated/core/serverSentEvents.gen.ts @@ -0,0 +1,242 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Config } from './types.gen.js'; + +export type ServerSentEventsOptions = Omit & + Pick & { + /** + * Fetch API implementation. You can use this option to provide a custom + * fetch instance. + * + * @default globalThis.fetch + */ + fetch?: typeof fetch; + /** + * Implementing clients can call request interceptors inside this hook. + */ + onRequest?: (url: string, init: RequestInit) => Promise; + /** + * Callback invoked when a network or parsing error occurs during streaming. + * + * This option applies only if the endpoint returns a stream of events. + * + * @param error The error that occurred. + */ + onSseError?: (error: unknown) => void; + /** + * Callback invoked when an event is streamed from the server. + * + * This option applies only if the endpoint returns a stream of events. + * + * @param event Event streamed from the server. + * @returns Nothing (void). + */ + onSseEvent?: (event: StreamEvent) => void; + serializedBody?: RequestInit['body']; + /** + * Default retry delay in milliseconds. + * + * This option applies only if the endpoint returns a stream of events. + * + * @default 3000 + */ + sseDefaultRetryDelay?: number; + /** + * Maximum number of retry attempts before giving up. + */ + sseMaxRetryAttempts?: number; + /** + * Maximum retry delay in milliseconds. + * + * Applies only when exponential backoff is used. + * + * This option applies only if the endpoint returns a stream of events. + * + * @default 30000 + */ + sseMaxRetryDelay?: number; + /** + * Optional sleep function for retry backoff. + * + * Defaults to using `setTimeout`. + */ + sseSleepFn?: (ms: number) => Promise; + url: string; + }; + +export interface StreamEvent { + data: TData; + event?: string; + id?: string; + retry?: number; +} + +export type ServerSentEventsResult = { + stream: AsyncGenerator< + TData extends Record ? TData[keyof TData] : TData, + TReturn, + TNext + >; +}; + +export function createSseClient({ + onRequest, + onSseError, + onSseEvent, + responseTransformer, + responseValidator, + sseDefaultRetryDelay, + sseMaxRetryAttempts, + sseMaxRetryDelay, + sseSleepFn, + url, + ...options +}: ServerSentEventsOptions): ServerSentEventsResult { + let lastEventId: string | undefined; + + const sleep = sseSleepFn ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); + + const createStream = async function* () { + let retryDelay: number = sseDefaultRetryDelay ?? 3000; + let attempt = 0; + const signal = options.signal ?? new AbortController().signal; + + while (true) { + if (signal.aborted) break; + + attempt++; + + const headers = + options.headers instanceof Headers + ? options.headers + : new Headers(options.headers as Record | undefined); + + if (lastEventId !== undefined) { + headers.set('Last-Event-ID', lastEventId); + } + + try { + const requestInit: RequestInit = { + redirect: 'follow', + ...options, + body: options.serializedBody, + headers, + signal, + }; + let request = new Request(url, requestInit); + if (onRequest) { + request = await onRequest(url, requestInit); + } + // fetch must be assigned here, otherwise it would throw the error: + // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation + const _fetch = options.fetch ?? globalThis.fetch; + const response = await _fetch(request); + + if (!response.ok) throw new Error(`SSE failed: ${response.status} ${response.statusText}`); + + if (!response.body) throw new Error('No body in SSE response'); + + const reader = response.body.pipeThrough(new TextDecoderStream()).getReader(); + + let buffer = ''; + + const abortHandler = () => { + try { + reader.cancel(); + } catch { + // noop + } + }; + + signal.addEventListener('abort', abortHandler); + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += value; + buffer = buffer.replace(/\r\n?/g, '\n'); // normalize line endings + + const chunks = buffer.split('\n\n'); + buffer = chunks.pop() ?? ''; + + for (const chunk of chunks) { + const lines = chunk.split('\n'); + const dataLines: Array = []; + let eventName: string | undefined; + + for (const line of lines) { + if (line.startsWith('data:')) { + dataLines.push(line.replace(/^data:\s*/, '')); + } else if (line.startsWith('event:')) { + eventName = line.replace(/^event:\s*/, ''); + } else if (line.startsWith('id:')) { + lastEventId = line.replace(/^id:\s*/, ''); + } else if (line.startsWith('retry:')) { + const parsed = Number.parseInt(line.replace(/^retry:\s*/, ''), 10); + if (!Number.isNaN(parsed)) { + retryDelay = parsed; + } + } + } + + let data: unknown; + let parsedJson = false; + + if (dataLines.length) { + const rawData = dataLines.join('\n'); + try { + data = JSON.parse(rawData); + parsedJson = true; + } catch { + data = rawData; + } + } + + if (parsedJson) { + if (responseValidator) { + await responseValidator(data); + } + + if (responseTransformer) { + data = await responseTransformer(data); + } + } + + onSseEvent?.({ + data, + event: eventName, + id: lastEventId, + retry: retryDelay, + }); + + if (dataLines.length) { + yield data as any; + } + } + } + } finally { + signal.removeEventListener('abort', abortHandler); + reader.releaseLock(); + } + + break; // exit loop on normal completion + } catch (error) { + // connection failed or aborted; retry after delay + onSseError?.(error); + + if (sseMaxRetryAttempts !== undefined && attempt >= sseMaxRetryAttempts) { + break; // stop after firing error + } + + // exponential backoff: double retry each attempt, cap at 30s + const backoff = Math.min(retryDelay * 2 ** (attempt - 1), sseMaxRetryDelay ?? 30000); + await sleep(backoff); + } + } + }; + + const stream = createStream(); + + return { stream }; +} diff --git a/src/lib/clients/management/generated/core/types.gen.ts b/src/lib/clients/management/generated/core/types.gen.ts new file mode 100644 index 0000000..bcef254 --- /dev/null +++ b/src/lib/clients/management/generated/core/types.gen.ts @@ -0,0 +1,110 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Auth, AuthToken } from './auth.gen.js'; +import type { BodySerializer, QuerySerializer, QuerySerializerOptions } from './bodySerializer.gen.js'; + +export type HttpMethod = + | 'connect' + | 'delete' + | 'get' + | 'head' + | 'options' + | 'patch' + | 'post' + | 'put' + | 'trace'; + +export type Client< + RequestFn = never, + Config = unknown, + MethodFn = never, + BuildUrlFn = never, + SseFn = never, +> = { + /** + * Returns the final request URL. + */ + buildUrl: BuildUrlFn; + getConfig: () => Config; + request: RequestFn; + setConfig: (config: Config) => Config; +} & { + [K in HttpMethod]: MethodFn; +} & ([SseFn] extends [never] ? { sse?: never } : { sse: { [K in HttpMethod]: SseFn } }); + +export interface Config { + /** + * Auth token or a function returning auth token. The resolved value will be + * added to the request payload as defined by its `security` array. + */ + auth?: ((auth: Auth) => Promise | AuthToken) | AuthToken; + /** + * A function for serializing request body parameter. By default, + * {@link JSON.stringify()} will be used. + */ + bodySerializer?: BodySerializer | null; + /** + * An object containing any HTTP headers that you want to pre-populate your + * `Headers` object with. + * + * {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more} + */ + headers?: + | RequestInit['headers'] + | Record< + string, + string | number | boolean | (string | number | boolean)[] | null | undefined | unknown + >; + /** + * The request method. + * + * {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more} + */ + method?: Uppercase; + /** + * A function for serializing request query parameters. By default, arrays + * will be exploded in form style, objects will be exploded in deepObject + * style, and reserved characters are percent-encoded. + * + * This method will have no effect if the native `paramsSerializer()` Axios + * API function is used. + * + * {@link https://swagger.io/docs/specification/serialization/#query View examples} + */ + querySerializer?: QuerySerializer | QuerySerializerOptions; + /** + * A function validating request data. This is useful if you want to ensure + * the request conforms to the desired shape, so it can be safely sent to + * the server. + */ + requestValidator?: (data: unknown) => Promise; + /** + * A function transforming response data before it's returned. This is useful + * for post-processing data, e.g., converting ISO strings into Date objects. + */ + responseTransformer?: (data: unknown) => Promise; + /** + * A function validating response data. This is useful if you want to ensure + * the response conforms to the desired shape, so it can be safely passed to + * the transformers and returned to the user. + */ + responseValidator?: (data: unknown) => Promise; +} + +/** + * Arbitrary metadata passed through the `meta` request option. + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface ClientMeta {} + +type IsExactlyNeverOrNeverUndefined = [T] extends [never] + ? true + : [T] extends [never | undefined] + ? [undefined] extends [T] + ? false + : true + : false; + +export type OmitNever> = { + [K in keyof T as IsExactlyNeverOrNeverUndefined extends true ? never : K]: T[K]; +}; diff --git a/src/lib/clients/management/generated/core/utils.gen.ts b/src/lib/clients/management/generated/core/utils.gen.ts new file mode 100644 index 0000000..53c009e --- /dev/null +++ b/src/lib/clients/management/generated/core/utils.gen.ts @@ -0,0 +1,140 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { BodySerializer, QuerySerializer } from './bodySerializer.gen.js'; +import { + type ArraySeparatorStyle, + serializeArrayParam, + serializeObjectParam, + serializePrimitiveParam, +} from './pathSerializer.gen.js'; + +export interface PathSerializer { + path: Record; + url: string; +} + +export const PATH_PARAM_RE: RegExp = /\{[^{}]+\}/g; + +export const defaultPathSerializer = ({ path, url: _url }: PathSerializer): string => { + let url = _url; + const matches = _url.match(PATH_PARAM_RE); + if (matches) { + for (const match of matches) { + let explode = false; + let name = match.substring(1, match.length - 1); + let style: ArraySeparatorStyle = 'simple'; + + if (name.endsWith('*')) { + explode = true; + name = name.substring(0, name.length - 1); + } + + if (name.startsWith('.')) { + name = name.substring(1); + style = 'label'; + } else if (name.startsWith(';')) { + name = name.substring(1); + style = 'matrix'; + } + + const value = path[name]; + + if (value === undefined || value === null) { + continue; + } + + if (Array.isArray(value)) { + url = url.replace(match, serializeArrayParam({ explode, name, style, value })); + continue; + } + + if (typeof value === 'object') { + url = url.replace( + match, + serializeObjectParam({ + explode, + name, + style, + value: value as Record, + valueOnly: true, + }), + ); + continue; + } + + if (style === 'matrix') { + url = url.replace( + match, + `;${serializePrimitiveParam({ + name, + value: value as string, + })}`, + ); + continue; + } + + const replaceValue = encodeURIComponent( + style === 'label' ? `.${value as string}` : (value as string), + ); + url = url.replace(match, replaceValue); + } + } + return url; +}; + +export const getUrl = ({ + baseUrl, + path, + query, + querySerializer, + url: _url, +}: { + baseUrl?: string; + path?: Record; + query?: Record; + querySerializer: QuerySerializer; + url: string; +}): string => { + const pathUrl = _url.startsWith('/') ? _url : `/${_url}`; + let url = (baseUrl ?? '') + pathUrl; + if (path) { + url = defaultPathSerializer({ path, url }); + } + let search = query ? querySerializer(query) : ''; + if (search.startsWith('?')) { + search = search.substring(1); + } + if (search) { + url += `?${search}`; + } + return url; +}; + +export function getValidRequestBody(options: { + body?: unknown; + bodySerializer?: BodySerializer | null; + serializedBody?: unknown; +}): unknown { + const hasBody = options.body !== undefined; + const isSerializedBody = hasBody && options.bodySerializer; + + if (isSerializedBody) { + if ('serializedBody' in options) { + const hasSerializedBody = + options.serializedBody !== undefined && options.serializedBody !== ''; + + return hasSerializedBody ? options.serializedBody : null; + } + + // not all clients implement a serializedBody property (i.e., client-axios) + return options.body !== '' ? options.body : null; + } + + // plain/text body + if (hasBody) { + return options.body; + } + + // no body was provided + return undefined; +} diff --git a/src/lib/clients/management/generated/index.ts b/src/lib/clients/management/generated/index.ts new file mode 100644 index 0000000..dcb3b55 --- /dev/null +++ b/src/lib/clients/management/generated/index.ts @@ -0,0 +1,4 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export { deleteApiV2OrganizationsById, deleteApiV2ProjectsById, deleteApiV2TeamMembers, deleteApiV2TeamsById, getApiV2Me, getApiV2MeilisearchVersions, getApiV2Organizations, getApiV2OrganizationsById, getApiV2Projects, getApiV2ProjectsById, getApiV2ProjectsByProjectIdCost, getApiV2ProjectsByProjectIdUsage, getApiV2Regions, getApiV2Resources, getApiV2TeamMembers, getApiV2Teams, getApiV2TeamsById, type Options, patchApiV2OrganizationsById, patchApiV2ProjectsById, patchApiV2ProjectsByProjectIdMasterKey, patchApiV2ProjectsByProjectIdPlan, patchApiV2ProjectsByProjectIdVersion, patchApiV2TeamsById, postApiV2Projects, postApiV2TeamMembers, postApiV2Teams } from './sdk.gen.js'; +export type { ClientOptions, DeleteApiV2OrganizationsByIdData, DeleteApiV2OrganizationsByIdError, DeleteApiV2OrganizationsByIdErrors, DeleteApiV2OrganizationsByIdResponse, DeleteApiV2OrganizationsByIdResponses, DeleteApiV2ProjectsByIdData, DeleteApiV2ProjectsByIdErrors, DeleteApiV2ProjectsByIdResponse, DeleteApiV2ProjectsByIdResponses, DeleteApiV2TeamMembersData, DeleteApiV2TeamMembersErrors, DeleteApiV2TeamMembersResponse, DeleteApiV2TeamMembersResponses, DeleteApiV2TeamsByIdData, DeleteApiV2TeamsByIdError, DeleteApiV2TeamsByIdErrors, DeleteApiV2TeamsByIdResponse, DeleteApiV2TeamsByIdResponses, Error, GetApiV2MeData, GetApiV2MeErrors, GetApiV2MeilisearchVersionsData, GetApiV2MeilisearchVersionsErrors, GetApiV2MeilisearchVersionsResponse, GetApiV2MeilisearchVersionsResponses, GetApiV2MeResponse, GetApiV2MeResponses, GetApiV2OrganizationsByIdData, GetApiV2OrganizationsByIdErrors, GetApiV2OrganizationsByIdResponse, GetApiV2OrganizationsByIdResponses, GetApiV2OrganizationsData, GetApiV2OrganizationsErrors, GetApiV2OrganizationsResponse, GetApiV2OrganizationsResponses, GetApiV2ProjectsByIdData, GetApiV2ProjectsByIdErrors, GetApiV2ProjectsByIdResponse, GetApiV2ProjectsByIdResponses, GetApiV2ProjectsByProjectIdCostData, GetApiV2ProjectsByProjectIdCostErrors, GetApiV2ProjectsByProjectIdCostResponse, GetApiV2ProjectsByProjectIdCostResponses, GetApiV2ProjectsByProjectIdUsageData, GetApiV2ProjectsByProjectIdUsageErrors, GetApiV2ProjectsByProjectIdUsageResponse, GetApiV2ProjectsByProjectIdUsageResponses, GetApiV2ProjectsData, GetApiV2ProjectsErrors, GetApiV2ProjectsResponse, GetApiV2ProjectsResponses, GetApiV2RegionsData, GetApiV2RegionsError, GetApiV2RegionsErrors, GetApiV2RegionsResponse, GetApiV2RegionsResponses, GetApiV2ResourcesData, GetApiV2ResourcesErrors, GetApiV2ResourcesResponse, GetApiV2ResourcesResponses, GetApiV2TeamMembersData, GetApiV2TeamMembersErrors, GetApiV2TeamMembersResponse, GetApiV2TeamMembersResponses, GetApiV2TeamsByIdData, GetApiV2TeamsByIdError, GetApiV2TeamsByIdErrors, GetApiV2TeamsByIdResponse, GetApiV2TeamsByIdResponses, GetApiV2TeamsData, GetApiV2TeamsError, GetApiV2TeamsErrors, GetApiV2TeamsResponse, GetApiV2TeamsResponses, Limit, MeilisearchVersion, Organization, Page, Pagination, PatchApiV2OrganizationsByIdData, PatchApiV2OrganizationsByIdError, PatchApiV2OrganizationsByIdErrors, PatchApiV2OrganizationsByIdResponse, PatchApiV2OrganizationsByIdResponses, PatchApiV2ProjectsByIdData, PatchApiV2ProjectsByIdErrors, PatchApiV2ProjectsByIdResponse, PatchApiV2ProjectsByIdResponses, PatchApiV2ProjectsByProjectIdMasterKeyData, PatchApiV2ProjectsByProjectIdMasterKeyErrors, PatchApiV2ProjectsByProjectIdMasterKeyResponse, PatchApiV2ProjectsByProjectIdMasterKeyResponses, PatchApiV2ProjectsByProjectIdPlanData, PatchApiV2ProjectsByProjectIdPlanErrors, PatchApiV2ProjectsByProjectIdPlanResponse, PatchApiV2ProjectsByProjectIdPlanResponses, PatchApiV2ProjectsByProjectIdVersionData, PatchApiV2ProjectsByProjectIdVersionErrors, PatchApiV2ProjectsByProjectIdVersionResponse, PatchApiV2ProjectsByProjectIdVersionResponses, PatchApiV2TeamsByIdData, PatchApiV2TeamsByIdError, PatchApiV2TeamsByIdErrors, PatchApiV2TeamsByIdResponse, PatchApiV2TeamsByIdResponses, Plan, PlanFeature, PostApiV2ProjectsData, PostApiV2ProjectsErrors, PostApiV2ProjectsResponse, PostApiV2ProjectsResponses, PostApiV2TeamMembersData, PostApiV2TeamMembersErrors, PostApiV2TeamMembersResponse, PostApiV2TeamMembersResponses, PostApiV2TeamsData, PostApiV2TeamsError, PostApiV2TeamsErrors, PostApiV2TeamsResponse, PostApiV2TeamsResponses, Project, ProjectCost, ProjectCostItem, ProjectUsage, ProjectUsageDay, Region, Resource, Team, TeamMember } from './types.gen.js'; diff --git a/src/lib/clients/management/generated/sdk.gen.ts b/src/lib/clients/management/generated/sdk.gen.ts new file mode 100644 index 0000000..a376360 --- /dev/null +++ b/src/lib/clients/management/generated/sdk.gen.ts @@ -0,0 +1,425 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { client } from './client.gen.js'; +import type { Client, ClientMeta, Options as Options2, RequestResult, TDataShape } from './client/index.js'; +import type { DeleteApiV2OrganizationsByIdData, DeleteApiV2OrganizationsByIdErrors, DeleteApiV2OrganizationsByIdResponses, DeleteApiV2ProjectsByIdData, DeleteApiV2ProjectsByIdErrors, DeleteApiV2ProjectsByIdResponses, DeleteApiV2TeamMembersData, DeleteApiV2TeamMembersErrors, DeleteApiV2TeamMembersResponses, DeleteApiV2TeamsByIdData, DeleteApiV2TeamsByIdErrors, DeleteApiV2TeamsByIdResponses, GetApiV2MeData, GetApiV2MeErrors, GetApiV2MeilisearchVersionsData, GetApiV2MeilisearchVersionsErrors, GetApiV2MeilisearchVersionsResponses, GetApiV2MeResponses, GetApiV2OrganizationsByIdData, GetApiV2OrganizationsByIdErrors, GetApiV2OrganizationsByIdResponses, GetApiV2OrganizationsData, GetApiV2OrganizationsErrors, GetApiV2OrganizationsResponses, GetApiV2ProjectsByIdData, GetApiV2ProjectsByIdErrors, GetApiV2ProjectsByIdResponses, GetApiV2ProjectsByProjectIdCostData, GetApiV2ProjectsByProjectIdCostErrors, GetApiV2ProjectsByProjectIdCostResponses, GetApiV2ProjectsByProjectIdUsageData, GetApiV2ProjectsByProjectIdUsageErrors, GetApiV2ProjectsByProjectIdUsageResponses, GetApiV2ProjectsData, GetApiV2ProjectsErrors, GetApiV2ProjectsResponses, GetApiV2RegionsData, GetApiV2RegionsErrors, GetApiV2RegionsResponses, GetApiV2ResourcesData, GetApiV2ResourcesErrors, GetApiV2ResourcesResponses, GetApiV2TeamMembersData, GetApiV2TeamMembersErrors, GetApiV2TeamMembersResponses, GetApiV2TeamsByIdData, GetApiV2TeamsByIdErrors, GetApiV2TeamsByIdResponses, GetApiV2TeamsData, GetApiV2TeamsErrors, GetApiV2TeamsResponses, PatchApiV2OrganizationsByIdData, PatchApiV2OrganizationsByIdErrors, PatchApiV2OrganizationsByIdResponses, PatchApiV2ProjectsByIdData, PatchApiV2ProjectsByIdErrors, PatchApiV2ProjectsByIdResponses, PatchApiV2ProjectsByProjectIdMasterKeyData, PatchApiV2ProjectsByProjectIdMasterKeyErrors, PatchApiV2ProjectsByProjectIdMasterKeyResponses, PatchApiV2ProjectsByProjectIdPlanData, PatchApiV2ProjectsByProjectIdPlanErrors, PatchApiV2ProjectsByProjectIdPlanResponses, PatchApiV2ProjectsByProjectIdVersionData, PatchApiV2ProjectsByProjectIdVersionErrors, PatchApiV2ProjectsByProjectIdVersionResponses, PatchApiV2TeamsByIdData, PatchApiV2TeamsByIdErrors, PatchApiV2TeamsByIdResponses, PostApiV2ProjectsData, PostApiV2ProjectsErrors, PostApiV2ProjectsResponses, PostApiV2TeamMembersData, PostApiV2TeamMembersErrors, PostApiV2TeamMembersResponses, PostApiV2TeamsData, PostApiV2TeamsErrors, PostApiV2TeamsResponses } from './types.gen.js'; + +export type Options = Options2 & { + /** + * You can provide a client instance returned by `createClient()` instead of + * individual options. This might be also useful if you want to implement a + * custom client. + */ + client?: Client; + /** + * You can pass arbitrary values through the `meta` object. This can be + * used to access values that aren't defined as part of the SDK function. + */ + meta?: keyof ClientMeta extends never ? Record : ClientMeta; +}; + +/** + * Retrieves the authenticated user identity + */ +export const getApiV2Me = (options?: Options): RequestResult => (options?.client ?? client).get({ + responseStyle: 'data', + security: [{ + key: 'personal_access_token', + scheme: 'bearer', + type: 'http' + }], + url: '/api/v2/me', + ...options +}); + +/** + * Lists the active Meilisearch versions + */ +export const getApiV2MeilisearchVersions = (options?: Options): RequestResult => (options?.client ?? client).get({ + responseStyle: 'data', + security: [{ + key: 'personal_access_token', + scheme: 'bearer', + type: 'http' + }], + url: '/api/v2/meilisearch-versions', + ...options +}); + +/** + * Lists organizations the authenticated user can access + */ +export const getApiV2Organizations = (options?: Options): RequestResult => (options?.client ?? client).get({ + responseStyle: 'data', + security: [{ + key: 'personal_access_token', + scheme: 'bearer', + type: 'http' + }], + url: '/api/v2/organizations', + ...options +}); + +/** + * Deletes an organization + */ +export const deleteApiV2OrganizationsById = (options: Options): RequestResult => (options.client ?? client).delete({ + responseStyle: 'data', + security: [{ + key: 'personal_access_token', + scheme: 'bearer', + type: 'http' + }], + url: '/api/v2/organizations/{id}', + ...options +}); + +/** + * Retrieves an organization + */ +export const getApiV2OrganizationsById = (options: Options): RequestResult => (options.client ?? client).get({ + responseStyle: 'data', + security: [{ + key: 'personal_access_token', + scheme: 'bearer', + type: 'http' + }], + url: '/api/v2/organizations/{id}', + ...options +}); + +/** + * Updates an organization + */ +export const patchApiV2OrganizationsById = (options: Options): RequestResult => (options.client ?? client).patch({ + responseStyle: 'data', + security: [{ + key: 'personal_access_token', + scheme: 'bearer', + type: 'http' + }], + url: '/api/v2/organizations/{id}', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Returns the estimated cost of a project usage + * + * Gross estimation of the cost of the project usage over the window: quantity × unit price of the project plan, before free tiers shared across the organization, discounts and taxes. The window is the same as the usage one. + */ +export const getApiV2ProjectsByProjectIdCost = (options: Options): RequestResult => (options.client ?? client).get({ + responseStyle: 'data', + security: [{ + key: 'personal_access_token', + scheme: 'bearer', + type: 'http' + }], + url: '/api/v2/projects/{projectId}/cost', + ...options +}); + +/** + * Rotates the master key of a project + * + * Generates a new master key and restarts the instance to apply it. The previous master key and every API key derived from it stop working. + */ +export const patchApiV2ProjectsByProjectIdMasterKey = (options: Options): RequestResult => (options.client ?? client).patch({ + responseStyle: 'data', + security: [{ + key: 'personal_access_token', + scheme: 'bearer', + type: 'http' + }], + url: '/api/v2/projects/{projectId}/master-key', + ...options +}); + +/** + * Changes the plan (resource) of a project + */ +export const patchApiV2ProjectsByProjectIdPlan = (options: Options): RequestResult => (options.client ?? client).patch({ + responseStyle: 'data', + security: [{ + key: 'personal_access_token', + scheme: 'bearer', + type: 'http' + }], + url: '/api/v2/projects/{projectId}/plan', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Returns the daily usage of a project + * + * Usage per UTC day. The window is clamped to the project lifetime, terminated projects included. + */ +export const getApiV2ProjectsByProjectIdUsage = (options: Options): RequestResult => (options.client ?? client).get({ + responseStyle: 'data', + security: [{ + key: 'personal_access_token', + scheme: 'bearer', + type: 'http' + }], + url: '/api/v2/projects/{projectId}/usage', + ...options +}); + +/** + * Upgrades the Meilisearch version of a project + */ +export const patchApiV2ProjectsByProjectIdVersion = (options: Options): RequestResult => (options.client ?? client).patch({ + responseStyle: 'data', + security: [{ + key: 'personal_access_token', + scheme: 'bearer', + type: 'http' + }], + url: '/api/v2/projects/{projectId}/version', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Lists the projects of the accessible teams + */ +export const getApiV2Projects = (options?: Options): RequestResult => (options?.client ?? client).get({ + responseStyle: 'data', + security: [{ + key: 'personal_access_token', + scheme: 'bearer', + type: 'http' + }], + url: '/api/v2/projects', + ...options +}); + +/** + * Creates a project in a team + */ +export const postApiV2Projects = (options?: Options): RequestResult => (options?.client ?? client).post({ + responseStyle: 'data', + security: [{ + key: 'personal_access_token', + scheme: 'bearer', + type: 'http' + }], + url: '/api/v2/projects', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } +}); + +/** + * Deletes a project + */ +export const deleteApiV2ProjectsById = (options: Options): RequestResult => (options.client ?? client).delete({ + responseStyle: 'data', + security: [{ + key: 'personal_access_token', + scheme: 'bearer', + type: 'http' + }], + url: '/api/v2/projects/{id}', + ...options +}); + +/** + * Retrieves a project + */ +export const getApiV2ProjectsById = (options: Options): RequestResult => (options.client ?? client).get({ + responseStyle: 'data', + security: [{ + key: 'personal_access_token', + scheme: 'bearer', + type: 'http' + }], + url: '/api/v2/projects/{id}', + ...options +}); + +/** + * Updates a project + */ +export const patchApiV2ProjectsById = (options: Options): RequestResult => (options.client ?? client).patch({ + responseStyle: 'data', + security: [{ + key: 'personal_access_token', + scheme: 'bearer', + type: 'http' + }], + url: '/api/v2/projects/{id}', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Lists the active regions + */ +export const getApiV2Regions = (options?: Options): RequestResult => (options?.client ?? client).get({ + responseStyle: 'data', + security: [{ + key: 'personal_access_token', + scheme: 'bearer', + type: 'http' + }], + url: '/api/v2/regions', + ...options +}); + +/** + * Lists the active resources and their plan pricing + */ +export const getApiV2Resources = (options?: Options): RequestResult => (options?.client ?? client).get({ + responseStyle: 'data', + security: [{ + key: 'personal_access_token', + scheme: 'bearer', + type: 'http' + }], + url: '/api/v2/resources', + ...options +}); + +/** + * Removes a member from a team + */ +export const deleteApiV2TeamMembers = (options?: Options): RequestResult => (options?.client ?? client).delete({ + responseStyle: 'data', + security: [{ + key: 'personal_access_token', + scheme: 'bearer', + type: 'http' + }], + url: '/api/v2/team-members', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } +}); + +/** + * Lists the members and pending invitees of a team + */ +export const getApiV2TeamMembers = (options?: Options): RequestResult => (options?.client ?? client).get({ + responseStyle: 'data', + security: [{ + key: 'personal_access_token', + scheme: 'bearer', + type: 'http' + }], + url: '/api/v2/team-members', + ...options +}); + +/** + * Invites a member to a team + */ +export const postApiV2TeamMembers = (options?: Options): RequestResult => (options?.client ?? client).post({ + responseStyle: 'data', + security: [{ + key: 'personal_access_token', + scheme: 'bearer', + type: 'http' + }], + url: '/api/v2/team-members', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } +}); + +/** + * Lists teams the authenticated user can access + */ +export const getApiV2Teams = (options?: Options): RequestResult => (options?.client ?? client).get({ + responseStyle: 'data', + security: [{ + key: 'personal_access_token', + scheme: 'bearer', + type: 'http' + }], + url: '/api/v2/teams', + ...options +}); + +/** + * Creates a team + */ +export const postApiV2Teams = (options?: Options): RequestResult => (options?.client ?? client).post({ + responseStyle: 'data', + security: [{ + key: 'personal_access_token', + scheme: 'bearer', + type: 'http' + }], + url: '/api/v2/teams', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } +}); + +/** + * Deletes a team + */ +export const deleteApiV2TeamsById = (options: Options): RequestResult => (options.client ?? client).delete({ + responseStyle: 'data', + security: [{ + key: 'personal_access_token', + scheme: 'bearer', + type: 'http' + }], + url: '/api/v2/teams/{id}', + ...options +}); + +/** + * Retrieves a team + */ +export const getApiV2TeamsById = (options: Options): RequestResult => (options.client ?? client).get({ + responseStyle: 'data', + security: [{ + key: 'personal_access_token', + scheme: 'bearer', + type: 'http' + }], + url: '/api/v2/teams/{id}', + ...options +}); + +/** + * Updates a team + */ +export const patchApiV2TeamsById = (options: Options): RequestResult => (options.client ?? client).patch({ + responseStyle: 'data', + security: [{ + key: 'personal_access_token', + scheme: 'bearer', + type: 'http' + }], + url: '/api/v2/teams/{id}', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); diff --git a/src/lib/clients/management/generated/types.gen.ts b/src/lib/clients/management/generated/types.gen.ts new file mode 100644 index 0000000..394c68e --- /dev/null +++ b/src/lib/clients/management/generated/types.gen.ts @@ -0,0 +1,1065 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type ClientOptions = { + baseUrl: 'https://{defaultHost}' | (string & {}); +}; + +/** + * Error body returned by every non-2xx response. Switch on `code`, never on `message`. + */ +export type Error = { + message: string; + /** + * Stable snake_case identifier of the error. + */ + code: string; + type: 'invalid_request' | 'auth' | 'internal'; + /** + * Documentation of this error code. + */ + link: string; + /** + * Per-field details, only present on `validation_failed`. + */ + errors?: Array<{ + field: string; + code: string; + message: string; + }>; +}; + +/** + * Envelope of every list response, the items being in `results`. + */ +export type Pagination = { + page: number; + limit: number; + /** + * Number of items matching the request, all pages included. + */ + total: number; + totalPages: number; +}; + +export type Organization = { + id: number; + name: string; + identifier: string; + ownerId: string; + isAdmin: boolean; + defaultTeamId: number | null; + customerTier: string | null; +}; + +export type Team = { + id: number; + name: string; + organizationId: number; + ownerId: string; + isAdmin: boolean; +}; + +export type TeamMember = { + id?: string | null; + email: string; + name?: string | null; + isOwner?: boolean | null; + status: 'accepted' | 'invited'; + invitedAt?: string | null; +}; + +export type PlanFeature = { + id?: string | null; + name: string; + kind: string; + enabled: boolean; + nickname?: string | null; + description?: string | null; + productName?: string | null; + unitPrice?: number | null; + monthlyEstimation?: number | null; + /** + * Billed per package of units: the quantity is divided by `divideBy` before applying `unitPrice`. + */ + transformQuantity?: { + divideBy?: number; + round?: 'up' | 'down'; + } | null; +}; + +export type Plan = { + id: number; + name: string; + code: string; + features: Array; +}; + +export type Resource = { + id: number; + name: string; + displayName?: string | null; + plan: string; + regionId: number; + storage?: number | null; + memory?: number | null; + vcpu?: number | null; + fullPlan?: Plan | null; +}; + +export type Region = { + id: number; + name: string; + awsRegion: string; + countryCode: string; + greenLabel?: boolean; +}; + +export type MeilisearchVersion = { + name: string; + tag: string; + latest: boolean; +}; + +export type Project = { + id: number; + name: string; + projectUid: string; + url?: string | null; + /** + * Only returned on project creation and master key rotation. + */ + masterKey?: string; + teamId: number; + resourceId: number; + msVersion: string; + workloadType: 'production' | 'dev'; + readOnly: boolean; + status: string; + paused: boolean; + createdAt: string; + terminatedAt?: string | null; + freeTrialEndsAt?: string | null; + resource: Resource; +}; + +/** + * Usage of a UTC day. A null quantity means nothing was collected that day. + */ +export type ProjectUsageDay = { + date: string; + /** + * Hours per machine size (resource), each one having its own hourly price. + */ + runtime: Array<{ + resourceId: number; + hours: number; + }>; + /** + * Provisioned disk. + */ + storageGb: number | null; + /** + * Outbound bandwidth. + */ + bandwidthBytes: number | null; + documents: number | null; + searches: number | null; + chatTokens: number | null; +}; + +export type ProjectUsage = { + projectId: number; + from: string; + to: string; + days: Array; +}; + +/** + * A feature the project is billed per usage for. Monthly prices (storage, documents, base fee) are prorated on the hours the project existed. + */ +export type ProjectCostItem = { + kind: 'instance_runtime' | 'storage' | 'bandwidth' | 'document' | 'search' | 'chat_token' | 'base'; + /** + * Machine size of an instance_runtime item. + */ + resourceId?: number; + quantity: number; + unit: 'hour' | 'GB-month' | 'MiB' | 'document-month' | 'search' | 'token' | 'month'; + unitPrice: number; + /** + * quantity × unitPrice + */ + amount: number; +}; + +export type ProjectCost = { + projectId: number; + from: string; + to: string; + currency: string; + total: number; + items: Array; +}; + +/** + * Page to return, starting at 1. + */ +export type Page = number; + +/** + * Number of items per page. Values above the maximum are capped to it. + */ +export type Limit = number; + +export type GetApiV2MeData = { + body?: never; + path?: never; + query?: never; + url: '/api/v2/me'; +}; + +export type GetApiV2MeErrors = { + /** + * unauthorized + */ + 401: unknown; +}; + +export type GetApiV2MeResponses = { + /** + * current user identity + */ + 200: { + name: string; + email: string; + }; +}; + +export type GetApiV2MeResponse = GetApiV2MeResponses[keyof GetApiV2MeResponses]; + +export type GetApiV2MeilisearchVersionsData = { + body?: never; + path?: never; + query?: { + /** + * Page to return, starting at 1. + */ + page?: number; + /** + * Number of items per page. Values above the maximum are capped to it. + */ + limit?: number; + }; + url: '/api/v2/meilisearch-versions'; +}; + +export type GetApiV2MeilisearchVersionsErrors = { + /** + * unauthorized + */ + 401: unknown; +}; + +export type GetApiV2MeilisearchVersionsResponses = { + /** + * meilisearch versions list + */ + 200: Pagination & { + results: Array; + }; +}; + +export type GetApiV2MeilisearchVersionsResponse = GetApiV2MeilisearchVersionsResponses[keyof GetApiV2MeilisearchVersionsResponses]; + +export type GetApiV2OrganizationsData = { + body?: never; + path?: never; + query?: { + /** + * Page to return, starting at 1. + */ + page?: number; + /** + * Number of items per page. Values above the maximum are capped to it. + */ + limit?: number; + }; + url: '/api/v2/organizations'; +}; + +export type GetApiV2OrganizationsErrors = { + /** + * unauthorized + */ + 401: unknown; +}; + +export type GetApiV2OrganizationsResponses = { + /** + * organizations list + */ + 200: Pagination & { + results: Array; + }; +}; + +export type GetApiV2OrganizationsResponse = GetApiV2OrganizationsResponses[keyof GetApiV2OrganizationsResponses]; + +export type DeleteApiV2OrganizationsByIdData = { + body?: never; + path: { + id: number; + }; + query?: never; + url: '/api/v2/organizations/{id}'; +}; + +export type DeleteApiV2OrganizationsByIdErrors = { + /** + * not the organization owner + */ + 403: unknown; + /** + * Organization deletion has project + */ + 422: Error; +}; + +export type DeleteApiV2OrganizationsByIdError = DeleteApiV2OrganizationsByIdErrors[keyof DeleteApiV2OrganizationsByIdErrors]; + +export type DeleteApiV2OrganizationsByIdResponses = { + /** + * organization deleted + */ + 204: void; +}; + +export type DeleteApiV2OrganizationsByIdResponse = DeleteApiV2OrganizationsByIdResponses[keyof DeleteApiV2OrganizationsByIdResponses]; + +export type GetApiV2OrganizationsByIdData = { + body?: never; + path: { + id: number; + }; + query?: never; + url: '/api/v2/organizations/{id}'; +}; + +export type GetApiV2OrganizationsByIdErrors = { + /** + * organization not accessible + */ + 403: unknown; +}; + +export type GetApiV2OrganizationsByIdResponses = { + /** + * organization found + */ + 200: Organization; +}; + +export type GetApiV2OrganizationsByIdResponse = GetApiV2OrganizationsByIdResponses[keyof GetApiV2OrganizationsByIdResponses]; + +export type PatchApiV2OrganizationsByIdData = { + body?: { + name?: string; + }; + path: { + id: number; + }; + query?: never; + url: '/api/v2/organizations/{id}'; +}; + +export type PatchApiV2OrganizationsByIdErrors = { + /** + * not the organization owner + */ + 403: unknown; + /** + * Validation failed + */ + 422: Error; +}; + +export type PatchApiV2OrganizationsByIdError = PatchApiV2OrganizationsByIdErrors[keyof PatchApiV2OrganizationsByIdErrors]; + +export type PatchApiV2OrganizationsByIdResponses = { + /** + * organization updated + */ + 200: Organization; +}; + +export type PatchApiV2OrganizationsByIdResponse = PatchApiV2OrganizationsByIdResponses[keyof PatchApiV2OrganizationsByIdResponses]; + +export type GetApiV2ProjectsByProjectIdCostData = { + body?: never; + path: { + projectId: number; + }; + query?: { + /** + * First UTC day of the window, inclusive. Defaults to the first day of the current month. + */ + from?: string; + /** + * Last UTC day of the window, inclusive. Defaults to today. The window can not exceed 93 days. + */ + to?: string; + }; + url: '/api/v2/projects/{projectId}/cost'; +}; + +export type GetApiV2ProjectsByProjectIdCostErrors = { + /** + * unauthorized + */ + 401: unknown; + /** + * project not accessible + */ + 403: unknown; + /** + * invalid window: from after to, larger than 93 days or not an ISO 8601 date + */ + 422: unknown; +}; + +export type GetApiV2ProjectsByProjectIdCostResponses = { + /** + * project estimated cost + */ + 200: ProjectCost; +}; + +export type GetApiV2ProjectsByProjectIdCostResponse = GetApiV2ProjectsByProjectIdCostResponses[keyof GetApiV2ProjectsByProjectIdCostResponses]; + +export type PatchApiV2ProjectsByProjectIdMasterKeyData = { + body?: never; + path: { + projectId: number; + }; + query?: never; + url: '/api/v2/projects/{projectId}/master-key'; +}; + +export type PatchApiV2ProjectsByProjectIdMasterKeyErrors = { + /** + * unauthorized + */ + 401: unknown; + /** + * not a team or organization owner + */ + 403: unknown; + /** + * project not running + */ + 423: unknown; +}; + +export type PatchApiV2ProjectsByProjectIdMasterKeyResponses = { + /** + * master key rotated, instance restarting + */ + 202: Project; +}; + +export type PatchApiV2ProjectsByProjectIdMasterKeyResponse = PatchApiV2ProjectsByProjectIdMasterKeyResponses[keyof PatchApiV2ProjectsByProjectIdMasterKeyResponses]; + +export type PatchApiV2ProjectsByProjectIdPlanData = { + body?: { + resourceId: number; + }; + path: { + projectId: number; + }; + query?: never; + url: '/api/v2/projects/{projectId}/plan'; +}; + +export type PatchApiV2ProjectsByProjectIdPlanErrors = { + /** + * unauthorized + */ + 401: unknown; + /** + * organization not self-serve + */ + 403: unknown; + /** + * project in failed state + */ + 422: unknown; +}; + +export type PatchApiV2ProjectsByProjectIdPlanResponses = { + /** + * plan change started + */ + 202: Project; +}; + +export type PatchApiV2ProjectsByProjectIdPlanResponse = PatchApiV2ProjectsByProjectIdPlanResponses[keyof PatchApiV2ProjectsByProjectIdPlanResponses]; + +export type GetApiV2ProjectsByProjectIdUsageData = { + body?: never; + path: { + projectId: number; + }; + query?: { + /** + * First UTC day of the window, inclusive. Defaults to the first day of the current month. + */ + from?: string; + /** + * Last UTC day of the window, inclusive. Defaults to today. The window can not exceed 93 days. + */ + to?: string; + }; + url: '/api/v2/projects/{projectId}/usage'; +}; + +export type GetApiV2ProjectsByProjectIdUsageErrors = { + /** + * unauthorized + */ + 401: unknown; + /** + * project not accessible + */ + 403: unknown; + /** + * invalid window: from after to, larger than 93 days or not an ISO 8601 date + */ + 422: unknown; +}; + +export type GetApiV2ProjectsByProjectIdUsageResponses = { + /** + * project usage + */ + 200: ProjectUsage; +}; + +export type GetApiV2ProjectsByProjectIdUsageResponse = GetApiV2ProjectsByProjectIdUsageResponses[keyof GetApiV2ProjectsByProjectIdUsageResponses]; + +export type PatchApiV2ProjectsByProjectIdVersionData = { + body?: { + version: string; + }; + path: { + projectId: number; + }; + query?: never; + url: '/api/v2/projects/{projectId}/version'; +}; + +export type PatchApiV2ProjectsByProjectIdVersionErrors = { + /** + * unauthorized + */ + 401: unknown; + /** + * project not accessible + */ + 403: unknown; + /** + * already running this version + */ + 422: unknown; + /** + * manual update disabled + */ + 423: unknown; +}; + +export type PatchApiV2ProjectsByProjectIdVersionResponses = { + /** + * version upgrade started + */ + 202: Project; +}; + +export type PatchApiV2ProjectsByProjectIdVersionResponse = PatchApiV2ProjectsByProjectIdVersionResponses[keyof PatchApiV2ProjectsByProjectIdVersionResponses]; + +export type GetApiV2ProjectsData = { + body?: never; + path?: never; + query?: { + /** + * Page to return, starting at 1. + */ + page?: number; + /** + * Number of items per page. Values above the maximum are capped to it. + */ + limit?: number; + teamId?: number; + }; + url: '/api/v2/projects'; +}; + +export type GetApiV2ProjectsErrors = { + /** + * unauthorized + */ + 401: unknown; +}; + +export type GetApiV2ProjectsResponses = { + /** + * projects list + */ + 200: Pagination & { + results: Array; + }; +}; + +export type GetApiV2ProjectsResponse = GetApiV2ProjectsResponses[keyof GetApiV2ProjectsResponses]; + +export type PostApiV2ProjectsData = { + body?: { + name: string; + teamId: number; + resourceId: number; + msVersion?: string; + }; + path?: never; + query?: never; + url: '/api/v2/projects'; +}; + +export type PostApiV2ProjectsErrors = { + /** + * project limit reached + */ + 403: unknown; + /** + * invalid project attributes + */ + 422: unknown; +}; + +export type PostApiV2ProjectsResponses = { + /** + * project created + */ + 201: Project; +}; + +export type PostApiV2ProjectsResponse = PostApiV2ProjectsResponses[keyof PostApiV2ProjectsResponses]; + +export type DeleteApiV2ProjectsByIdData = { + body?: never; + path: { + id: number; + }; + query?: never; + url: '/api/v2/projects/{id}'; +}; + +export type DeleteApiV2ProjectsByIdErrors = { + /** + * project not accessible + */ + 403: unknown; +}; + +export type DeleteApiV2ProjectsByIdResponses = { + /** + * project deleted + */ + 204: void; +}; + +export type DeleteApiV2ProjectsByIdResponse = DeleteApiV2ProjectsByIdResponses[keyof DeleteApiV2ProjectsByIdResponses]; + +export type GetApiV2ProjectsByIdData = { + body?: never; + path: { + id: number; + }; + query?: never; + url: '/api/v2/projects/{id}'; +}; + +export type GetApiV2ProjectsByIdErrors = { + /** + * project not accessible + */ + 403: unknown; +}; + +export type GetApiV2ProjectsByIdResponses = { + /** + * project found + */ + 200: Project; +}; + +export type GetApiV2ProjectsByIdResponse = GetApiV2ProjectsByIdResponses[keyof GetApiV2ProjectsByIdResponses]; + +export type PatchApiV2ProjectsByIdData = { + body?: { + name: string; + }; + path: { + id: number; + }; + query?: never; + url: '/api/v2/projects/{id}'; +}; + +export type PatchApiV2ProjectsByIdErrors = { + /** + * project not accessible + */ + 403: unknown; + /** + * invalid project attributes + */ + 422: unknown; +}; + +export type PatchApiV2ProjectsByIdResponses = { + /** + * project updated + */ + 200: Project; +}; + +export type PatchApiV2ProjectsByIdResponse = PatchApiV2ProjectsByIdResponses[keyof PatchApiV2ProjectsByIdResponses]; + +export type GetApiV2RegionsData = { + body?: never; + path?: never; + query?: { + /** + * Page to return, starting at 1. + */ + page?: number; + /** + * Number of items per page. Values above the maximum are capped to it. + */ + limit?: number; + }; + url: '/api/v2/regions'; +}; + +export type GetApiV2RegionsErrors = { + /** + * Invalid limit + */ + 400: Error; + /** + * unauthorized + */ + 401: unknown; +}; + +export type GetApiV2RegionsError = GetApiV2RegionsErrors[keyof GetApiV2RegionsErrors]; + +export type GetApiV2RegionsResponses = { + /** + * regions list + */ + 200: Pagination & { + results: Array; + }; +}; + +export type GetApiV2RegionsResponse = GetApiV2RegionsResponses[keyof GetApiV2RegionsResponses]; + +export type GetApiV2ResourcesData = { + body?: never; + path?: never; + query?: { + /** + * Page to return, starting at 1. + */ + page?: number; + /** + * Number of items per page. Values above the maximum are capped to it. + */ + limit?: number; + regionId?: number; + }; + url: '/api/v2/resources'; +}; + +export type GetApiV2ResourcesErrors = { + /** + * unauthorized + */ + 401: unknown; +}; + +export type GetApiV2ResourcesResponses = { + /** + * resources list + */ + 200: Pagination & { + results: Array; + }; +}; + +export type GetApiV2ResourcesResponse = GetApiV2ResourcesResponses[keyof GetApiV2ResourcesResponses]; + +export type DeleteApiV2TeamMembersData = { + body?: { + teamId?: number; + email?: string; + }; + path?: never; + query?: never; + url: '/api/v2/team-members'; +}; + +export type DeleteApiV2TeamMembersErrors = { + /** + * not the team or organization owner + */ + 403: unknown; + /** + * owner cannot be removed + */ + 422: unknown; +}; + +export type DeleteApiV2TeamMembersResponses = { + /** + * member removed + */ + 204: void; +}; + +export type DeleteApiV2TeamMembersResponse = DeleteApiV2TeamMembersResponses[keyof DeleteApiV2TeamMembersResponses]; + +export type GetApiV2TeamMembersData = { + body?: never; + path?: never; + query?: { + /** + * Page to return, starting at 1. + */ + page?: number; + /** + * Number of items per page. Values above the maximum are capped to it. + */ + limit?: number; + teamId?: number; + }; + url: '/api/v2/team-members'; +}; + +export type GetApiV2TeamMembersErrors = { + /** + * team not accessible + */ + 403: unknown; +}; + +export type GetApiV2TeamMembersResponses = { + /** + * team members list + */ + 200: Pagination & { + results: Array; + }; +}; + +export type GetApiV2TeamMembersResponse = GetApiV2TeamMembersResponses[keyof GetApiV2TeamMembersResponses]; + +export type PostApiV2TeamMembersData = { + body?: { + teamId?: number; + email?: string; + }; + path?: never; + query?: never; + url: '/api/v2/team-members'; +}; + +export type PostApiV2TeamMembersErrors = { + /** + * not the team or organization owner + */ + 403: unknown; + /** + * email is already a member + */ + 422: unknown; +}; + +export type PostApiV2TeamMembersResponses = { + /** + * member invited + */ + 201: TeamMember; +}; + +export type PostApiV2TeamMembersResponse = PostApiV2TeamMembersResponses[keyof PostApiV2TeamMembersResponses]; + +export type GetApiV2TeamsData = { + body?: never; + path?: never; + query?: { + /** + * Page to return, starting at 1. + */ + page?: number; + /** + * Number of items per page. Values above the maximum are capped to it. + */ + limit?: number; + }; + url: '/api/v2/teams'; +}; + +export type GetApiV2TeamsErrors = { + /** + * Missing authorization header + */ + 401: Error; +}; + +export type GetApiV2TeamsError = GetApiV2TeamsErrors[keyof GetApiV2TeamsErrors]; + +export type GetApiV2TeamsResponses = { + /** + * teams list + */ + 200: Pagination & { + results: Array; + }; +}; + +export type GetApiV2TeamsResponse = GetApiV2TeamsResponses[keyof GetApiV2TeamsResponses]; + +export type PostApiV2TeamsData = { + body?: { + team: { + name?: string; + organizationId: number; + }; + }; + path?: never; + query?: never; + url: '/api/v2/teams'; +}; + +export type PostApiV2TeamsErrors = { + /** + * Forbidden + */ + 403: Error; +}; + +export type PostApiV2TeamsError = PostApiV2TeamsErrors[keyof PostApiV2TeamsErrors]; + +export type PostApiV2TeamsResponses = { + /** + * team created + */ + 201: Team; +}; + +export type PostApiV2TeamsResponse = PostApiV2TeamsResponses[keyof PostApiV2TeamsResponses]; + +export type DeleteApiV2TeamsByIdData = { + body?: never; + path: { + id: number; + }; + query?: never; + url: '/api/v2/teams/{id}'; +}; + +export type DeleteApiV2TeamsByIdErrors = { + /** + * Team not found + */ + 404: Error; + /** + * Team deletion has project + */ + 422: Error; +}; + +export type DeleteApiV2TeamsByIdError = DeleteApiV2TeamsByIdErrors[keyof DeleteApiV2TeamsByIdErrors]; + +export type DeleteApiV2TeamsByIdResponses = { + /** + * team deleted + */ + 204: void; +}; + +export type DeleteApiV2TeamsByIdResponse = DeleteApiV2TeamsByIdResponses[keyof DeleteApiV2TeamsByIdResponses]; + +export type GetApiV2TeamsByIdData = { + body?: never; + path: { + id: number; + }; + query?: never; + url: '/api/v2/teams/{id}'; +}; + +export type GetApiV2TeamsByIdErrors = { + /** + * Team not found + */ + 404: Error; +}; + +export type GetApiV2TeamsByIdError = GetApiV2TeamsByIdErrors[keyof GetApiV2TeamsByIdErrors]; + +export type GetApiV2TeamsByIdResponses = { + /** + * team found + */ + 200: Team; +}; + +export type GetApiV2TeamsByIdResponse = GetApiV2TeamsByIdResponses[keyof GetApiV2TeamsByIdResponses]; + +export type PatchApiV2TeamsByIdData = { + body?: { + name?: string; + /** + * New owner's uuid, must already be a team member + */ + ownerId?: string; + }; + path: { + id: number; + }; + query?: never; + url: '/api/v2/teams/{id}'; +}; + +export type PatchApiV2TeamsByIdErrors = { + /** + * Team not found + */ + 404: Error; + /** + * Team user is not member + */ + 422: Error; +}; + +export type PatchApiV2TeamsByIdError = PatchApiV2TeamsByIdErrors[keyof PatchApiV2TeamsByIdErrors]; + +export type PatchApiV2TeamsByIdResponses = { + /** + * team updated + */ + 200: Team; +}; + +export type PatchApiV2TeamsByIdResponse = PatchApiV2TeamsByIdResponses[keyof PatchApiV2TeamsByIdResponses]; diff --git a/src/lib/clients/meilisearch.ts b/src/lib/clients/meilisearch.ts index 29d08c4..c9f127e 100644 --- a/src/lib/clients/meilisearch.ts +++ b/src/lib/clients/meilisearch.ts @@ -1,19 +1,22 @@ -/** Builds the meilisearch-js client from a resolved project connection. */ +/** + * Builds the meilisearch-js client from a resolved project connection. + * The only place, with the Management API client, that touches HTTP. + */ import { type DocumentsQuery, Meilisearch } from "meilisearch"; import type { ListPage } from "../pagination.js"; export interface MeilisearchClientOptions { url: string; apiKey: string | undefined; - /** CLI version, sent in the X-Meilisearch-Client header. */ - version: string; + /** `meili-cli (v1.2.3)`, sent in the X-Meilisearch-Client header. */ + userAgent: string; } export function createMeilisearchClient(options: MeilisearchClientOptions): Meilisearch { return new Meilisearch({ host: options.url, apiKey: options.apiKey, - clientAgents: [`meili-cli (v${options.version})`], + clientAgents: [options.userAgent], }); } diff --git a/src/lib/clients/trace.ts b/src/lib/clients/trace.ts index 65928dc..a464652 100644 --- a/src/lib/clients/trace.ts +++ b/src/lib/clients/trace.ts @@ -8,8 +8,6 @@ import { redactBody, redactHeaders } from "../config.js"; type Write = (line: string) => void; -let traceInstalled = false; - /** * Wraps the global fetch for the rest of the process and writes a redacted * trace of every request and response on stderr. @@ -19,11 +17,9 @@ let traceInstalled = false; * behavior of the CLI in debug mode. Wrapping fetch keeps the SDK untouched. */ export function installHttpTrace(write: Write = (line) => process.stderr.write(line)): void { - if (traceInstalled) return; - traceInstalled = true; const original = globalThis.fetch; globalThis.fetch = async (input, init) => { - traceRequest(write, input, init); + await traceRequest(write, input, init); const started = performance.now(); let response: Response; try { @@ -40,19 +36,21 @@ export function installHttpTrace(write: Write = (line) => process.stderr.write(l }; } -function traceRequest( +/** + * meilisearch-js calls `fetch(url, init)`; the generated Management API client + * builds a `Request` and calls `fetch(request)`. Both shapes are traced. + */ +async function traceRequest( write: Write, input: Parameters[0], init: RequestInit | undefined, -): void { +): Promise { + const request = input instanceof Request ? input : undefined; const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - const method = init?.method ?? (input instanceof Request ? input.method : "GET"); + const method = init?.method ?? request?.method ?? "GET"; write(`> ${method.toUpperCase()} ${url}\n`); - const headers = new Headers( - init?.headers ?? (input instanceof Request ? input.headers : undefined), - ); - writeHeaders(write, "> ", headers); - const body = init?.body; + writeHeaders(write, "> ", new Headers(init?.headers ?? request?.headers)); + const body = init?.body ?? (await request?.clone().text()); if (typeof body === "string") writeBody(write, "> ", body); else if (body !== undefined && body !== null) write("> [body not shown: not text]\n"); } diff --git a/src/lib/config.ts b/src/lib/config.ts index 1d62cf0..e4bed34 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -9,7 +9,7 @@ import { randomBytes } from "node:crypto"; import { mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; -import { MeiliCliError } from "./errors.js"; +import { isRecord, MeiliCliError } from "./errors.js"; export type Env = Readonly>; @@ -52,10 +52,15 @@ export const ENV_VARS: ReadonlyArray<{ name: string; description: string; flag?: description: "Config file path. Default ~/.config/meili/config.json. Give each agent its own file, read-only.", }, - { name: ENV.MANAGEMENT_TOKEN, description: "Management API personal access token." }, + { + name: ENV.MANAGEMENT_TOKEN, + description: "Management API personal access token.", + flag: "management-token", + }, { name: ENV.MANAGEMENT_URL, description: `Management API base URL. Default ${DEFAULT_MANAGEMENT_URL}.`, + flag: "management-url", }, { name: ENV.MANAGEMENT_CLIENT_ID, @@ -75,6 +80,8 @@ export const ENV_VARS: ReadonlyArray<{ name: string; description: string; flag?: export interface ProjectEntry { url: string; apiKey?: string; + /** The project's `id` in the Management API, for the management commands. */ + cloudId?: number; } export interface ConfigFile { @@ -111,7 +118,7 @@ function parseProjects(parsed: unknown, path: string): Record new MeiliCliError( "cli_config", - `The config file ${path} must be {"projects": {"": {"url": "...", "apiKey": "..."}}}.`, + `The config file ${path} must be {"projects": {"": {"url": "...", "apiKey": "...", "cloudId": 1}}}, the key and the id optional.`, ); const projects = isRecord(parsed) ? parsed.projects : undefined; if (!isRecord(projects)) throw shape(); @@ -119,8 +126,12 @@ function parseProjects(parsed: unknown, path: string): Record ConfigFile, +): number { + if (flags.project !== undefined && flags.id !== undefined) { + throw new MeiliCliError( + "cli_usage", + "`--project` cannot be combined with `--id`: the name and the id contradict each other.", + ); + } + if (flags.project === undefined) { + if (flags.id === undefined) { + throw new MeiliCliError( + "cli_usage", + "Name the project with `--id ` or `--project `.", + ); + } + return flags.id; + } + const file = readFile(); + const { cloudId } = projectOf(file, flags.project); + if (cloudId === undefined) { + throw new MeiliCliError( + "cli_config", + `Project \`${flags.project}\` has no \`cloudId\` in ${file.path}: the management commands need the project's id.`, + { + hint: `Run \`meili config sync-with-cloud\`, or \`meili config update --project ${flags.project} --project-cloud-id \`.`, + }, + ); + } + return cloudId; +} + +/** `cli_config` when the file already has an entry named `name`; `detail` follows the path. */ +export function assertNoProject( + file: ConfigFile, + name: string, + detail: string, + hint: string, +): void { + if (file.projects[name] !== undefined) { + throw new MeiliCliError( + "cli_config", + `Project \`${name}\` already exists in ${file.path}${detail}`, + { hint }, + ); + } +} + +/** In the help of every command that writes the config file (config.md, "Config file"). */ +export const CONFIG_FILE_WARNING = + "The file holds API keys and decides where every later command goes, for every process that reads it: an agent runs this only when the human asked for it explicitly."; + /** The named entry, or `cli_config` with the path and the names the file has. */ export function projectOf(file: ConfigFile, name: string): ProjectEntry { const entry = file.projects[name]; @@ -250,10 +319,14 @@ export function maskSecret(value: string): string { return value.length > 4 ? `****${value.slice(-4)}` : "****"; } -/** Body fields whose value is a secret, as listed in config.md. `apikey` is the Management API's spelling. */ +/** + * Body fields whose value is a secret, as listed in config.md. `apikey` is the + * Management API's spelling, `masterKey` a Cloud project's key in the same API. + */ export const REDACTED_FIELDS: ReadonlySet = new Set([ "apiKey", "apikey", + "masterKey", "key", "token", "access_token", @@ -302,7 +375,3 @@ export function redactBody(value: unknown): unknown { export function nonEmpty(value: string | undefined): string | undefined { return value === undefined || value === "" ? undefined : value; } - -export function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 8107e2a..526cb97 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -41,38 +41,8 @@ export const CLI_ERROR_CODES = { export type CliErrorCode = keyof typeof CLI_ERROR_CODES; -/** - * Codes the CLI builds for Management API errors, which have no error body in - * the OpenAPI spec. Keyed by code, valued by exit code. - */ -export const MANAGEMENT_ERROR_CODES = { - management_unauthorized: EXIT_CODES.auth, - management_forbidden: EXIT_CODES.auth, - management_not_found: EXIT_CODES.notFound, - management_unprocessable: EXIT_CODES.error, - management_error: EXIT_CODES.error, -} as const; - -export type ManagementErrorCode = keyof typeof MANAGEMENT_ERROR_CODES; - -/** Maps an HTTP status of the Management API to its CLI error code. */ -export function managementErrorCode(status: number): ManagementErrorCode { - switch (status) { - case 401: - return "management_unauthorized"; - case 403: - return "management_forbidden"; - case 404: - return "management_not_found"; - case 422: - return "management_unprocessable"; - default: - return "management_error"; - } -} - -/** Exit code of an Meilisearch API error, from its HTTP status. */ -export function meilisearchExitCode(status: number): ExitCode { +/** Exit code of an API error, from its HTTP status. Both APIs answer the same shape. */ +export function apiExitCode(status: number): ExitCode { if (status === 401 || status === 403) return EXIT_CODES.auth; if (status === 404) return EXIT_CODES.notFound; return EXIT_CODES.error; @@ -95,6 +65,36 @@ export interface MeiliCliErrorOptions { cause?: unknown; } +/** + * A non-2xx answer of the Management API. Its body is the error object of the + * document, the same shape as a Meilisearch one, and it is passed through + * (docs/contract/output.md, "Errors"); `body` is undefined when the answer + * carried none, which is not the API answering. + */ +export class ManagementApiError extends Error { + readonly status: number; + readonly body: unknown; + readonly url: string; + + constructor(status: number, body: unknown, url: string) { + super(parseErrorBody(body)?.message ?? `The Management API answered HTTP ${status} to ${url}.`); + this.name = "ManagementApiError"; + this.status = status; + this.body = body; + this.url = url; + } +} + +/** The error object of either API, or undefined when the body is not one. */ +function parseErrorBody(body: unknown): ErrorBody | undefined { + if (!isRecord(body)) return undefined; + const { message, code, type, link } = body; + if (typeof message !== "string" || typeof code !== "string" || typeof type !== "string") { + return undefined; + } + return { code, type, message, ...(typeof link === "string" ? { link } : {}) }; +} + /** The only error class commands may throw. */ export class MeiliCliError extends Error { readonly code: CliErrorCode; @@ -127,39 +127,55 @@ export interface RenderedError { const HELP_SUFFIX = /\n?See more help with --help\.?\s*$/; /** - * Converts anything thrown during a command into a contract error. `commandIds` - * are the commands of the manifest, `get indexes`, for the hint of an unknown command. + * Both APIs answer the same error object, so both are rendered the same way: + * the body passed through, a hint on a credential error, and the exit code of + * the status. No error body at all is a proxy or a wrong URL, not the API. */ -export function toRenderedError(error: unknown, commandIds: readonly string[] = []): RenderedError { - if (error instanceof MeiliCliError) { - return { body: error.toBody(), exitCode: error.exitCode, stack: error.stack }; - } - - if (error instanceof MeilisearchApiError) { - const status = error.response.status; - if (error.cause !== undefined) { - const body: ErrorBody = { - code: error.cause.code, - type: error.cause.type, - message: error.cause.message, - link: error.cause.link, - }; - if (status === 401 || status === 403) { - body.hint = "Provide an API key with `--project-api-key ` or `MEILI_PROJECT_API_KEY`."; - } - return { body, exitCode: meilisearchExitCode(status), stack: error.stack }; - } - // An HTTP error without a Meilisearch error body: a proxy or a wrong URL answered. +function renderApiError( + error: Error, + status: number, + url: string, + sent: unknown, + api: { name: string; hint: string }, +): RenderedError { + const body = parseErrorBody(sent); + if (body === undefined) { return { body: { code: "cli_network", type: "cli", - message: `${serverName(error.response.url)} answered HTTP ${status} without a Meilisearch error body.`, + message: `${serverName(url)} answered HTTP ${status} without a ${api.name} error body.`, }, exitCode: EXIT_CODES.network, stack: error.stack, }; } + if (status === 401 || status === 403) body.hint = api.hint; + return { body, exitCode: apiExitCode(status), stack: error.stack }; +} + +/** + * Converts anything thrown during a command into a contract error. `commandIds` + * are the commands of the manifest, `get indexes`, for the hint of an unknown command. + */ +export function toRenderedError(error: unknown, commandIds: readonly string[] = []): RenderedError { + if (error instanceof MeiliCliError) { + return { body: error.toBody(), exitCode: error.exitCode, stack: error.stack }; + } + + if (error instanceof MeilisearchApiError) { + return renderApiError(error, error.response.status, error.response.url, error.cause, { + name: "Meilisearch", + hint: "Provide an API key with `--project-api-key ` or `MEILI_PROJECT_API_KEY`.", + }); + } + + if (error instanceof ManagementApiError) { + return renderApiError(error, error.status, error.url, error.body, { + name: "Management API", + hint: "Provide a personal access token with `--management-token ` or `MEILI_MANAGEMENT_TOKEN`.", + }); + } if (error instanceof MeilisearchRequestTimeOutError) { return { @@ -261,9 +277,14 @@ function serverName(url: string): string { } function networkMessage(error: MeilisearchRequestError): string { - const reason = deepestCauseMessage(error.cause); const target = error.message.match(/https?:\/\/\S+/)?.[0]; const where = target === undefined ? "the Meilisearch instance" : target.replace(/\/$/, ""); + return cannotConnectMessage(where, error.cause); +} + +/** The `cli_network` message: `Cannot connect to `, with the deepest cause when there is one. */ +export function cannotConnectMessage(where: string, cause: unknown): string { + const reason = deepestCauseMessage(cause); return reason === undefined ? `Cannot connect to ${where}.` : `Cannot connect to ${where}: ${reason}.`; @@ -339,3 +360,8 @@ export function handleFatalError( } process.exitCode = rendered.exitCode; } + +/** A JSON object, as opposed to an array, a scalar or null. */ +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/lib/pagination.ts b/src/lib/pagination.ts index bdf5965..b1b80c8 100644 --- a/src/lib/pagination.ts +++ b/src/lib/pagination.ts @@ -3,7 +3,7 @@ * See docs/contract/output.md, section Pagination. */ -/** What every list route of the Meilisearch API returns: `results` plus pagination fields. */ +/** What every list route of either API returns: `results` plus pagination fields. */ export type ListPage = { results: readonly unknown[] } & Record; export interface OffsetQuery { diff --git a/src/lib/session.ts b/src/lib/session.ts index ce24230..482efcd 100644 --- a/src/lib/session.ts +++ b/src/lib/session.ts @@ -15,12 +15,11 @@ import { DEFAULT_MANAGEMENT_URL, ENV, type Env, - isRecord, nonEmpty, readOwnerOnly, writeSecretFile, } from "./config.js"; -import { MeiliCliError } from "./errors.js"; +import { isRecord, MeiliCliError } from "./errors.js"; export interface ManagementCredentials { token: string; @@ -156,19 +155,24 @@ export function removeCredentialsFile(path: string): void { /** A token with less than this left is refreshed before the command runs. */ const EXPIRY_MARGIN_MS = 30_000; +export interface ManagementFlags { + token?: string | undefined; + url?: string | undefined; +} + /** - * `MEILI_MANAGEMENT_TOKEN`, then the session of `auth login`, with - * `MEILI_MANAGEMENT_URL` and its default. Fails before any network call when - * neither exists. An access token that is about to expire is refreshed here, - * and the new one is written back. The `--management-token` and - * `--management-url` flags arrive with the first management command. + * `--management-token`, then `MEILI_MANAGEMENT_TOKEN`, then the session of + * `auth login`, with `--management-url`, `MEILI_MANAGEMENT_URL` and its + * default. Fails before any network call when none exists. An access token + * that is about to expire is refreshed here, and the new one is written back. */ export async function resolveManagementCredentials( + flags: ManagementFlags, env: Env, warn: (warning: MeiliCliError) => void, ): Promise { - const chosen = nonEmpty(env[ENV.MANAGEMENT_URL]); - const token = nonEmpty(env[ENV.MANAGEMENT_TOKEN]); + const chosen = nonEmpty(flags.url) ?? nonEmpty(env[ENV.MANAGEMENT_URL]); + const token = nonEmpty(flags.token) ?? nonEmpty(env[ENV.MANAGEMENT_TOKEN]); if (token !== undefined) return { token, url: chosen ?? DEFAULT_MANAGEMENT_URL }; const path = credentialsFilePath(env); @@ -176,15 +180,17 @@ export async function resolveManagementCredentials( if (session === undefined) { throw new MeiliCliError( "cli_auth_missing", - "No Management API credentials. Run `meili auth login`, or set the `MEILI_MANAGEMENT_TOKEN` environment variable.", + "No Management API credentials. Run `meili auth login`, pass `--management-token`, or set the `MEILI_MANAGEMENT_TOKEN` environment variable.", { hint: "Run `meili auth login` to log in with a browser." }, ); } if (chosen !== undefined && chosen !== session.managementUrl) { throw new MeiliCliError( "cli_config", - `The session of \`meili auth login\` was issued by ${session.managementUrl}, but \`${ENV.MANAGEMENT_URL}\` points at ${chosen}. A token is never sent to another Cloud.`, - { hint: "Run `meili auth login` against that URL, or unset the variable." }, + `The session of \`meili auth login\` was issued by ${session.managementUrl}, but the Management API URL given is ${chosen}. A token is never sent to another Cloud.`, + { + hint: "Run `meili auth login` against that URL, or drop `--management-url` and `MEILI_MANAGEMENT_URL`.", + }, ); } const fresh = await ensureFreshSession(path, session); diff --git a/test/commands/add/__snapshots__/projects.test.ts.snap b/test/commands/add/__snapshots__/projects.test.ts.snap new file mode 100644 index 0000000..ff39faa --- /dev/null +++ b/test/commands/add/__snapshots__/projects.test.ts.snap @@ -0,0 +1,69 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`meili add projects > passes the API's error body through on a 403, with exit 3 1`] = ` +{ + "exitCode": 3, + "stderr": "{"error":{"code":"project_not_accessible","type":"auth","message":"You cannot access this project.","link":"https://www.meilisearch.com/docs/reference/errors#project_not_accessible","hint":"Provide a personal access token with \`--management-token \` or \`MEILI_MANAGEMENT_TOKEN\`."}} +", + "stdout": "", +} +`; + +exports[`meili add projects > prints its help without any network call 1`] = ` +"Create a Meilisearch Cloud project in a team, on a resource. + +USAGE + $ meili add projects [-o ] [--debug] [--management-token ] + [--management-url ] [--name ] [--team-id ] [--resource-id + ] [--ms-version ] [--add-to-config] [--file | --data + ] + +FLAGS + -o, --output= json (default): one document, the API body as it + is, indented in a terminal, compact when piped. + ndjson: one item per line, list commands only, + streams with --all. + --add-to-config Add the project to the config file, as an entry + named after it with its URL, its master key and + its cloudId. + --data= Inline JSON body. + --debug Print every HTTP request and response on stderr, + secrets redacted. + --file= Read the body from a file. \`-\` reads from stdin. + --management-token= Management API personal access token. + --management-url= Management API base URL. Default + https://api.meilisearch.com. + --ms-version= Meilisearch version of the project, the latest + by default. + --name= Name of the project. + --resource-id= Resource of the project: its plan and its + region. + --team-id= Team that owns the project. + +DESCRIPTION + Create a Meilisearch Cloud project in a team, on a resource. + +EXAMPLES + $ meili add projects --name demo --team-id 12 --resource-id 3 + + $ meili add projects --name demo --team-id 12 --resource-id 3 --output json + + $ meili add projects --name demo --team-id 12 --resource-id 3 --add-to-config + + $ meili add projects --name demo --team-id 12 --resource-id 3 --ms-version v1.54.0 + + $ meili add projects --data '{"name":"demo","teamId":12,"resourceId":3}' + + $ meili add projects --file project.json --name other-name + +" +`; + +exports[`meili add projects > sends the flags as the project body with the token, and prints the project created 1`] = ` +{ + "exitCode": 0, + "stderr": "", + "stdout": "{"id":343,"name":"demo","projectUid":"ms-abc","url":"https://ms-abc.sfo.meilisearch.io","masterKey":"projectmasterkey9z8y","teamId":12,"resourceId":3,"msVersion":"v1.54.0","workloadType":"production","readOnly":false,"status":"running","paused":false,"createdAt":"2026-10-14T10:00:00Z","resource":{"id":3,"name":"build","plan":"build","regionId":1}} +", +} +`; diff --git a/test/commands/add/projects.test.ts b/test/commands/add/projects.test.ts new file mode 100644 index 0000000..612e719 --- /dev/null +++ b/test/commands/add/projects.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from "vitest"; +import AddProjects from "../../../src/commands/add/projects.js"; +import { + managementEnv as env, + forbidden, + MANAGEMENT_TOKEN, + MANAGEMENT_URL, + managementHandlers, + type ReceivedRequest, +} from "../../helpers/management-handlers.js"; +import { server, useMockServer } from "../../helpers/msw.js"; +import { readJson, runCli, runCommand, tempConfig } from "../../helpers/run-command.js"; + +useMockServer(); + +describe("meili add projects", () => { + it("prints its help without any network call", async () => { + const result = await runCli(["add", "projects", "--help"]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toMatchSnapshot(); + }); + + it("sends the flags as the project body with the token, and prints the project created", async () => { + const received: ReceivedRequest[] = []; + server.use(managementHandlers.createProject(received)); + const result = await runCommand( + AddProjects, + ["--name", "demo", "--team-id", "12", "--resource-id", "3", "--output", "json"], + { env }, + ); + expect(result).toMatchSnapshot(); + expect(received).toMatchObject([ + { + authorization: `Bearer ${MANAGEMENT_TOKEN}`, + body: { name: "demo", teamId: 12, resourceId: 3 }, + }, + ]); + }); + + it("adds the project to the config file with --add-to-config: its URL, its master key and its cloudId", async () => { + server.use(managementHandlers.createProject()); + const { path, env: configEnv } = tempConfig("add-to-config.json", { + local: { url: "http://localhost:7700" }, + }); + const result = await runCommand( + AddProjects, + ["--name", "demo", "--team-id", "12", "--resource-id", "3", "--add-to-config"], + { env: { ...env, ...configEnv } }, + ); + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout).id).toBe(343); + expect(readJson(path)).toEqual({ + projects: { + local: { url: "http://localhost:7700" }, + demo: { + url: "https://ms-abc.sfo.meilisearch.io", + apiKey: "projectmasterkey9z8y", + cloudId: 343, + }, + }, + }); + }); + + it("refuses --add-to-config for a name the file has, before any network call", async () => { + const { path, env: configEnv } = tempConfig("taken.json", { + demo: { url: "http://elsewhere" }, + }); + const result = await runCommand( + AddProjects, + ["--name", "demo", "--team-id", "12", "--resource-id", "3", "--add-to-config"], + { env: { ...env, ...configEnv } }, + ); + expect(result.exitCode).toBe(2); + expect(JSON.parse(result.stderr).error).toMatchObject({ + code: "cli_config", + message: `Project \`demo\` already exists in ${path}: nothing was created.`, + }); + }); + + it("offers no --project: the route creates a project rather than reaching one", async () => { + const result = await runCommand(AddProjects, ["--name", "demo", "--project", "prod"], { + env, + }); + expect(result.exitCode).toBe(2); + expect(JSON.parse(result.stderr).error).toMatchObject({ + code: "cli_usage", + type: "cli", + message: "Nonexistent flag: --project", + }); + }); + + it("takes the body from --data, a flag overriding its field", async () => { + const received: ReceivedRequest[] = []; + server.use(managementHandlers.createProject(received)); + const result = await runCommand( + AddProjects, + ["--data", '{"name":"from-data","teamId":12,"resourceId":3}', "--name", "demo"], + { env }, + ); + expect(result.exitCode).toBe(0); + expect(received[0]?.body).toEqual({ name: "demo", teamId: 12, resourceId: 3 }); + }); + + it("takes the token from --management-token over MEILI_MANAGEMENT_TOKEN", async () => { + const received: ReceivedRequest[] = []; + server.use(managementHandlers.createProject(received)); + await runCommand(AddProjects, ["--name", "demo", "--management-token", "flagToken"], { + env, + }); + expect(received[0]?.authorization).toBe("Bearer flagToken"); + }); + + it("passes the API's error body through on a 403, with exit 3", async () => { + server.use(managementHandlers.apiError("/api/v2/projects", 403, forbidden)); + const result = await runCommand(AddProjects, ["--name", "demo", "--output", "json"], { + env, + }); + expect(result).toMatchSnapshot(); + }); + + it("fails with cli_auth_missing and exit 3 without a token, before any network call", async () => { + const result = await runCommand(AddProjects, ["--name", "demo", "--output", "json"], { + env: { MEILI_MANAGEMENT_URL: MANAGEMENT_URL }, + }); + expect(result.exitCode).toBe(3); + expect(JSON.parse(result.stderr).error.code).toBe("cli_auth_missing"); + expect(result.stderr).not.toContain("MEILI_PROJECT"); + }); + + it("reports a connection failure with cli_network and exit 6", async () => { + server.use(managementHandlers.networkError("/api/v2/projects")); + const result = await runCommand(AddProjects, ["--name", "demo"], { env }); + expect(result.exitCode).toBe(6); + expect(JSON.parse(result.stderr).error.code).toBe("cli_network"); + expect(JSON.parse(result.stderr).error.message).toContain(MANAGEMENT_URL); + }); +}); diff --git a/test/commands/config/__snapshots__/set.test.ts.snap b/test/commands/config/__snapshots__/set.test.ts.snap index f53a6f9..972af0e 100644 --- a/test/commands/config/__snapshots__/set.test.ts.snap +++ b/test/commands/config/__snapshots__/set.test.ts.snap @@ -5,7 +5,7 @@ exports[`meili config set > prints its help without any network call 1`] = ` USAGE $ meili config set --project --project-url [-o ] - [--debug] [--project-api-key ] + [--debug] [--project-api-key ] [--project-cloud-id ] FLAGS -o, --output= json (default): one document, the API body as it @@ -17,6 +17,8 @@ FLAGS --project= (required) Name of the project to set. --project-api-key= API key of the project. \`-\` reads it from stdin, out of the shell history. + --project-cloud-id= Id of the project in Meilisearch Cloud, for the + management commands. --project-url= (required) URL of the project. DESCRIPTION @@ -32,6 +34,8 @@ EXAMPLES $ meili config set --project prod --project-url https://ms-abc.sfo.meilisearch.io --project-api-key - < key.txt + $ meili config set --project prod --project-url https://ms-abc.sfo.meilisearch.io --project-cloud-id 343 + $ meili config set --project local --project-url http://localhost:7700 --output json " diff --git a/test/commands/config/__snapshots__/sync-with-cloud.test.ts.snap b/test/commands/config/__snapshots__/sync-with-cloud.test.ts.snap new file mode 100644 index 0000000..714bf5f --- /dev/null +++ b/test/commands/config/__snapshots__/sync-with-cloud.test.ts.snap @@ -0,0 +1,40 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`meili config sync-with-cloud > prints its help without any network call 1`] = ` +"Set the cloudId of the config file entries whose URL is a Meilisearch Cloud project the token sees. Never writes a key, never adds or removes an entry. Prints what changed. + +USAGE + $ meili config sync-with-cloud [-o ] [--debug] [--management-token ] + [--management-url ] [--team-id ] + +FLAGS + -o, --output= json (default): one document, the API body as it + is, indented in a terminal, compact when piped. + ndjson: one item per line, list commands only, + streams with --all. + --debug Print every HTTP request and response on stderr, + secrets redacted. + --management-token= Management API personal access token. + --management-url= Management API base URL. Default + https://api.meilisearch.com. + --team-id= Only the projects of this team; missing entries + are then not reported. + +DESCRIPTION + Set the cloudId of the config file entries whose URL is a Meilisearch Cloud + project the token sees. Never writes a key, never adds or removes an entry. + Prints what changed. + + The file holds API keys and decides where every later command goes, for every + process that reads it: an agent runs this only when the human asked for it + explicitly. + +EXAMPLES + $ meili config sync-with-cloud + + $ meili config sync-with-cloud --team-id 12 + + $ meili config sync-with-cloud --output json + +" +`; diff --git a/test/commands/config/set.test.ts b/test/commands/config/set.test.ts index a2f9754..97c6e30 100644 --- a/test/commands/config/set.test.ts +++ b/test/commands/config/set.test.ts @@ -1,16 +1,13 @@ -import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; import ConfigSet from "../../../src/commands/config/set.js"; import { useMockServer } from "../../helpers/msw.js"; -import { runCli, runCommand, scrubbedHome } from "../../helpers/run-command.js"; +import { readJson as read, runCli, runCommand, scrubbedHome } from "../../helpers/run-command.js"; vi.mock("../../../src/lib/input.js", () => ({ readStdin: () => "key-from-stdin" })); useMockServer(); -const read = (path: string) => JSON.parse(readFileSync(path, "utf8")) as unknown; - describe("meili config set", () => { it("prints its help without any network call", async () => { const result = await runCli(["config", "set", "--help"]); @@ -30,6 +27,17 @@ describe("meili config set", () => { expect(read(path)).toEqual({ projects: { local: { url: "http://localhost:7700" } } }); }); + it("stores the Cloud id given with --project-cloud-id", async () => { + const path = join(scrubbedHome, "cloud.json"); + const result = await runCommand( + ConfigSet, + ["--project", "prod", "--project-url", "https://p", "--project-cloud-id", "343"], + { env: { MEILI_CONFIG: path } }, + ); + expect(result.exitCode).toBe(0); + expect(read(path)).toEqual({ projects: { prod: { url: "https://p", cloudId: 343 } } }); + }); + it("reads the key from stdin with -", async () => { const path = join(scrubbedHome, "stdin.json"); const result = await runCommand( diff --git a/test/commands/config/sync-with-cloud.test.ts b/test/commands/config/sync-with-cloud.test.ts new file mode 100644 index 0000000..478c615 --- /dev/null +++ b/test/commands/config/sync-with-cloud.test.ts @@ -0,0 +1,100 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import ConfigSyncWithCloud from "../../../src/commands/config/sync-with-cloud.js"; +import { + MANAGEMENT_URL, + managementEnv, + managementHandlers, + project, + type ReceivedRequest, +} from "../../helpers/management-handlers.js"; +import { server, useMockServer } from "../../helpers/msw.js"; +import { readJson, runCli, runCommand, tempConfig } from "../../helpers/run-command.js"; + +useMockServer(); + +const staging = { ...project, id: 344, name: "staging", url: "https://ms-def.sfo.meilisearch.io" }; +const fresh = { ...project, id: 345, name: "fresh", url: "https://ms-ghi.sfo.meilisearch.io" }; + +describe("meili config sync-with-cloud", () => { + it("prints its help without any network call", async () => { + const result = await runCli(["config", "sync-with-cloud", "--help"]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toMatchSnapshot(); + }); + + it("sets cloudId by URL on the entries that have one, keeps the rest, never writes a key or an entry", async () => { + const { path, env } = tempConfig("sync.json", { + demo: { url: "https://ms-abc.sfo.meilisearch.io", cloudId: 343 }, + "my-staging": { url: "https://MS-DEF.sfo.meilisearch.io/", apiKey: "search" }, + local: { url: "http://localhost:7700" }, + gone: { url: "https://ms-old.sfo.meilisearch.io", cloudId: 1 }, + }); + server.use(managementHandlers.listProjects([project, staging, fresh])); + const result = await runCommand(ConfigSyncWithCloud, ["--output", "json"], { + env: { ...managementEnv, ...env }, + }); + expect(result).toEqual({ + stdout: '{"updated":["my-staging"],"unchanged":["demo"],"missing":["gone"]}\n', + stderr: "", + exitCode: 0, + }); + expect(readJson(path)).toEqual({ + projects: { + demo: { url: "https://ms-abc.sfo.meilisearch.io", cloudId: 343 }, + "my-staging": { url: "https://MS-DEF.sfo.meilisearch.io/", apiKey: "search", cloudId: 344 }, + local: { url: "http://localhost:7700" }, + gone: { url: "https://ms-old.sfo.meilisearch.io", cloudId: 1 }, + }, + }); + expect(readFileSync(path, "utf8")).not.toContain(project.masterKey); + }); + + it("reads every page, so a project the first page misses is still matched", async () => { + const second = { ...project, id: 344, url: "https://ms-def.sfo.meilisearch.io" }; + const { path, env } = tempConfig("sync-pages.json", { + demo: { url: "https://ms-abc.sfo.meilisearch.io" }, + late: { url: "https://ms-def.sfo.meilisearch.io" }, + }); + const received: ReceivedRequest[] = []; + server.use(managementHandlers.listProjects([project, second], received, 1)); + const result = await runCommand(ConfigSyncWithCloud, [], { + env: { ...managementEnv, ...env }, + }); + expect(result.exitCode).toBe(0); + expect(received).toHaveLength(2); + expect(JSON.parse(result.stdout)).toEqual({ + updated: ["demo", "late"], + unchanged: [], + missing: [], + }); + expect(readJson(path)).toEqual({ + projects: { + demo: { url: "https://ms-abc.sfo.meilisearch.io", cloudId: 343 }, + late: { url: "https://ms-def.sfo.meilisearch.io", cloudId: 344 }, + }, + }); + }); + + it("passes --team-id to the API and does not report missing entries then", async () => { + const { path, env } = tempConfig("sync-team.json", { + other: { url: "https://o", cloudId: 9 }, + demo: { url: "https://ms-abc.sfo.meilisearch.io" }, + }); + const received: ReceivedRequest[] = []; + server.use(managementHandlers.listProjects([project], received)); + const result = await runCommand(ConfigSyncWithCloud, ["--team-id", "12"], { + env: { ...managementEnv, ...env }, + }); + expect(result.exitCode).toBe(0); + expect(received[0]?.url).toBe(`${MANAGEMENT_URL}/api/v2/projects?teamId=12&page=1`); + expect(JSON.parse(result.stdout)).toEqual({ updated: ["demo"], unchanged: [], missing: [] }); + expect(readJson(path)).toEqual({ + projects: { + other: { url: "https://o", cloudId: 9 }, + demo: { url: "https://ms-abc.sfo.meilisearch.io", cloudId: 343 }, + }, + }); + }); +}); diff --git a/test/commands/config/unset.test.ts b/test/commands/config/unset.test.ts index 58fa2a2..bd79e4c 100644 --- a/test/commands/config/unset.test.ts +++ b/test/commands/config/unset.test.ts @@ -1,10 +1,9 @@ -import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import ConfigUnset from "../../../src/commands/config/unset.js"; import { writeConfigFile } from "../../../src/lib/config.js"; import { useMockServer } from "../../helpers/msw.js"; -import { runCli, runCommand, scrubbedHome } from "../../helpers/run-command.js"; +import { readJson, runCli, runCommand, scrubbedHome } from "../../helpers/run-command.js"; useMockServer(); @@ -23,7 +22,7 @@ describe("meili config unset", () => { env: { MEILI_CONFIG: path }, }); expect(result).toEqual({ stdout: "", stderr: "", exitCode: 0 }); - expect(JSON.parse(readFileSync(path, "utf8"))).toEqual({ + expect(readJson(path)).toEqual({ projects: { b: { url: "http://b" } }, }); }); diff --git a/test/commands/delete/__snapshots__/projects.test.ts.snap b/test/commands/delete/__snapshots__/projects.test.ts.snap new file mode 100644 index 0000000..f447951 --- /dev/null +++ b/test/commands/delete/__snapshots__/projects.test.ts.snap @@ -0,0 +1,47 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`meili delete projects > passes the API's error body through on a 403, with exit 3 1`] = ` +{ + "exitCode": 3, + "stderr": "{"error":{"code":"project_not_accessible","type":"auth","message":"You cannot access this project.","link":"https://www.meilisearch.com/docs/reference/errors#project_not_accessible","hint":"Provide a personal access token with \`--management-token \` or \`MEILI_MANAGEMENT_TOKEN\`."}} +", + "stdout": "", +} +`; + +exports[`meili delete projects > prints its help without any network call 1`] = ` +"Delete a Meilisearch Cloud project, with its data. Prints nothing on success. + +USAGE + $ meili delete projects --yes [-o ] [--debug] [--management-token + ] [--management-url ] [--project ] [--id ] + +FLAGS + -o, --output= json (default): one document, the API body as it + is, indented in a terminal, compact when piped. + ndjson: one item per line, list commands only, + streams with --all. + --debug Print every HTTP request and response on stderr, + secrets redacted. + --id= The project to delete, or give --project + and its cloudId from the config file. + --management-token= Management API personal access token. + --management-url= Management API base URL. Default + https://api.meilisearch.com. + --project= A project of the config file, whose cloudId + names the Cloud project, in place of --id. + --yes (required) Confirm the deletion. Nothing is sent + to the API without it. + +DESCRIPTION + Delete a Meilisearch Cloud project, with its data. Prints nothing on success. + +EXAMPLES + $ meili delete projects --id 343 --yes + + $ meili delete projects --id 343 --yes --output json + + $ meili delete projects --project staging --yes + +" +`; diff --git a/test/commands/delete/projects.test.ts b/test/commands/delete/projects.test.ts new file mode 100644 index 0000000..45759fd --- /dev/null +++ b/test/commands/delete/projects.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import DeleteProjects from "../../../src/commands/delete/projects.js"; +import { + managementEnv as env, + forbidden, + managementHandlers, +} from "../../helpers/management-handlers.js"; +import { server, useMockServer } from "../../helpers/msw.js"; +import { runCli, runCommand, tempConfig } from "../../helpers/run-command.js"; + +useMockServer(); + +describe("meili delete projects", () => { + it("prints its help without any network call", async () => { + const result = await runCli(["delete", "projects", "--help"]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toMatchSnapshot(); + }); + + it("deletes the project of --id and prints nothing", async () => { + server.use(managementHandlers.deleteProject()); + const result = await runCommand(DeleteProjects, ["--id", "343", "--yes", "--output", "json"], { + env, + }); + expect(result).toEqual({ stdout: "", stderr: "", exitCode: 0 }); + }); + + it("takes the project's cloudId from the config file with --project", async () => { + const { env: configEnv } = tempConfig("projects.json", { + staging: { url: "https://s", cloudId: 343 }, + }); + server.use(managementHandlers.deleteProject()); + const result = await runCommand(DeleteProjects, ["--project", "staging", "--yes"], { + env: { ...env, ...configEnv }, + }); + expect(result).toEqual({ stdout: "", stderr: "", exitCode: 0 }); + }); + + it("passes the API's error body through on a 403, with exit 3", async () => { + server.use(managementHandlers.apiError("/api/v2/projects/343", 403, forbidden)); + const result = await runCommand(DeleteProjects, ["--id", "343", "--yes", "--output", "json"], { + env, + }); + expect(result).toMatchSnapshot(); + }); + + it("calls a 403 without an error body a network error, not the API speaking", async () => { + server.use(managementHandlers.apiError("/api/v2/projects/343", 403)); + const result = await runCommand(DeleteProjects, ["--id", "343", "--yes"], { env }); + expect(result.exitCode).toBe(6); + expect(JSON.parse(result.stderr).error.code).toBe("cli_network"); + }); +}); diff --git a/test/commands/get/__snapshots__/projects.test.ts.snap b/test/commands/get/__snapshots__/projects.test.ts.snap new file mode 100644 index 0000000..670d868 --- /dev/null +++ b/test/commands/get/__snapshots__/projects.test.ts.snap @@ -0,0 +1,64 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`meili get projects > lists the projects in the envelope the API returns, with no paging flag yet 1`] = ` +{ + "exitCode": 0, + "stderr": "", + "stdout": "{"page":1,"limit":20,"total":2,"totalPages":1,"results":[{"id":343,"name":"demo","projectUid":"ms-abc","url":"https://ms-abc.sfo.meilisearch.io","masterKey":"projectmasterkey9z8y","teamId":12,"resourceId":3,"msVersion":"v1.54.0","workloadType":"production","readOnly":false,"status":"running","paused":false,"createdAt":"2026-10-14T10:00:00Z","resource":{"id":3,"name":"build","plan":"build","regionId":1}},{"id":344,"name":"staging","projectUid":"ms-abc","url":"https://ms-def.sfo.meilisearch.io","masterKey":"projectmasterkey9z8y","teamId":12,"resourceId":3,"msVersion":"v1.54.0","workloadType":"production","readOnly":false,"status":"running","paused":false,"createdAt":"2026-10-14T10:00:00Z","resource":{"id":3,"name":"build","plan":"build","regionId":1}}]} +", +} +`; + +exports[`meili get projects > passes the API's error body through on an unknown id, with exit 3 1`] = ` +{ + "exitCode": 3, + "stderr": "{"error":{"code":"project_not_accessible","type":"auth","message":"You cannot access this project.","link":"https://www.meilisearch.com/docs/reference/errors#project_not_accessible","hint":"Provide a personal access token with \`--management-token \` or \`MEILI_MANAGEMENT_TOKEN\`."}} +", + "stdout": "", +} +`; + +exports[`meili get projects > prints its help without any network call 1`] = ` +"List the Meilisearch Cloud projects the token sees, or get one with --id or --project. + +USAGE + $ meili get projects [-o ] [--debug] [--management-token ] + [--management-url ] [--project ] [--id ] [--team-id ] + +FLAGS + -o, --output= json (default): one document, the API body as it + is, indented in a terminal, compact when piped. + ndjson: one item per line, list commands only, + streams with --all. + --debug Print every HTTP request and response on stderr, + secrets redacted. + --id= The project to get, or give --project and + its cloudId from the config file. Without + either, the command lists the projects. + --management-token= Management API personal access token. + --management-url= Management API base URL. Default + https://api.meilisearch.com. + --project= A project of the config file, whose cloudId + names the Cloud project, in place of --id. + --team-id= Only the projects of this team. Ignored with + --id. + +DESCRIPTION + List the Meilisearch Cloud projects the token sees, or get one with --id or + --project. + +EXAMPLES + $ meili get projects + + $ meili get projects --output json + + $ meili get projects --team-id 12 + + $ meili get projects --id 343 + + $ meili get projects --project prod + + $ meili get projects --output ndjson + +" +`; diff --git a/test/commands/get/projects.test.ts b/test/commands/get/projects.test.ts new file mode 100644 index 0000000..5385f1b --- /dev/null +++ b/test/commands/get/projects.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import GetProjects from "../../../src/commands/get/projects.js"; +import { + managementEnv as env, + MANAGEMENT_URL, + managementHandlers, + project, + type ReceivedRequest, +} from "../../helpers/management-handlers.js"; +import { server, useMockServer } from "../../helpers/msw.js"; +import { runCli, runCommand, tempConfig } from "../../helpers/run-command.js"; + +useMockServer(); + +const staging = { ...project, id: 344, name: "staging", url: "https://ms-def.sfo.meilisearch.io" }; + +describe("meili get projects", () => { + it("prints its help without any network call", async () => { + const result = await runCli(["get", "projects", "--help"]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toMatchSnapshot(); + }); + + it("lists the projects in the envelope the API returns, with no paging flag yet", async () => { + server.use(managementHandlers.listProjects([project, staging])); + const result = await runCommand(GetProjects, ["--output", "json"], { env }); + expect(result).toMatchSnapshot(); + }); + + it("passes --team-id to the list route, and prints one project per line in ndjson", async () => { + const received: ReceivedRequest[] = []; + server.use(managementHandlers.listProjects([project, staging], received)); + const result = await runCommand(GetProjects, ["--team-id", "12", "--output", "ndjson"], { + env, + }); + expect(result.exitCode).toBe(0); + expect(received[0]?.url).toBe(`${MANAGEMENT_URL}/api/v2/projects?teamId=12`); + expect(result.stdout).toBe(`${JSON.stringify(project)}\n${JSON.stringify(staging)}\n`); + }); + + it("gets one project with --id, or with --project and its cloudId", async () => { + server.use(managementHandlers.getProject([project, staging])); + const byId = await runCommand(GetProjects, ["--id", "344", "--output", "json"], { env }); + expect(byId).toEqual({ stdout: `${JSON.stringify(staging)}\n`, stderr: "", exitCode: 0 }); + + const { env: configEnv } = tempConfig("projects.json", { + demo: { url: "https://ms-abc.sfo.meilisearch.io", cloudId: 343 }, + }); + const byName = await runCommand(GetProjects, ["--project", "demo", "--output", "json"], { + env: { ...env, ...configEnv }, + }); + expect(byName.stdout).toBe(`${JSON.stringify(project)}\n`); + }); + + it("passes the API's error body through on an unknown id, with exit 3", async () => { + server.use(managementHandlers.getProject([project])); + const result = await runCommand(GetProjects, ["--id", "999", "--output", "json"], { env }); + expect(result).toMatchSnapshot(); + }); +}); diff --git a/test/commands/schema.test.ts b/test/commands/schema.test.ts index 00b48f7..96afbae 100644 --- a/test/commands/schema.test.ts +++ b/test/commands/schema.test.ts @@ -52,7 +52,7 @@ describe("meili schema", () => { description: string; flags: Array<{ name: string }>; }; - // `config set` declares the three values it writes, with its own descriptions. + // `config set` declares the four values it writes, with its own descriptions. expect(command.flags).toEqual([ { name: "project", @@ -74,6 +74,12 @@ describe("meili schema", () => { description: "API key of the project. `-` reads it from stdin, out of the shell history.", helpValue: "", }, + { + name: "project-cloud-id", + type: "option", + description: "Id of the project in Meilisearch Cloud, for the management commands.", + helpValue: "", + }, ]); expect(command.description).toContain("\n\n"); }); diff --git a/test/commands/update/__snapshots__/projects.test.ts.snap b/test/commands/update/__snapshots__/projects.test.ts.snap new file mode 100644 index 0000000..b2c9eae --- /dev/null +++ b/test/commands/update/__snapshots__/projects.test.ts.snap @@ -0,0 +1,60 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`meili update projects > passes the API's error body through on a 403, with exit 3 1`] = ` +{ + "exitCode": 3, + "stderr": "{"error":{"code":"project_not_accessible","type":"auth","message":"You cannot access this project.","link":"https://www.meilisearch.com/docs/reference/errors#project_not_accessible","hint":"Provide a personal access token with \`--management-token \` or \`MEILI_MANAGEMENT_TOKEN\`."}} +", + "stdout": "", +} +`; + +exports[`meili update projects > prints its help without any network call 1`] = ` +"Rename a Meilisearch Cloud project. + +USAGE + $ meili update projects [-o ] [--debug] [--management-token ] + [--management-url ] [--project ] [--id ] [--name ] + [--file | --data ] + +FLAGS + -o, --output= json (default): one document, the API body as it + is, indented in a terminal, compact when piped. + ndjson: one item per line, list commands only, + streams with --all. + --data= Inline JSON body. + --debug Print every HTTP request and response on stderr, + secrets redacted. + --file= Read the body from a file. \`-\` reads from stdin. + --id= The project to update, or give --project + and its cloudId from the config file. + --management-token= Management API personal access token. + --management-url= Management API base URL. Default + https://api.meilisearch.com. + --name= New name of the project. + --project= A project of the config file, whose cloudId + names the Cloud project, in place of --id. + +DESCRIPTION + Rename a Meilisearch Cloud project. + +EXAMPLES + $ meili update projects --id 343 --name renamed + + $ meili update projects --id 343 --name renamed --output json + + $ meili update projects --project prod --name renamed + + $ meili update projects --id 343 --data '{"name":"renamed"}' + +" +`; + +exports[`meili update projects > sends the new name for the project of --id, and prints the project updated 1`] = ` +{ + "exitCode": 0, + "stderr": "", + "stdout": "{"id":343,"name":"renamed","projectUid":"ms-abc","url":"https://ms-abc.sfo.meilisearch.io","masterKey":"projectmasterkey9z8y","teamId":12,"resourceId":3,"msVersion":"v1.54.0","workloadType":"production","readOnly":false,"status":"running","paused":false,"createdAt":"2026-10-14T10:00:00Z","resource":{"id":3,"name":"build","plan":"build","regionId":1}} +", +} +`; diff --git a/test/commands/update/projects.test.ts b/test/commands/update/projects.test.ts new file mode 100644 index 0000000..bf6c1b9 --- /dev/null +++ b/test/commands/update/projects.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import UpdateProjects from "../../../src/commands/update/projects.js"; +import { + managementEnv as env, + forbidden, + MANAGEMENT_URL, + managementHandlers, + type ReceivedRequest, +} from "../../helpers/management-handlers.js"; +import { server, useMockServer } from "../../helpers/msw.js"; +import { runCli, runCommand, tempConfig } from "../../helpers/run-command.js"; + +useMockServer(); + +describe("meili update projects", () => { + it("prints its help without any network call", async () => { + const result = await runCli(["update", "projects", "--help"]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toMatchSnapshot(); + }); + + it("sends the new name for the project of --id, and prints the project updated", async () => { + const received: ReceivedRequest[] = []; + server.use(managementHandlers.updateProject(received)); + const result = await runCommand( + UpdateProjects, + ["--id", "343", "--name", "renamed", "--output", "json"], + { env }, + ); + expect(result).toMatchSnapshot(); + expect(received[0]?.body).toEqual({ name: "renamed" }); + }); + + it("takes the project's cloudId from the config file with --project, not with --id too", async () => { + const { env: configEnv } = tempConfig("projects.json", { + prod: { url: "https://ms-abc.sfo.meilisearch.io", cloudId: 343 }, + }); + const received: ReceivedRequest[] = []; + server.use(managementHandlers.updateProject(received)); + const named = await runCommand(UpdateProjects, ["--project", "prod", "--name", "renamed"], { + env: { ...env, ...configEnv }, + }); + expect(named.exitCode).toBe(0); + expect(received[0]?.url).toBe(`${MANAGEMENT_URL}/api/v2/projects/343`); + + const both = await runCommand( + UpdateProjects, + ["--project", "prod", "--id", "343", "--name", "x"], + { env: { ...env, ...configEnv } }, + ); + expect(both.exitCode).toBe(2); + expect(JSON.parse(both.stderr).error.code).toBe("cli_usage"); + }); + + it("passes the API's error body through on a 403, with exit 3", async () => { + server.use(managementHandlers.apiError("/api/v2/projects/343", 403, forbidden)); + const result = await runCommand( + UpdateProjects, + ["--id", "343", "--name", "x", "--output", "json"], + { + env, + }, + ); + expect(result).toMatchSnapshot(); + }); +}); diff --git a/test/contract/__snapshots__/commands.test.ts.snap b/test/contract/__snapshots__/commands.test.ts.snap index 81ff080..9b2034e 100644 --- a/test/contract/__snapshots__/commands.test.ts.snap +++ b/test/contract/__snapshots__/commands.test.ts.snap @@ -174,6 +174,80 @@ exports[`oclif.manifest.json > \`meili schema\` is a snapshot, the index and eve "POST /indexes", ], }, + { + "api": "management", + "description": "Create a Meilisearch Cloud project in a team, on a resource.", + "examples": [ + "meili add projects --name demo --team-id 12 --resource-id 3", + "meili add projects --name demo --team-id 12 --resource-id 3 --output json", + "meili add projects --name demo --team-id 12 --resource-id 3 --add-to-config", + "meili add projects --name demo --team-id 12 --resource-id 3 --ms-version v1.54.0", + "meili add projects --data '{"name":"demo","teamId":12,"resourceId":3}'", + "meili add projects --file project.json --name other-name", + ], + "flags": [ + { + "description": "Management API personal access token.", + "helpValue": "", + "name": "management-token", + "type": "option", + }, + { + "description": "Management API base URL. Default https://api.meilisearch.com.", + "helpValue": "", + "name": "management-url", + "type": "option", + }, + { + "description": "Name of the project.", + "helpValue": "", + "name": "name", + "type": "option", + }, + { + "description": "Team that owns the project.", + "helpValue": "", + "name": "team-id", + "type": "option", + }, + { + "description": "Resource of the project: its plan and its region.", + "helpValue": "", + "name": "resource-id", + "type": "option", + }, + { + "description": "Meilisearch version of the project, the latest by default.", + "helpValue": "", + "name": "ms-version", + "type": "option", + }, + { + "description": "Add the project to the config file, as an entry named after it with its URL, its master key and its cloudId.", + "name": "add-to-config", + "type": "boolean", + }, + { + "description": "Read the body from a file. \`-\` reads from stdin.", + "exclusive": [ + "data", + ], + "helpValue": "", + "name": "file", + "type": "option", + }, + { + "description": "Inline JSON body.", + "helpValue": "", + "name": "data", + "type": "option", + }, + ], + "id": "add projects", + "routes": [ + "POST /api/v2/projects", + ], + }, { "api": "none", "description": "Log in to Meilisearch Cloud with a browser, and store the token for the management commands.", @@ -382,6 +456,7 @@ The file holds API keys and decides where every later command goes, for every pr "examples": [ "meili config set --project local --project-url http://localhost:7700", "meili config set --project prod --project-url https://ms-abc.sfo.meilisearch.io --project-api-key - < key.txt", + "meili config set --project prod --project-url https://ms-abc.sfo.meilisearch.io --project-cloud-id 343", "meili config set --project local --project-url http://localhost:7700 --output json", ], "flags": [ @@ -405,9 +480,47 @@ The file holds API keys and decides where every later command goes, for every pr "name": "project-api-key", "type": "option", }, + { + "description": "Id of the project in Meilisearch Cloud, for the management commands.", + "helpValue": "", + "name": "project-cloud-id", + "type": "option", + }, ], "id": "config set", }, + { + "api": "management", + "description": "Set the cloudId of the config file entries whose URL is a Meilisearch Cloud project the token sees. Never writes a key, never adds or removes an entry. Prints what changed. + +The file holds API keys and decides where every later command goes, for every process that reads it: an agent runs this only when the human asked for it explicitly.", + "examples": [ + "meili config sync-with-cloud", + "meili config sync-with-cloud --team-id 12", + "meili config sync-with-cloud --output json", + ], + "flags": [ + { + "description": "Management API personal access token.", + "helpValue": "", + "name": "management-token", + "type": "option", + }, + { + "description": "Management API base URL. Default https://api.meilisearch.com.", + "helpValue": "", + "name": "management-url", + "type": "option", + }, + { + "description": "Only the projects of this team; missing entries are then not reported.", + "helpValue": "", + "name": "team-id", + "type": "option", + }, + ], + "id": "config sync-with-cloud", + }, { "api": "none", "description": "Remove a project from the config file. @@ -612,6 +725,51 @@ The file holds API keys and decides where every later command goes, for every pr "DELETE /indexes/{index_uid}", ], }, + { + "api": "management", + "description": "Delete a Meilisearch Cloud project, with its data. Prints nothing on success.", + "examples": [ + "meili delete projects --id 343 --yes", + "meili delete projects --id 343 --yes --output json", + "meili delete projects --project staging --yes", + ], + "flags": [ + { + "description": "Management API personal access token.", + "helpValue": "", + "name": "management-token", + "type": "option", + }, + { + "description": "Management API base URL. Default https://api.meilisearch.com.", + "helpValue": "", + "name": "management-url", + "type": "option", + }, + { + "description": "A project of the config file, whose cloudId names the Cloud project, in place of --id.", + "helpValue": "", + "name": "project", + "type": "option", + }, + { + "description": "The project to delete, or give --project and its cloudId from the config file.", + "helpValue": "", + "name": "id", + "type": "option", + }, + { + "description": "Confirm the deletion. Nothing is sent to the API without it.", + "name": "yes", + "required": true, + "type": "boolean", + }, + ], + "id": "delete projects", + "routes": [ + "DELETE /api/v2/projects/{id}", + ], + }, { "api": "meilisearch", "async": true, @@ -943,6 +1101,55 @@ The file holds API keys and decides where every later command goes, for every pr "GET /indexes/{index_uid}", ], }, + { + "api": "management", + "description": "List the Meilisearch Cloud projects the token sees, or get one with --id or --project.", + "examples": [ + "meili get projects", + "meili get projects --output json", + "meili get projects --team-id 12", + "meili get projects --id 343", + "meili get projects --project prod", + "meili get projects --output ndjson", + ], + "flags": [ + { + "description": "Management API personal access token.", + "helpValue": "", + "name": "management-token", + "type": "option", + }, + { + "description": "Management API base URL. Default https://api.meilisearch.com.", + "helpValue": "", + "name": "management-url", + "type": "option", + }, + { + "description": "A project of the config file, whose cloudId names the Cloud project, in place of --id.", + "helpValue": "", + "name": "project", + "type": "option", + }, + { + "description": "The project to get, or give --project and its cloudId from the config file. Without either, the command lists the projects.", + "helpValue": "", + "name": "id", + "type": "option", + }, + { + "description": "Only the projects of this team. Ignored with --id.", + "helpValue": "", + "name": "team-id", + "type": "option", + }, + ], + "id": "get projects", + "routes": [ + "GET /api/v2/projects", + "GET /api/v2/projects/{id}", + ], + }, { "api": "meilisearch", "description": "List the tasks of the instance, narrowed by --ids, --statuses, --types and the other filters of the route.", @@ -1494,6 +1701,67 @@ The file holds API keys and decides where every later command goes, for every pr "PATCH /indexes/{index_uid}", ], }, + { + "api": "management", + "description": "Rename a Meilisearch Cloud project.", + "examples": [ + "meili update projects --id 343 --name renamed", + "meili update projects --id 343 --name renamed --output json", + "meili update projects --project prod --name renamed", + "meili update projects --id 343 --data '{"name":"renamed"}'", + ], + "flags": [ + { + "description": "Management API personal access token.", + "helpValue": "", + "name": "management-token", + "type": "option", + }, + { + "description": "Management API base URL. Default https://api.meilisearch.com.", + "helpValue": "", + "name": "management-url", + "type": "option", + }, + { + "description": "A project of the config file, whose cloudId names the Cloud project, in place of --id.", + "helpValue": "", + "name": "project", + "type": "option", + }, + { + "description": "The project to update, or give --project and its cloudId from the config file.", + "helpValue": "", + "name": "id", + "type": "option", + }, + { + "description": "New name of the project.", + "helpValue": "", + "name": "name", + "type": "option", + }, + { + "description": "Read the body from a file. \`-\` reads from stdin.", + "exclusive": [ + "data", + ], + "helpValue": "", + "name": "file", + "type": "option", + }, + { + "description": "Inline JSON body.", + "helpValue": "", + "name": "data", + "type": "option", + }, + ], + "id": "update projects", + "routes": [ + "PATCH /api/v2/projects/{id}", + ], + }, ], "index": { "commands": [ @@ -1511,6 +1779,13 @@ The file holds API keys and decides where every later command goes, for every pr "POST /indexes", ], }, + { + "description": "Create a Meilisearch Cloud project in a team, on a resource.", + "id": "add projects", + "routes": [ + "POST /api/v2/projects", + ], + }, { "description": "Log in to Meilisearch Cloud with a browser, and store the token for the management commands.", "id": "auth login", @@ -1542,6 +1817,10 @@ The file holds API keys and decides where every later command goes, for every pr "description": "Set a project in the config file, creating the file owner-only; refuses a name that exists.", "id": "config set", }, + { + "description": "Set the cloudId of the config file entries whose URL is a Meilisearch Cloud project the token sees. Never writes a key, never adds or removes an entry. Prints what changed.", + "id": "config sync-with-cloud", + }, { "description": "Remove a project from the config file.", "id": "config unset", @@ -1562,6 +1841,13 @@ The file holds API keys and decides where every later command goes, for every pr "DELETE /indexes/{index_uid}", ], }, + { + "description": "Delete a Meilisearch Cloud project, with its data. Prints nothing on success.", + "id": "delete projects", + "routes": [ + "DELETE /api/v2/projects/{id}", + ], + }, { "description": "Delete the finished tasks matched by --ids, --statuses, --types and the other filters of the route.", "id": "delete tasks", @@ -1591,6 +1877,14 @@ The file holds API keys and decides where every later command goes, for every pr "GET /indexes/{index_uid}", ], }, + { + "description": "List the Meilisearch Cloud projects the token sees, or get one with --id or --project.", + "id": "get projects", + "routes": [ + "GET /api/v2/projects", + "GET /api/v2/projects/{id}", + ], + }, { "description": "List the tasks of the instance, narrowed by --ids, --statuses, --types and the other filters of the route.", "id": "get tasks", @@ -1623,6 +1917,13 @@ The file holds API keys and decides where every later command goes, for every pr "PATCH /indexes/{index_uid}", ], }, + { + "description": "Rename a Meilisearch Cloud project.", + "id": "update projects", + "routes": [ + "PATCH /api/v2/projects/{id}", + ], + }, ], "globalFlags": [ { diff --git a/test/contract/__snapshots__/help.test.ts.snap b/test/contract/__snapshots__/help.test.ts.snap index a490e1e..f175c2d 100644 --- a/test/contract/__snapshots__/help.test.ts.snap +++ b/test/contract/__snapshots__/help.test.ts.snap @@ -18,6 +18,8 @@ COMMANDS --filter. get health Check that the Meilisearch instance is available. get indexes List the indexes of the instance, or get one with --id. + get projects List the Meilisearch Cloud projects the token sees, or get one + with --id or --project. get tasks List the tasks of the instance, narrowed by --ids, --statuses, --types and the other filters of the route. diff --git a/test/contract/api.test.ts b/test/contract/api.test.ts index 405cfac..c637c1b 100644 --- a/test/contract/api.test.ts +++ b/test/contract/api.test.ts @@ -21,6 +21,7 @@ import { type OpenApiDocument, parseRoute, pathFlagsForRoute, + projectFlagForRoute, ROUTE_EXCEPTIONS, routeKey, } from "../../src/lib/api-naming.js"; @@ -70,12 +71,33 @@ const commands = await Promise.all( const globalFlags = [ ...Object.keys(BaseCommand.baseFlags), ...Object.keys(BaseCommand.connectionFlags), + ...Object.keys(BaseCommand.managementFlags), + ...Object.keys(BaseCommand.cloudProjectFlags), ]; const VERBS = ["get", "add", "update", "delete", ...LITERAL_VERBS]; +/** The commands of the "Commands that do not derive from a route" table of commands.md. */ +const listedCommands = tableRows( + section(readDoc("docs/contract/commands.md"), "Commands that do not derive from a route"), +) + // The command words are the ones starting with a letter: `meili config add --project `. + .map(([command = ""]) => + (codeSpans(command)[0] ?? "") + .split(" ") + .slice(1) + .filter((word) => /^[a-z]/.test(word)) + .join(" "), + ) + .filter((id) => id !== ""); + +const hasRoutes = (command: CommandClass): boolean => + Array.isArray(command.routes) && command.routes.length > 0; + describe("API commands derive from their routes", () => { for (const { id, file, command } of commands) { - if (command.api !== "meilisearch" && command.api !== "management") continue; + if ((command.api !== "meilisearch" && command.api !== "management") || !hasRoutes(command)) { + continue; + } const api = command.api; const spec = specs[api]; @@ -85,10 +107,6 @@ describe("API commands derive from their routes", () => { `openapi/management.json is missing and ${file} needs it. Run \`pnpm openapi:management\` with a GitHub token that reads meilisearch/meilisearch-cloud.`, ); } - expect( - Array.isArray(command.routes) && command.routes.length > 0, - `${file} must declare static routes`, - ).toBe(true); const routes = (command.routes as string[]).map(parseRoute); for (const route of routes) { expect(hasRoute(spec, route), `${routeKey(route)} is not in the OpenAPI document`).toBe( @@ -110,15 +128,21 @@ describe("API commands derive from their routes", () => { expect(Object.keys(command.args ?? {}), "no positional argument").toEqual([]); const flags = command.flags ?? {}; const pathFlags = routes.map((route) => pathFlagsForRoute(route, id)); + // `--project ` may give a Cloud project's id from the config file (rule 4). + const fromConfig = (flag: string): boolean => + routes.some((route) => projectFlagForRoute(route) === flag); for (const flag of new Set(pathFlags.flat())) { if (flag === "id") continue; - expect(flags[flag]?.required, `--${flag} must be a required flag`).toBe(true); + expect(flags[flag], `--${flag} must exist`).toBeDefined(); + if (!fromConfig(flag)) { + expect(flags[flag]?.required, `--${flag} must be a required flag`).toBe(true); + } } const routesWithId = pathFlags.filter((set) => set.includes("id")).length; if (routesWithId > 0) { expect(flags.id, "--id must exist").toBeDefined(); expect(flags.id?.required === true, "--id is required only when every route takes it").toBe( - routesWithId === routes.length, + routesWithId === routes.length && !fromConfig("id"), ); } @@ -150,19 +174,24 @@ describe("API commands derive from their routes", () => { } }); -describe("commands without an API", () => { +describe("commands that do not derive from a route", () => { const apiCommands = new Set([ ...derived.meilisearch.keys(), ...(derived.management?.keys() ?? []), ]); - for (const { id, command } of commands.filter(({ command }) => command.api === "none")) { - it(`meili ${id} has no route and takes neither the name of an API command nor a verb`, () => { - expect(command.routes ?? []).toEqual([]); + for (const { id, command } of commands.filter(({ command }) => !hasRoutes(command))) { + it(`meili ${id} is in the table of commands.md and takes neither the name of an API command nor a verb`, () => { + expect(listedCommands).toContain(id); expect(command.keyActions ?? []).toEqual([]); expect(apiCommands).not.toContain(id); expect(VERBS).not.toContain(id.split(" ")[0]); }); } + it("the table of commands.md lists only commands that exist", () => { + const ids = commands.map(({ id }) => id); + for (const listed of listedCommands) + expect(ids, `meili ${listed} is listed but does not exist`).toContain(listed); + }); }); describe("exceptions", () => { diff --git a/test/contract/docs.test.ts b/test/contract/docs.test.ts index 9e86bb2..7579bf8 100644 --- a/test/contract/docs.test.ts +++ b/test/contract/docs.test.ts @@ -18,23 +18,16 @@ for await (const file of glob("docs/contract/*.md", { cwd: PROJECT_ROOT })) guid /** * Paths the docs name on purpose before they exist: the worked example of the - * skill, and pieces that arrive with the first management command. When one of - * them appears for real, remove it from this list. + * skill, and the changelog of the first release. When one of them appears for + * real, remove it from this list. */ const PLANNED_PATHS = [ "CHANGELOG.md", - "src/commands/get/projects.ts", "src/commands/get/stats.ts", - "src/lib/clients/management/generated/", "test/commands/get/__snapshots__/stats.test.ts.snap", "test/commands/get/stats.test.ts", - "test/helpers/management-handlers.ts", - "test/integration/management/", - "test/integration/management/README.md", - "test/integration/management/bootstrap/", "test/integration/meilisearch/get-stats.test.ts", ]; -const PLANNED_SCRIPTS = ["gen:management"]; /** A span that is a path of this repository: a known top-level file or folder. */ const PATH_SPAN = @@ -75,13 +68,7 @@ describe("the guides cite things that exist", () => { for (const span of spans) { const script = span.match(/^pnpm ([\w:-]+)/)?.[1]; if (script === undefined || PNPM_BUILTINS.has(script)) continue; - const planned = PLANNED_SCRIPTS.includes(script); - expect( - scripts.includes(script), - planned - ? `pnpm ${script} exists now: remove it from PLANNED_SCRIPTS` - : `no script ${script}`, - ).toBe(!planned); + expect(scripts.includes(script), `no script ${script}`).toBe(true); } }); diff --git a/test/contract/env.test.ts b/test/contract/env.test.ts index 1441b3e..1384177 100644 --- a/test/contract/env.test.ts +++ b/test/contract/env.test.ts @@ -38,6 +38,8 @@ describe("flags and environment variables share their name (CLAUDE.md, rule 14)" const groups = [ BaseCommand.baseFlags, BaseCommand.connectionFlags, + BaseCommand.managementFlags, + BaseCommand.cloudProjectFlags, BaseCommand.waitFlags, BaseCommand.paginationFlags, BaseCommand.fromPaginationFlags, diff --git a/test/contract/errors.test.ts b/test/contract/errors.test.ts index 1045a08..c257b46 100644 --- a/test/contract/errors.test.ts +++ b/test/contract/errors.test.ts @@ -1,6 +1,6 @@ /** docs/contract/output.md against errors.ts: codes and exit codes, both ways. */ import { describe, expect, it } from "vitest"; -import { CLI_ERROR_CODES, EXIT_CODES, MANAGEMENT_ERROR_CODES } from "../../src/lib/errors.js"; +import { CLI_ERROR_CODES, EXIT_CODES } from "../../src/lib/errors.js"; import { codeSpans, readDoc, section, tableRows } from "./markdown.js"; const outputDoc = readDoc("docs/contract/output.md"); @@ -12,9 +12,9 @@ describe("error codes", () => { expect(new Set(documented)).toEqual(new Set(Object.keys(CLI_ERROR_CODES))); }); - it("management_ codes of errors.ts are exactly those of output.md", () => { - const documented = codeSpans(errorsSection).filter((span) => /^management_[a-z_]+$/.test(span)); - expect(new Set(documented)).toEqual(new Set(Object.keys(MANAGEMENT_ERROR_CODES))); + it("the CLI invents no code for either API: both are passed through", () => { + const invented = codeSpans(errorsSection).filter((span) => /^management_[a-z_]+$/.test(span)); + expect(invented).toEqual([]); }); }); @@ -38,12 +38,5 @@ describe("exit codes", () => { cli_input_invalid: 2, cli_internal: 1, }); - expect(MANAGEMENT_ERROR_CODES).toEqual({ - management_unauthorized: 3, - management_forbidden: 3, - management_not_found: 4, - management_unprocessable: 1, - management_error: 1, - }); }); }); diff --git a/test/contract/redaction.test.ts b/test/contract/redaction.test.ts index 1035de0..073f107 100644 --- a/test/contract/redaction.test.ts +++ b/test/contract/redaction.test.ts @@ -1,6 +1,8 @@ /** Secrets never reach stderr, not even in --debug traces. */ import { describe, expect, it } from "vitest"; +import AddProjects from "../../src/commands/add/projects.js"; import Health from "../../src/commands/get/health.js"; +import { MANAGEMENT_URL, managementHandlers, project } from "../helpers/management-handlers.js"; import { MEILISEARCH_URL, meilisearchHandlers } from "../helpers/meilisearch-handlers.js"; import { server, useMockServer } from "../helpers/msw.js"; import { runCommand } from "../helpers/run-command.js"; @@ -28,6 +30,26 @@ describe("--debug", () => { expect(result.stderr).not.toContain(SECRET); }); + it("masks the management token and the project's masterKey in the trace, not on stdout", async () => { + server.use(managementHandlers.createProject()); + const result = await runCommand( + AddProjects, + ["--debug", "--name", "demo", "--output", "json"], + { + env: { MEILI_MANAGEMENT_TOKEN: SECRET, MEILI_MANAGEMENT_URL: MANAGEMENT_URL }, + }, + ); + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain(`> POST ${MANAGEMENT_URL}/api/v2/projects`); + expect(result.stderr).toContain("> authorization: Bearer ****1a2b"); + expect(result.stderr).toContain('> {"name":"demo"}'); + expect(result.stderr).toContain('"masterKey":"****9z8y"'); + expect(result.stderr).not.toContain(SECRET); + expect(result.stderr).not.toContain(project.masterKey); + // The API body is data: the key the agent asked for goes to stdout unchanged. + expect(JSON.parse(result.stdout).masterKey).toBe(project.masterKey); + }); + it("never prints the key from MEILI_PROJECT_API_KEY either", async () => { server.use(meilisearchHandlers.health()); const result = await runCommand(Health, ["--debug", "--output", "json"], { diff --git a/test/helpers/errors.ts b/test/helpers/errors.ts new file mode 100644 index 0000000..c53e4cb --- /dev/null +++ b/test/helpers/errors.ts @@ -0,0 +1,12 @@ +import { MeiliCliError } from "../../src/lib/errors.js"; + +/** The MeiliCliError `fn` throws; anything else fails the test. */ +export function caught(fn: () => unknown): MeiliCliError { + try { + fn(); + } catch (error) { + if (error instanceof MeiliCliError) return error; + throw error; + } + throw new Error("expected a MeiliCliError"); +} diff --git a/test/helpers/management-handlers.ts b/test/helpers/management-handlers.ts new file mode 100644 index 0000000..0ebbb29 --- /dev/null +++ b/test/helpers/management-handlers.ts @@ -0,0 +1,119 @@ +/** + * msw handlers for the Management API, following openapi/management.json. The + * host is fake: msw intercepts fetch before any DNS lookup, and an unhandled + * request is an error. + */ +import { HttpResponse, http } from "msw"; +import type { + Error as ApiError, + Project, +} from "../../src/lib/clients/management/generated/types.gen.js"; + +export const MANAGEMENT_URL = "https://management.test"; +export const MANAGEMENT_TOKEN = "mspat_testtoken1a2b"; +/** The environment of a management command test. */ +export const managementEnv = { + MEILI_MANAGEMENT_TOKEN: MANAGEMENT_TOKEN, + MEILI_MANAGEMENT_URL: MANAGEMENT_URL, +}; + +/** A project as the API returns it; `masterKey` is a secret by design (output.md, "Pass-through"). */ +export const project: Project = { + id: 343, + name: "demo", + projectUid: "ms-abc", + url: "https://ms-abc.sfo.meilisearch.io", + masterKey: "projectmasterkey9z8y", + teamId: 12, + resourceId: 3, + msVersion: "v1.54.0", + workloadType: "production", + readOnly: false, + status: "running", + paused: false, + createdAt: "2026-10-14T10:00:00Z", + resource: { id: 3, name: "build", plan: "build", regionId: 1 }, +}; + +/** An error body as the document describes it: the same shape as a Meilisearch one. */ +export const forbidden: ApiError = { + message: "You cannot access this project.", + code: "project_not_accessible", + type: "auth", + link: "https://www.meilisearch.com/docs/reference/errors#project_not_accessible", +}; + +/** What a handler saw: the URL, the `Authorization` header and the parsed body. */ +export interface ReceivedRequest { + url: string; + authorization: string | null; + body: unknown; +} + +/** Parses the JSON body, if any, and records it with the URL and the `Authorization` header. */ +async function record(received: ReceivedRequest[], request: Request): Promise { + const body: unknown = request.method === "GET" ? undefined : await request.json(); + received.push({ url: request.url, authorization: request.headers.get("authorization"), body }); + return body; +} + +export const managementHandlers = { + /** + * `GET /api/v2/projects` paging `all` as the API does: `limit` items of the + * page asked for, in the envelope of the document. `pageSize` stands in for + * the API's own default, to put a handful of projects on several pages. + */ + listProjects: (all: Project[], received: ReceivedRequest[] = [], pageSize = 20) => + http.get(`${MANAGEMENT_URL}/api/v2/projects`, async ({ request }) => { + await record(received, request); + const query = new URL(request.url).searchParams; + const limit = Number(query.get("limit") ?? pageSize); + const page = Number(query.get("page") ?? 1); + return HttpResponse.json({ + page, + limit, + total: all.length, + totalPages: Math.max(1, Math.ceil(all.length / limit)), + results: all.slice((page - 1) * limit, page * limit), + }); + }), + + /** `POST /api/v2/projects` answering 201 with `project` carrying the attributes received. */ + createProject: (received: ReceivedRequest[] = []) => + http.post(`${MANAGEMENT_URL}/api/v2/projects`, async ({ request }) => { + const attributes = (await record(received, request)) as Record; + return HttpResponse.json({ ...project, ...attributes }, { status: 201 }); + }), + + /** `GET /api/v2/projects/{id}` answering the project of `all` with that id, or the API's 403. */ + getProject: (all: Project[]) => + http.get(`${MANAGEMENT_URL}/api/v2/projects/:id`, ({ params }) => { + const found = all.find((project) => String(project.id) === params.id); + return found === undefined + ? HttpResponse.json(forbidden, { status: 403 }) + : HttpResponse.json(found); + }), + + /** `PATCH /api/v2/projects/{id}` answering 200 with `project` carrying the attributes received. */ + updateProject: (received: ReceivedRequest[] = []) => + http.patch(`${MANAGEMENT_URL}/api/v2/projects/:id`, async ({ request }) => { + const body = (await record(received, request)) as Record; + return HttpResponse.json({ ...project, ...body }); + }), + + /** `DELETE /api/v2/projects/{id}` answering 204. */ + deleteProject: () => + http.delete( + `${MANAGEMENT_URL}/api/v2/projects/:id`, + () => new HttpResponse(null, { status: 204 }), + ), + + /** Any method on `path` answering `status`, with the body the running API sends or none. */ + apiError: (path: string, status: number, body?: unknown) => + http.all(`${MANAGEMENT_URL}${path}`, () => + body === undefined ? new HttpResponse(null, { status }) : HttpResponse.json(body, { status }), + ), + + /** Any method on `path` failing at the network level. */ + networkError: (path: string) => http.all(`${MANAGEMENT_URL}${path}`, () => HttpResponse.error()), +}; diff --git a/test/helpers/run-command.ts b/test/helpers/run-command.ts index c0000bd..a9162b0 100644 --- a/test/helpers/run-command.ts +++ b/test/helpers/run-command.ts @@ -5,13 +5,14 @@ * them. Help, `--version` and unknown commands go through oclif's `run`, which * reads `oclif.manifest.json` and `dist` (built by `pnpm test` first). */ -import { mkdtempSync } from "node:fs"; +import { mkdtempSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { format } from "node:util"; import { Config, Errors, type Interfaces, run as oclifRun, settings } from "@oclif/core"; import { vi } from "vitest"; +import { type ConfigFile, writeConfigFile } from "../../src/lib/config.js"; export const PROJECT_ROOT = fileURLToPath(new URL("../..", import.meta.url)); @@ -65,6 +66,21 @@ export async function runCli(argv: string[], options: RunOptions = {}): Promise< /** A temporary home per test file, where tests may write config files. */ export const scrubbedHome = mkdtempSync(join(tmpdir(), "meili-test-home-")); +/** Writes a config file in the temporary home and gives the environment that points at it. */ +export function tempConfig( + name: string, + projects: ConfigFile["projects"], +): { path: string; env: { MEILI_CONFIG: string } } { + const path = join(scrubbedHome, name); + writeConfigFile({ path, projects }); + return { path, env: { MEILI_CONFIG: path } }; +} + +/** The parsed content of a JSON file, for the assertions on a config file. */ +export function readJson(path: string): unknown { + return JSON.parse(readFileSync(path, "utf8")); +} + /** A fresh environment: the developer's MEILI_* variables never leak in. */ export function scrubbedEnv( overrides: Record = {}, diff --git a/test/lib/api-naming.test.ts b/test/lib/api-naming.test.ts index 84c9483..ee160d7 100644 --- a/test/lib/api-naming.test.ts +++ b/test/lib/api-naming.test.ts @@ -11,6 +11,7 @@ import { type OpenApiDocument, parseRoute, pathFlagsForRoute, + projectFlagForRoute, routeKey, singular, toKebabCase, @@ -63,14 +64,17 @@ const spec: OpenApiDocument = { "/dumps": { post: {} }, "/api/v2/me": { get: {} }, "/api/v2/projects": { - get: query("teamId"), - post: body({ project: { properties: { name: {}, teamId: {}, resourceId: {} } } }), + get: { + parameters: [{ $ref: "#/components/parameters/Page" }, ...query("teamId").parameters], + }, + post: body({ name: {}, teamId: {}, resourceId: {} }), }, "/api/v2/projects/{id}": { get: {}, delete: {} }, "/api/v2/team-members": { get: query("teamId"), post: {}, delete: {} }, }, components: { schemas: { IndexCreateRequest: { properties: { uid: {}, primaryKey: {} } } }, + parameters: { Page: { name: "page", in: "query" } }, }, }; @@ -178,6 +182,18 @@ describe("exceptions", () => { }); }); +describe("projectFlagForRoute", () => { + it("names the flag --project can fill: id on projects, project-id under a project, nothing else", () => { + expect(projectFlagForRoute(parseRoute("PATCH /api/v2/projects/{id}"))).toBe("id"); + expect(projectFlagForRoute(parseRoute("PATCH /api/v2/projects/{projectId}/version"))).toBe( + "project-id", + ); + expect(projectFlagForRoute(parseRoute("POST /api/v2/projects"))).toBeUndefined(); + expect(projectFlagForRoute(parseRoute("PATCH /api/v2/teams/{id}"))).toBeUndefined(); + expect(projectFlagForRoute(parseRoute("GET /indexes/{index_uid}"))).toBeUndefined(); + }); +}); + describe("pathFlagsForRoute", () => { it("names --id for the command's own resource and the singular collection for a parent", () => { expect(pathFlagsForRoute(parseRoute("GET /health"), "get health")).toEqual([]); @@ -205,6 +221,10 @@ describe("pathFlagsForRoute", () => { expect( pathFlagsForRoute(parseRoute("GET /tasks/{task_id}/documents"), "get documents"), ).toEqual(["task"]); + // A parent parameter of the Management API keeps its API name: `--project` is the global flag. + expect( + pathFlagsForRoute(parseRoute("PATCH /api/v2/projects/{projectId}/version"), "update version"), + ).toEqual(["project-id"]); expect(pathFlagsForRoute(parseRoute("GET /api/v2/projects/{id}"), "get projects")).toEqual([ "id", ]); @@ -215,10 +235,10 @@ describe("flagsForRoute", () => { it("takes the query parameters and the first-level body fields, in kebab-case", () => { expect(flags("GET /indexes", "get indexes")).toEqual(["limit", "offset"]); expect(flags("GET /health", "get health")).toEqual([]); - expect(flags("GET /api/v2/projects", "get projects")).toEqual(["team-id"]); + expect(flags("GET /api/v2/projects", "get projects")).toEqual(["page", "team-id"]); }); - it("unwraps a body made of one object named after the resource", () => { + it("resolves a shared parameter, which the Management API uses for its paging", () => { expect(flags("POST /api/v2/projects", "add projects")).toEqual([ "name", "resource-id", diff --git a/test/lib/config.test.ts b/test/lib/config.test.ts index 42fcce3..b87eb92 100644 --- a/test/lib/config.test.ts +++ b/test/lib/config.test.ts @@ -1,4 +1,4 @@ -import { chmodSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { chmodSync, statSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; @@ -10,27 +10,19 @@ import { readConfigFile, redactBody, redactHeaders, + resolveCloudProjectId, resolveProjectConnection, writeConfigFile, } from "../../src/lib/config.js"; -import { MeiliCliError } from "../../src/lib/errors.js"; -import { scrubbedHome } from "../helpers/run-command.js"; +import type { MeiliCliError } from "../../src/lib/errors.js"; +import { caught } from "../helpers/errors.js"; +import { readJson, scrubbedHome } from "../helpers/run-command.js"; /** A read that must not warn: an unexpected warning fails the test. */ const noWarning = (warning: MeiliCliError): never => { throw warning; }; -function caught(fn: () => unknown): MeiliCliError { - try { - fn(); - } catch (error) { - if (error instanceof MeiliCliError) return error; - throw error; - } - throw new Error("expected a MeiliCliError"); -} - const file = (name: string, projects: ConfigFile["projects"]): ConfigFile => ({ path: join(scrubbedHome, name), projects, @@ -81,12 +73,21 @@ describe("readConfigFile", () => { }, ); - it("refuses invalid JSON and a shape other than projects of url and apiKey", () => { + it("reads an entry's cloudId, a positive integer", () => { + const path = join(scrubbedHome, "cloud.json"); + writeConfigFile({ path, projects: { prod: { url: "http://prod", cloudId: 343 } } }); + expect(readConfigFile(path, noWarning).projects).toEqual({ + prod: { url: "http://prod", cloudId: 343 }, + }); + }); + + it("refuses invalid JSON and a shape other than projects of url, apiKey and cloudId", () => { for (const [name, content] of [ ["bad.json", "{"], ["shape.json", '{"current":"prod"}'], ["entry.json", '{"projects":{"prod":{"apiKey":"k"}}}'], ["key.json", '{"projects":{"prod":{"url":"http://p","apiKey":1}}}'], + ["cloud.json", '{"projects":{"prod":{"url":"http://p","cloudId":"343"}}}'], ] as const) { const path = join(scrubbedHome, name); writeFileSync(path, content, { mode: 0o600 }); @@ -102,7 +103,37 @@ describe("writeConfigFile", () => { const path = join(scrubbedHome, "nested", "config.json"); writeConfigFile({ path, projects: { prod } }); expect(statSync(path).mode & 0o777).toBe(0o600); - expect(JSON.parse(readFileSync(path, "utf8"))).toEqual({ projects: { prod } }); + expect(readJson(path)).toEqual({ projects: { prod } }); + }); +}); + +describe("resolveCloudProjectId", () => { + const entries = () => file("ids.json", { prod: { url: "http://prod", cloudId: 343 }, local }); + const unread = () => { + throw new Error("the file must not be read"); + }; + + it("takes --id as given, or the cloudId of the entry --project names", () => { + expect(resolveCloudProjectId({ id: 7 }, unread)).toBe(7); + expect(resolveCloudProjectId({ project: "prod" }, entries)).toBe(343); + }); + + it("fails with cli_usage on both flags or neither", () => { + expect(caught(() => resolveCloudProjectId({ id: 7, project: "prod" }, unread)).code).toBe( + "cli_usage", + ); + const neither = caught(() => resolveCloudProjectId({}, unread)); + expect(neither.code).toBe("cli_usage"); + expect(neither.message).toBe("Name the project with `--id ` or `--project `."); + }); + + it("fails with cli_config, naming the ways to set it, on an entry without cloudId", () => { + const error = caught(() => resolveCloudProjectId({ project: "local" }, entries)); + expect(error.code).toBe("cli_config"); + expect(error.message).toContain("`local` has no `cloudId`"); + expect(error.hint).toBe( + "Run `meili config sync-with-cloud`, or `meili config update --project local --project-cloud-id `.", + ); }); }); @@ -217,12 +248,13 @@ describe("redaction", () => { }); }); - it("redacts apiKey, key and token fields at any depth", () => { + it("redacts apiKey, masterKey, key and token fields at any depth", () => { expect( redactBody({ results: [{ uid: "1", key: "supersecretkey1a2b", nested: { token: "tok1234x" } }], apiKey: "k", - apikey: "masterKey1234", + apikey: "oldspelling1234", + masterKey: "projectmasterkey9z8y", keep: "visible", count: 2, }), @@ -230,6 +262,7 @@ describe("redaction", () => { results: [{ uid: "1", key: "****1a2b", nested: { token: "****234x" } }], apiKey: "****", apikey: "****1234", + masterKey: "****9z8y", keep: "visible", count: 2, }); diff --git a/test/lib/errors.test.ts b/test/lib/errors.test.ts index 67ac4d4..f813063 100644 --- a/test/lib/errors.test.ts +++ b/test/lib/errors.test.ts @@ -6,16 +6,56 @@ import { } from "meilisearch"; import { describe, expect, it } from "vitest"; import { + apiExitCode, CLI_ERROR_CODES, EXIT_CODES, formatError, - MANAGEMENT_ERROR_CODES, + ManagementApiError, MeiliCliError, - managementErrorCode, - meilisearchExitCode, toRenderedError, } from "../../src/lib/errors.js"; +describe("ManagementApiError", () => { + const forbidden = { + message: "You cannot access this project.", + code: "project_not_accessible", + type: "auth", + link: "https://docs.meilisearch.com/errors#project_not_accessible", + }; + + it("passes the API's error body through, like a Meilisearch one", () => { + const rendered = toRenderedError( + new ManagementApiError(403, forbidden, "https://m/api/v2/projects/343"), + ); + expect(rendered.body).toEqual({ + ...forbidden, + hint: expect.stringContaining("--management-token"), + }); + expect(rendered.exitCode).toBe(3); + }); + + it("maps the status to an exit code, as it does for Meilisearch", () => { + const at = (status: number) => + toRenderedError(new ManagementApiError(status, { ...forbidden }, "https://m/x")).exitCode; + expect(at(404)).toBe(4); + expect(at(422)).toBe(1); + expect(at(500)).toBe(1); + }); + + it("calls an answer without an error body a network error, not the API speaking", () => { + const rendered = toRenderedError( + new ManagementApiError(502, "bad gateway", "https://m/api/v2/projects"), + ); + expect(rendered.body).toEqual({ + code: "cli_network", + type: "cli", + message: + "The server at https://m/api/v2/projects answered HTTP 502 without a Management API error body.", + }); + expect(rendered.exitCode).toBe(6); + }); +}); + const apiBody = { message: "Index `movies` not found.", code: "index_not_found", @@ -172,20 +212,10 @@ describe("formatError", () => { }); describe("code tables", () => { - it("maps Management API statuses to codes and exit codes", () => { - expect(managementErrorCode(401)).toBe("management_unauthorized"); - expect(managementErrorCode(403)).toBe("management_forbidden"); - expect(managementErrorCode(404)).toBe("management_not_found"); - expect(managementErrorCode(422)).toBe("management_unprocessable"); - expect(managementErrorCode(500)).toBe("management_error"); - expect(MANAGEMENT_ERROR_CODES.management_unauthorized).toBe(3); - expect(MANAGEMENT_ERROR_CODES.management_not_found).toBe(4); - }); - - it("maps Meilisearch API statuses to exit codes", () => { - expect(meilisearchExitCode(401)).toBe(3); - expect(meilisearchExitCode(403)).toBe(3); - expect(meilisearchExitCode(404)).toBe(4); - expect(meilisearchExitCode(409)).toBe(1); + it("maps an API status to an exit code, the same way for both APIs", () => { + expect(apiExitCode(401)).toBe(3); + expect(apiExitCode(403)).toBe(3); + expect(apiExitCode(404)).toBe(4); + expect(apiExitCode(409)).toBe(1); }); }); diff --git a/test/lib/session.test.ts b/test/lib/session.test.ts index faa2937..a103725 100644 --- a/test/lib/session.test.ts +++ b/test/lib/session.test.ts @@ -139,21 +139,32 @@ describe("resolveManagementCredentials", () => { it("reads the token and the URL, with the production URL by default", async () => { await expect( - resolveManagementCredentials({ ...empty, MEILI_MANAGEMENT_TOKEN: "envToken" }, noWarning), + resolveManagementCredentials({}, { ...empty, MEILI_MANAGEMENT_TOKEN: "envToken" }, noWarning), ).resolves.toEqual({ token: "envToken", url: DEFAULT_MANAGEMENT_URL }); await expect( resolveManagementCredentials( + {}, { ...empty, MEILI_MANAGEMENT_TOKEN: "envToken", MEILI_MANAGEMENT_URL: URL }, noWarning, ), ).resolves.toEqual({ token: "envToken", url: URL }); }); + it("takes the flags over the variables", async () => { + await expect( + resolveManagementCredentials( + { token: "flagToken", url: "https://flag.test" }, + { ...empty, MEILI_MANAGEMENT_TOKEN: "envToken", MEILI_MANAGEMENT_URL: URL }, + noWarning, + ), + ).resolves.toEqual({ token: "flagToken", url: "https://flag.test" }); + }); + it("fails before any network call with cli_auth_missing and exit 3", async () => { - await expect(resolveManagementCredentials(empty, noWarning)).rejects.toMatchObject({ + await expect(resolveManagementCredentials({}, empty, noWarning)).rejects.toMatchObject({ code: "cli_auth_missing", exitCode: 3, - message: expect.stringContaining("meili auth login"), + message: expect.stringContaining("--management-token"), }); }); @@ -161,12 +172,12 @@ describe("resolveManagementCredentials", () => { const env = sessionHome(); storeSession(env, URL, { accessToken: "fromFile" }); - await expect(resolveManagementCredentials(env, noWarning)).resolves.toEqual({ + await expect(resolveManagementCredentials({}, env, noWarning)).resolves.toEqual({ token: "fromFile", url: URL, }); await expect( - resolveManagementCredentials({ ...env, MEILI_MANAGEMENT_TOKEN: "envToken" }, noWarning), + resolveManagementCredentials({}, { ...env, MEILI_MANAGEMENT_TOKEN: "envToken" }, noWarning), ).resolves.toEqual({ token: "envToken", url: DEFAULT_MANAGEMENT_URL }); }); @@ -175,6 +186,7 @@ describe("resolveManagementCredentials", () => { storeSession(env, URL); await expect( resolveManagementCredentials( + {}, { ...env, MEILI_MANAGEMENT_URL: "https://other.test" }, noWarning, ), @@ -187,7 +199,7 @@ describe("resolveManagementCredentials", () => { expiresAt: new Date(Date.now() - 1000).toISOString(), refreshToken: undefined, }); - await expect(resolveManagementCredentials(env, noWarning)).rejects.toMatchObject({ + await expect(resolveManagementCredentials({}, env, noWarning)).rejects.toMatchObject({ code: "cli_auth_failed", exitCode: 3, hint: expect.stringContaining("meili auth login"),