diff --git a/.changeset/first-version.md b/.changeset/first-version.md index 47ef6f4..71b1d2b 100644 --- a/.changeset/first-version.md +++ b/.changeset/first-version.md @@ -9,6 +9,7 @@ First version of `meili`, the Meilisearch CLI built for coding agents. - Write commands take their body from flags, `--data ''` or `--file ` (`-` for stdin), a flag overriding its field; `null` sends a JSON null. - `meili schema` prints every command with its flags and examples as JSON. - 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 add` and `config remove` manage the file. +- 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 add`, `config update` and `config remove` 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, `meili create projects --add-to-config` adds the project it creates. - `json` output by default, `ndjson` for lists, `--all` to follow the pagination, `--wait` and `--timeout` for asynchronous tasks. - Structured errors on stderr with stable codes and exit codes, a "did you mean" hint on an unknown command, `--debug` HTTP traces with secrets masked. diff --git a/.claude/skills/add-command/SKILL.md b/.claude/skills/add-command/SKILL.md index 28bfb34..239fe1f 100644 --- a/.claude/skills/add-command/SKILL.md +++ b/.claude/skills/add-command/SKILL.md @@ -13,7 +13,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. @@ -32,7 +32,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). diff --git a/CLAUDE.md b/CLAUDE.md index e8ba299..4e07dc1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,7 +37,7 @@ 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: it runs the generator through `pnpm dlx` with TypeScript 5 beside it, which the generator needs, and CI fails when the committed files differ. 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. +- `@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, 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 and a local Management API, see `docs/contract/testing.md`. - Biome for lint and format. - changesets for versioning and the changelog. @@ -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/README.md b/README.md index 4245a72..8adff2d 100644 --- a/README.md +++ b/README.md @@ -33,16 +33,19 @@ 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 add` and `meili config remove` manage the file. +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 add`, `meili config update` and `meili config remove` 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 create 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 diff --git a/docs/adr/0005-named-projects-config-file.md b/docs/adr/0005-named-projects-config-file.md index 44c9137..05622fe 100644 --- a/docs/adr/0005-named-projects-config-file.md +++ b/docs/adr/0005-named-projects-config-file.md @@ -1,6 +1,6 @@ # ADR 0005: Named projects in a config file, chosen on the command line, nothing deduced -Status: accepted. Date: 2026-10-14. +Status: accepted. Date: 2026-10-14. One point is superseded by ADR 0006: an entry is changed with `config update`, no longer with `remove` then `add`. ## Context 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..a7c1684 --- /dev/null +++ b/docs/adr/0006-cloud-projects-in-the-config-file.md @@ -0,0 +1,34 @@ +# 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; `--project` on a command none of whose routes takes a project is `cli_usage`. 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`: `create projects --add-to-config`, which adds the created project as an entry named after it; `config add` and `config update` with `--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 update` 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. +- **`config update`** changes the fields of an entry, replacing the `remove` then `add` of ADR 0005: `cloudId` arrives after an entry exists, and a key is corrected in place. +- **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 add`, 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 rows of `config update` and `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 704e9cc..fe47dee 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 `create`, `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 `create indexes --id movies` and `create 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`. +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`, `--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 the body is wrapped, `{"project": {…}}`, `--file` and `--data` give the wrapped object, as the flags do. 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. @@ -51,9 +51,9 @@ Every API command declares its routes, `static routes = ["GET /indexes", "GET /i | `GET /keys`, `GET /keys/{key}` | `meili get keys [--id ]` | | `POST /dumps` | `meili create dumps` | | `GET /api/v2/projects`, `GET /api/v2/projects/{id}` | `meili get projects [--id ] [--team-id ]` | -| `POST /api/v2/projects` | `meili create projects --name --team-id --resource-id [--ms-version ]` | -| `PATCH /api/v2/projects/{id}` | `meili update projects --id --name ` | -| `DELETE /api/v2/projects/{id}` | `meili delete projects --id ` | +| `POST /api/v2/projects` | `meili create 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 )` | | `GET /api/v2/team-members` | `meili get team-members --team-id ` | ## Exceptions @@ -105,7 +105,7 @@ These flags work on every command. |---|---| | `--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`. | | `--management-token ` | Management API personal access token. | | `--management-url ` | Management API base URL. Default `https://api.meilisearch.com`. | | `-o, --output ` | `json` (default) or `ndjson`. See `output.md`. | @@ -137,16 +137,24 @@ Commands reuse these groups. A command that matches a group uses the whole group | `--file ` | Read the body from a file. `-` reads from stdin. | | `--data ` | Inline JSON body. `--file` and `--data` are exclusive. | -## Commands without an API +**Config file** (`create projects`): -These commands call no API. They are the only ones listed here; their `--help` defines their shape. Their name is never the name of an API resource. The `config` commands are for humans: an agent works with the file it was given, see `config.md`. +| 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 are the only ones listed here; their `--help` defines their shape. Their name is never the name of an API resource. The `config` commands are for humans: an agent works with the file it was given, see `config.md`. `config sync-with-cloud` is the one that calls an API, the Management API, with `static api = "management"` and no route. | Command | Effect | |---|---| | `meili schema` | Print the full command tree as JSON: commands, flags, descriptions, examples. Generated from the oclif manifest. This is how an agent learns the CLI in one call. | | `meili config list` | Print the config file path and its projects as JSON, keys masked. | -| `meili config add --project --project-url [--project-api-key ]` | Add a project to the config file, creating it 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`. Nothing on stdout. | +| `meili config add --project --project-url [--project-api-key ] [--project-cloud-id ]` | Add a project to the config file, creating it 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`. Nothing on stdout. | +| `meili config update --project [--project-url ] [--project-api-key ] [--project-cloud-id ]` | Change the fields given of a project of the config file. At least one, or `cli_usage`. Nothing on stdout. | | `meili config remove --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 --version` | CLI version only. No network call. | ## Not in scope diff --git a/docs/contract/config.md b/docs/contract/config.md index 286d017..ae46ff3 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 add` and `meili config remove` manage the file, see `commands.md`. `config add` refuses a name that already exists: changing an entry is a `remove` then an `add`, on purpose. +- `meili config list`, `meili config add`, `meili config update`, `meili config remove` and `meili config sync-with-cloud` manage the file, see `commands.md`. `config add` refuses a name that already exists; `config update` changes the fields of an entry. ## 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. +- `--project` on a management command none of whose routes takes a project, `create projects`, is a `cli_usage` error: a flag is never ignored in silence. + +Three ways set `cloudId`, all explicit, the only moments the CLI consults the Management API on the config file's behalf: + +1. `meili create 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 add … --project-cloud-id ` and `meili config update --project --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 personal access token (PAT), created in the Meilisearch Cloud dashboard and sent as `Authorization: Bearer `. diff --git a/oclif.manifest.json b/oclif.manifest.json index cc7cd8d..3f9667c 100644 --- a/oclif.manifest.json +++ b/oclif.manifest.json @@ -7,6 +7,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": { @@ -70,6 +71,14 @@ "name": "debug", "allowNo": false, "type": "boolean" + }, + "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, @@ -174,7 +183,7 @@ "type": "option" }, "project": { - "description": "A project of the config file. Cannot be combined with --project-url. Required when the file has several projects.", + "description": "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 or --id. Required when the file has several projects.", "name": "project", "hasDynamicHelp": false, "helpValue": "", @@ -437,6 +446,313 @@ "remove.js" ] }, + "config:sync-with-cloud": { + "aliases": [], + "args": {}, + "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 %>", + "<%= config.bin %> <%= command.id %> --team-id 12", + "<%= config.bin %> <%= command.id %> --output json" + ], + "flags": { + "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: its URL and key for a Meilisearch command, its cloudId for a management command. Cannot be combined with --project-url or --id. Required when the file has several projects.", + "name": "project", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "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" + }, + "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" + }, + "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:sync-with-cloud", + "pluginAlias": "@meilisearch/cli", + "pluginName": "@meilisearch/cli", + "pluginType": "core", + "strict": true, + "enableJsonFlag": false, + "api": "management", + "routes": [], + "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" + } + }, + "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" + } + }, + "isESM": true, + "relativePath": [ + "dist", + "commands", + "config", + "sync-with-cloud.js" + ] + }, + "config:update": { + "aliases": [], + "args": {}, + "description": "Change the URL, the key or the cloudId of a project of 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 --project-cloud-id 343", + "<%= config.bin %> <%= command.id %> --project prod --project-api-key - < key.txt", + "<%= config.bin %> <%= command.id %> --project prod --project-url https://ms-abc.sfo.meilisearch.io --output json" + ], + "flags": { + "project-url": { + "description": "New URL of the project.", + "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": "Name of the project to change.", + "name": "project", + "required": true, + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "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" + }, + "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-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, + "hiddenAliases": [], + "id": "config:update", + "pluginAlias": "@meilisearch/cli", + "pluginName": "@meilisearch/cli", + "pluginType": "core", + "strict": true, + "enableJsonFlag": false, + "api": "none", + "routes": [], + "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" + } + }, + "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" + } + }, + "isESM": true, + "relativePath": [ + "dist", + "commands", + "config", + "update.js" + ] + }, "create:projects": { "aliases": [], "args": {}, @@ -444,6 +760,7 @@ "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" @@ -466,7 +783,7 @@ "type": "option" }, "project": { - "description": "A project of the config file. Cannot be combined with --project-url. Required when the file has several projects.", + "description": "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 or --id. Required when the file has several projects.", "name": "project", "hasDynamicHelp": false, "helpValue": "", @@ -540,6 +857,12 @@ "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": [ @@ -643,7 +966,8 @@ "description": "Delete a Meilisearch Cloud project, as written: no confirmation. Prints nothing on success.", "examples": [ "<%= config.bin %> <%= command.id %> --id 343", - "<%= config.bin %> <%= command.id %> --id 343 --output json" + "<%= config.bin %> <%= command.id %> --id 343 --output json", + "<%= config.bin %> <%= command.id %> --project staging" ], "flags": { "project-url": { @@ -663,7 +987,7 @@ "type": "option" }, "project": { - "description": "A project of the config file. Cannot be combined with --project-url. Required when the file has several projects.", + "description": "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 or --id. Required when the file has several projects.", "name": "project", "hasDynamicHelp": false, "helpValue": "", @@ -706,9 +1030,8 @@ "type": "boolean" }, "id": { - "description": "The project to delete.", + "description": "The project to delete, or give --project and its cloudId from the config file.", "name": "id", - "required": true, "hasDynamicHelp": false, "helpValue": "", "multiple": false, @@ -819,7 +1142,7 @@ "type": "option" }, "project": { - "description": "A project of the config file. Cannot be combined with --project-url. Required when the file has several projects.", + "description": "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 or --id. Required when the file has several projects.", "name": "project", "hasDynamicHelp": false, "helpValue": "", @@ -968,7 +1291,7 @@ "type": "option" }, "project": { - "description": "A project of the config file. Cannot be combined with --project-url. Required when the file has several projects.", + "description": "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 or --id. Required when the file has several projects.", "name": "project", "hasDynamicHelp": false, "helpValue": "", @@ -1146,7 +1469,7 @@ "type": "option" }, "project": { - "description": "A project of the config file. Cannot be combined with --project-url. Required when the file has several projects.", + "description": "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 or --id. Required when the file has several projects.", "name": "project", "hasDynamicHelp": false, "helpValue": "", @@ -1270,6 +1593,7 @@ "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": { @@ -1290,7 +1614,7 @@ "type": "option" }, "project": { - "description": "A project of the config file. Cannot be combined with --project-url. Required when the file has several projects.", + "description": "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 or --id. Required when the file has several projects.", "name": "project", "hasDynamicHelp": false, "helpValue": "", @@ -1333,9 +1657,8 @@ "type": "boolean" }, "id": { - "description": "The project to update.", + "description": "The project to update, or give --project and its cloudId from the config file.", "name": "id", - "required": true, "hasDynamicHelp": false, "helpValue": "", "multiple": false, diff --git a/src/commands/config/add.ts b/src/commands/config/add.ts index 8d6cf7b..9d74918 100644 --- a/src/commands/config/add.ts +++ b/src/commands/config/add.ts @@ -1,7 +1,11 @@ import { Flags } from "@oclif/core"; import { type Api, BaseCommand } from "../../lib/base-command.js"; -import { writeConfigFile } from "../../lib/config.js"; -import { MeiliCliError } from "../../lib/errors.js"; +import { + assertNoProject, + CONFIG_FILE_WARNING, + parseProjectUrl, + writeConfigFile, +} from "../../lib/config.js"; export default class ConfigAdd extends BaseCommand { static api: Api = "none"; @@ -9,11 +13,12 @@ export default class ConfigAdd extends BaseCommand { static override description = `Add a project to the config file, creating it 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", ]; @@ -28,28 +33,31 @@ The file holds API keys and decides where every later command goes, for every pr description: "URL of the project.", helpValue: "", }), + "project-cloud-id": Flags.integer({ + description: "Id of the project in Meilisearch Cloud, for the management commands.", + helpValue: "", + }), }; async run(): Promise { const { flags } = await this.parse(ConfigAdd); - // A Meilisearch instance answers over HTTP(S) only; the SDK mangles any other scheme. - const url = URL.parse(flags["project-url"]); - if (url === null || (url.protocol !== "http:" && url.protocol !== "https:")) { - throw new MeiliCliError("cli_usage", `Invalid project URL \`${flags["project-url"]}\`.`); - } + const url = parseProjectUrl(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 remove --project ${flags.project}\` first.` }, - ); - } + assertNoProject( + file, + flags.project, + ".", + `Run \`meili config remove --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, + apiKey: flags["project-api-key"], + cloudId: flags["project-cloud-id"], + }, }, }); } diff --git a/src/commands/config/remove.ts b/src/commands/config/remove.ts index 125cb9d..2975b1f 100644 --- a/src/commands/config/remove.ts +++ b/src/commands/config/remove.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 ConfigRemove 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/config/sync-with-cloud.ts b/src/commands/config/sync-with-cloud.ts new file mode 100644 index 0000000..bc6cd7a --- /dev/null +++ b/src/commands/config/sync-with-cloud.ts @@ -0,0 +1,69 @@ +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 { 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 = { + "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); + const projects = await getApiV2Projects({ + client: this.managementClient, + query: { teamId: flags["team-id"] }, + }); + 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/update.ts b/src/commands/config/update.ts new file mode 100644 index 0000000..fdfd4ea --- /dev/null +++ b/src/commands/config/update.ts @@ -0,0 +1,63 @@ +import { Flags } from "@oclif/core"; +import { type Api, BaseCommand } from "../../lib/base-command.js"; +import { + CONFIG_FILE_WARNING, + parseProjectUrl, + projectOf, + writeConfigFile, +} from "../../lib/config.js"; +import { MeiliCliError } from "../../lib/errors.js"; + +export default class ConfigUpdate extends BaseCommand { + static api: Api = "none"; + + static override description = + `Change the URL, the key or the cloudId of a project of the config file. + +${CONFIG_FILE_WARNING}`; + + static override examples = [ + "<%= config.bin %> <%= command.id %> --project prod --project-cloud-id 343", + "<%= config.bin %> <%= command.id %> --project prod --project-api-key - < key.txt", + "<%= config.bin %> <%= command.id %> --project prod --project-url https://ms-abc.sfo.meilisearch.io --output json", + ]; + + static override flags = { + project: Flags.string({ + required: true, + description: "Name of the project to change.", + helpValue: "", + }), + "project-url": Flags.string({ description: "New URL of the project.", helpValue: "" }), + "project-cloud-id": Flags.integer({ + description: "Id of the project in Meilisearch Cloud, for the management commands.", + helpValue: "", + }), + }; + + async run(): Promise { + const { flags } = await this.parse(ConfigUpdate); + const url = flags["project-url"]; + const apiKey = this.globalFlags["project-api-key"]; + const cloudId = flags["project-cloud-id"]; + if (url === undefined && apiKey === undefined && cloudId === undefined) { + throw new MeiliCliError( + "cli_usage", + "Nothing to change: give `--project-url`, `--project-api-key` or `--project-cloud-id`.", + ); + } + const file = this.configFile(); + const entry = projectOf(file, flags.project); + writeConfigFile({ + path: file.path, + projects: { + ...file.projects, + [flags.project]: { + url: url === undefined ? entry.url : parseProjectUrl(url), + apiKey: apiKey ?? entry.apiKey, + cloudId: cloudId ?? entry.cloudId, + }, + }, + }); + } +} diff --git a/src/commands/create/projects.ts b/src/commands/create/projects.ts index 85ad6cf..c2f355d 100644 --- a/src/commands/create/projects.ts +++ b/src/commands/create/projects.ts @@ -2,6 +2,8 @@ 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"; export default class CreateProjects extends BaseCommand { static api: Api = "management"; @@ -13,6 +15,7 @@ export default class CreateProjects extends BaseCommand { 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", @@ -29,6 +32,10 @@ export default class CreateProjects extends BaseCommand { 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, }; @@ -40,6 +47,37 @@ export default class CreateProjects extends BaseCommand { resourceId: flags["resource-id"], msVersion: flags["ms-version"], }); - await this.output(await postApiV2Projects({ client: this.managementClient, body })); + 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 remove --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/delete/projects.ts b/src/commands/delete/projects.ts index e5b6825..c31b5dc 100644 --- a/src/commands/delete/projects.ts +++ b/src/commands/delete/projects.ts @@ -13,14 +13,22 @@ export default class DeleteProjects extends BaseCommand { static override examples = [ "<%= config.bin %> <%= command.id %> --id 343", "<%= config.bin %> <%= command.id %> --id 343 --output json", + "<%= config.bin %> <%= command.id %> --project staging", ]; static override flags = { - id: Flags.integer({ required: true, description: "The project to delete.", helpValue: "" }), + id: Flags.integer({ + description: + "The project to delete, or give --project and its cloudId from the config file.", + helpValue: "", + }), }; async run(): Promise { const { flags } = await this.parse(DeleteProjects); - await deleteApiV2ProjectsById({ client: this.managementClient, path: { id: flags.id } }); + await deleteApiV2ProjectsById({ + client: this.managementClient, + path: { id: this.cloudProjectId(flags.id) }, + }); } } diff --git a/src/commands/update/projects.ts b/src/commands/update/projects.ts index 1ee0ba8..16fc474 100644 --- a/src/commands/update/projects.ts +++ b/src/commands/update/projects.ts @@ -13,11 +13,16 @@ export default class UpdateProjects extends BaseCommand { 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 = { - id: Flags.integer({ required: true, description: "The project to update.", helpValue: "" }), + 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, }; @@ -30,7 +35,7 @@ export default class UpdateProjects extends BaseCommand { await this.output( await patchApiV2ProjectsById({ client: this.managementClient, - path: { id: flags.id }, + path: { id: this.cloudProjectId(flags.id) }, body: { project }, }), ); diff --git a/src/lib/api-naming.ts b/src/lib/api-naming.ts index 6ca132b..0bfc93d 100644 --- a/src/lib/api-naming.ts +++ b/src/lib/api-naming.ts @@ -15,7 +15,14 @@ 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"]; +export const CLI_OWNED_FLAGS: readonly string[] = [ + "wait", + "timeout", + "all", + "file", + "data", + "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[] = [ @@ -385,10 +392,28 @@ 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; +} + function resolveSchema( spec: OpenApiDocument, schema: OpenApiSchema | undefined, diff --git a/src/lib/base-command.ts b/src/lib/base-command.ts index d67719e..d7bc877 100644 --- a/src/lib/base-command.ts +++ b/src/lib/base-command.ts @@ -6,6 +6,7 @@ */ import { Command, Errors, Flags, type Interfaces } from "@oclif/core"; import type { EnqueuedTask, Meilisearch } from "meilisearch"; +import { parseRoute, projectFlagForRoute } from "./api-naming.js"; import { type BodyInputFlags, buildBody } from "./body.js"; import { installHttpTrace } from "./clients/http-trace.js"; import { createManagementClient, type ManagementClient } from "./clients/management.js"; @@ -16,6 +17,7 @@ import { DEFAULT_MANAGEMENT_URL, ENV, readConfigFile, + resolveCloudProjectId, resolveManagementCredentials, resolveProjectConnection, } from "./config.js"; @@ -60,7 +62,7 @@ export abstract class BaseCommand extends Command { }), project: Flags.string({ description: - "A project of the config file. Cannot be combined with --project-url. Required when the file has several projects.", + "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 or --id. Required when the file has several projects.", helpValue: "", }), "management-token": Flags.string({ @@ -157,6 +159,16 @@ export abstract class BaseCommand extends Command { return; } + const routes = (this.constructor as typeof BaseCommand).routes; + if ( + this.globalFlags.project !== undefined && + !routes.some((route) => projectFlagForRoute(parseRoute(route)) !== undefined) + ) { + throw new MeiliCliError( + "cli_usage", + "`--project` has no use here: none of the routes of this command takes a project.", + ); + } this.managementClient = createManagementClient({ ...resolveManagementCredentials( { token: this.globalFlags["management-token"], url: this.globalFlags["management-url"] }, @@ -195,6 +207,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(process.env), writeWarning); diff --git a/src/lib/clients/management/generated/responses.gen.ts b/src/lib/clients/management/generated/responses.gen.ts index e75041b..88103e7 100644 --- a/src/lib/clients/management/generated/responses.gen.ts +++ b/src/lib/clients/management/generated/responses.gen.ts @@ -64,7 +64,7 @@ export const RESPONSES: Readonly> }, "POST /api/v2/projects": { "201": "project created", - "403": "team not accessible", + "403": "project limit reached", "422": "invalid project attributes" }, "GET /api/v2/projects/{id}": { diff --git a/src/lib/clients/management/generated/types.gen.ts b/src/lib/clients/management/generated/types.gen.ts index 196a2bf..78814b6 100644 --- a/src/lib/clients/management/generated/types.gen.ts +++ b/src/lib/clients/management/generated/types.gen.ts @@ -546,7 +546,7 @@ export type PostApiV2ProjectsData = { export type PostApiV2ProjectsErrors = { /** - * team not accessible + * project limit reached */ 403: unknown; /** diff --git a/src/lib/config.ts b/src/lib/config.ts index 239ed3c..c75cb8a 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -61,6 +61,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 { @@ -104,7 +106,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(); @@ -112,8 +114,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]; diff --git a/test/commands/__snapshots__/schema.test.ts.snap b/test/commands/__snapshots__/schema.test.ts.snap index d4340be..84b3611 100644 --- a/test/commands/__snapshots__/schema.test.ts.snap +++ b/test/commands/__snapshots__/schema.test.ts.snap @@ -18,9 +18,11 @@ FLAGS --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. Cannot be combined - with --project-url. Required when the file has - several projects. + --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 or --id. Required when the file + has several projects. --project-api-key= Project API key. \`-\` reads it from stdin. --project-url= Project URL, the Meilisearch instance to talk to. diff --git a/test/commands/config/__snapshots__/add.test.ts.snap b/test/commands/config/__snapshots__/add.test.ts.snap index d02e725..933a3f6 100644 --- a/test/commands/config/__snapshots__/add.test.ts.snap +++ b/test/commands/config/__snapshots__/add.test.ts.snap @@ -6,7 +6,7 @@ exports[`meili config add > prints its help without any network call 1`] = ` USAGE $ meili config add --project-url --project [--project-api-key ] [--management-token ] [--management-url - ] [-o ] [--debug] + ] [-o ] [--debug] [--project-cloud-id ] FLAGS -o, --output= json (default): one document, the API body as it @@ -20,6 +20,8 @@ FLAGS https://api.meilisearch.com. --project= (required) Name of the project to add. --project-api-key= Project API key. \`-\` reads it from stdin. + --project-cloud-id= Id of the project in Meilisearch Cloud, for the + management commands. --project-url= (required) URL of the project. DESCRIPTION @@ -35,6 +37,8 @@ EXAMPLES $ meili config add --project prod --project-url https://ms-abc.sfo.meilisearch.io --project-api-key - < key.txt + $ meili config add --project prod --project-url https://ms-abc.sfo.meilisearch.io --project-cloud-id 343 + $ meili config add --project local --project-url http://localhost:7700 --output json " diff --git a/test/commands/config/__snapshots__/list.test.ts.snap b/test/commands/config/__snapshots__/list.test.ts.snap index ed126f2..63d2456 100644 --- a/test/commands/config/__snapshots__/list.test.ts.snap +++ b/test/commands/config/__snapshots__/list.test.ts.snap @@ -18,9 +18,11 @@ FLAGS --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. Cannot be combined - with --project-url. Required when the file has - several projects. + --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 or --id. Required when the file + has several projects. --project-api-key= Project API key. \`-\` reads it from stdin. --project-url= Project URL, the Meilisearch instance to talk to. 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..9369392 --- /dev/null +++ b/test/commands/config/__snapshots__/sync-with-cloud.test.ts.snap @@ -0,0 +1,49 @@ +// 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 [--project-url ] [--project-api-key ] + [--project ] [--management-token ] [--management-url ] [-o + ] [--debug] [--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. + --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 or --id. Required when the file + has several projects. + --project-api-key= Project API key. \`-\` reads it from stdin. + --project-url= Project URL, the Meilisearch instance to talk + to. + --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/__snapshots__/update.test.ts.snap b/test/commands/config/__snapshots__/update.test.ts.snap new file mode 100644 index 0000000..eb39e77 --- /dev/null +++ b/test/commands/config/__snapshots__/update.test.ts.snap @@ -0,0 +1,42 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`meili config update > prints its help without any network call 1`] = ` +"Change the URL, the key or the cloudId of a project of the config file. + +USAGE + $ meili config update --project [--project-url ] + [--project-api-key ] [--management-token ] [--management-url + ] [-o ] [--debug] [--project-cloud-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. + --project= (required) Name of the project to change. + --project-api-key= Project API key. \`-\` reads it from stdin. + --project-cloud-id= Id of the project in Meilisearch Cloud, for the + management commands. + --project-url= New URL of the project. + +DESCRIPTION + Change the URL, the key or the cloudId of a project of 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. + +EXAMPLES + $ meili config update --project prod --project-cloud-id 343 + + $ meili config update --project prod --project-api-key - < key.txt + + $ meili config update --project prod --project-url https://ms-abc.sfo.meilisearch.io --output json + +" +`; diff --git a/test/commands/config/add.test.ts b/test/commands/config/add.test.ts index 0b3ca86..5c67734 100644 --- a/test/commands/config/add.test.ts +++ b/test/commands/config/add.test.ts @@ -1,16 +1,13 @@ -import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; import ConfigAdd from "../../../src/commands/config/add.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 add", () => { it("prints its help without any network call", async () => { const result = await runCli(["config", "add", "--help"]); @@ -30,6 +27,17 @@ describe("meili config add", () => { 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( + ConfigAdd, + ["--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/remove.test.ts b/test/commands/config/remove.test.ts index 37773c5..7f3864e 100644 --- a/test/commands/config/remove.test.ts +++ b/test/commands/config/remove.test.ts @@ -1,10 +1,9 @@ -import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import ConfigRemove from "../../../src/commands/config/remove.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 remove", () => { 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/config/sync-with-cloud.test.ts b/test/commands/config/sync-with-cloud.test.ts new file mode 100644 index 0000000..addee22 --- /dev/null +++ b/test/commands/config/sync-with-cloud.test.ts @@ -0,0 +1,74 @@ +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("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`); + 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/update.test.ts b/test/commands/config/update.test.ts new file mode 100644 index 0000000..daad43a --- /dev/null +++ b/test/commands/config/update.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import ConfigUpdate from "../../../src/commands/config/update.js"; +import { useMockServer } from "../../helpers/msw.js"; +import { readJson, runCli, runCommand, tempConfig } from "../../helpers/run-command.js"; + +useMockServer(); + +describe("meili config update", () => { + it("prints its help without any network call", async () => { + const result = await runCli(["config", "update", "--help"]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toMatchSnapshot(); + }); + + it("changes the fields given and keeps the others, and prints nothing", async () => { + const { path, env } = tempConfig("update.json", { prod: { url: "https://old", apiKey: "k" } }); + const result = await runCommand( + ConfigUpdate, + ["--project", "prod", "--project-cloud-id", "343", "--output", "json"], + { env }, + ); + expect(result).toEqual({ stdout: "", stderr: "", exitCode: 0 }); + expect(readJson(path)).toEqual({ + projects: { prod: { url: "https://old", apiKey: "k", cloudId: 343 } }, + }); + + await runCommand(ConfigUpdate, ["--project", "prod", "--project-url", "https://new"], { env }); + expect(readJson(path)).toEqual({ + projects: { prod: { url: "https://new", apiKey: "k", cloudId: 343 } }, + }); + }); + + it("fails with cli_usage without a field to change, and cli_config on an unknown project", async () => { + const { path, env } = tempConfig("update-errors.json", { prod: { url: "https://p" } }); + const nothing = await runCommand(ConfigUpdate, ["--project", "prod"], { env }); + expect(nothing.exitCode).toBe(2); + expect(JSON.parse(nothing.stderr).error.code).toBe("cli_usage"); + const unknown = await runCommand( + ConfigUpdate, + ["--project", "nope", "--project-cloud-id", "1"], + { env }, + ); + expect(unknown.exitCode).toBe(2); + expect(JSON.parse(unknown.stderr).error.message).toBe( + `No project \`nope\` in ${path}. Projects: prod.`, + ); + }); +}); diff --git a/test/commands/create/__snapshots__/projects.test.ts.snap b/test/commands/create/__snapshots__/projects.test.ts.snap index c7c3bb6..2e15fc0 100644 --- a/test/commands/create/__snapshots__/projects.test.ts.snap +++ b/test/commands/create/__snapshots__/projects.test.ts.snap @@ -16,13 +16,16 @@ USAGE $ meili create projects [--project-url ] [--project-api-key ] [--project ] [--management-token ] [--management-url ] [-o ] [--debug] [--name ] [--team-id ] [--resource-id ] - [--ms-version ] [--file | --data ] + [--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. @@ -33,9 +36,11 @@ FLAGS --ms-version= Meilisearch version of the project, the latest by default. --name= Name of the project. - --project= A project of the config file. Cannot be combined - with --project-url. Required when the file has - several projects. + --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 or --id. Required when the file + has several projects. --project-api-key= Project API key. \`-\` reads it from stdin. --project-url= Project URL, the Meilisearch instance to talk to. @@ -51,6 +56,8 @@ EXAMPLES $ meili create projects --name demo --team-id 12 --resource-id 3 --output json + $ meili create projects --name demo --team-id 12 --resource-id 3 --add-to-config + $ meili create projects --name demo --team-id 12 --resource-id 3 --ms-version v1.54.0 $ meili create projects --data '{"name":"demo","teamId":12,"resourceId":3}' diff --git a/test/commands/create/projects.test.ts b/test/commands/create/projects.test.ts index caf7cb6..bac47d5 100644 --- a/test/commands/create/projects.test.ts +++ b/test/commands/create/projects.test.ts @@ -8,7 +8,7 @@ import { type ReceivedRequest, } from "../../helpers/management-handlers.js"; import { server, useMockServer } from "../../helpers/msw.js"; -import { runCli, runCommand } from "../../helpers/run-command.js"; +import { readJson, runCli, runCommand, tempConfig } from "../../helpers/run-command.js"; useMockServer(); @@ -29,7 +29,7 @@ describe("meili create projects", () => { { env }, ); expect(result).toMatchSnapshot(); - expect(received).toEqual([ + expect(received).toMatchObject([ { authorization: `Bearer ${MANAGEMENT_TOKEN}`, body: { name: "demo", teamId: 12, resourceId: 3 }, @@ -37,6 +37,58 @@ describe("meili create projects", () => { ]); }); + 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( + CreateProjects, + ["--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( + CreateProjects, + ["--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("refuses --project, since none of its routes takes a project", async () => { + const result = await runCommand(CreateProjects, ["--name", "demo", "--project", "prod"], { + env, + }); + expect(result.exitCode).toBe(2); + expect(JSON.parse(result.stderr).error).toEqual({ + code: "cli_usage", + type: "cli", + message: "`--project` has no use here: none of the routes of this command takes a project.", + }); + }); + it("takes the body from --data, a flag overriding its field", async () => { const received: ReceivedRequest[] = []; server.use(managementHandlers.createProject(received)); diff --git a/test/commands/delete/__snapshots__/projects.test.ts.snap b/test/commands/delete/__snapshots__/projects.test.ts.snap index a3d1146..5ba37a3 100644 --- a/test/commands/delete/__snapshots__/projects.test.ts.snap +++ b/test/commands/delete/__snapshots__/projects.test.ts.snap @@ -13,9 +13,9 @@ exports[`meili delete projects > prints its help without any network call 1`] = "Delete a Meilisearch Cloud project, as written: no confirmation. Prints nothing on success. USAGE - $ meili delete projects --id [--project-url ] [--project-api-key - ] [--project ] [--management-token ] [--management-url - ] [-o ] [--debug] + $ meili delete projects [--project-url ] [--project-api-key ] + [--project ] [--management-token ] [--management-url ] [-o + ] [--debug] [--id ] FLAGS -o, --output= json (default): one document, the API body as it @@ -24,13 +24,16 @@ FLAGS streams with --all. --debug Print every HTTP request and response on stderr, secrets redacted. - --id= (required) The project to delete. + --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. Cannot be combined - with --project-url. Required when the file has - several projects. + --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 or --id. Required when the file + has several projects. --project-api-key= Project API key. \`-\` reads it from stdin. --project-url= Project URL, the Meilisearch instance to talk to. @@ -44,5 +47,7 @@ EXAMPLES $ meili delete projects --id 343 --output json + $ meili delete projects --project staging + " `; diff --git a/test/commands/delete/projects.test.ts b/test/commands/delete/projects.test.ts index 57750ab..77bd64e 100644 --- a/test/commands/delete/projects.test.ts +++ b/test/commands/delete/projects.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import DeleteProjects from "../../../src/commands/delete/projects.js"; import { managementEnv as env, managementHandlers } from "../../helpers/management-handlers.js"; import { server, useMockServer } from "../../helpers/msw.js"; -import { runCli, runCommand } from "../../helpers/run-command.js"; +import { runCli, runCommand, tempConfig } from "../../helpers/run-command.js"; useMockServer(); @@ -20,6 +20,17 @@ describe("meili delete projects", () => { 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"], { + env: { ...env, ...configEnv }, + }); + expect(result).toEqual({ stdout: "", stderr: "", exitCode: 0 }); + }); + it("maps a 403 to management_forbidden and exit 3", async () => { server.use(managementHandlers.apiError("/api/v2/projects/343", 403, { error: "Forbidden" })); const result = await runCommand(DeleteProjects, ["--id", "343", "--output", "json"], { env }); diff --git a/test/commands/get/__snapshots__/health.test.ts.snap b/test/commands/get/__snapshots__/health.test.ts.snap index f6a71e9..22e0b17 100644 --- a/test/commands/get/__snapshots__/health.test.ts.snap +++ b/test/commands/get/__snapshots__/health.test.ts.snap @@ -27,9 +27,11 @@ FLAGS --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. Cannot be combined - with --project-url. Required when the file has - several projects. + --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 or --id. Required when the file + has several projects. --project-api-key= Project API key. \`-\` reads it from stdin. --project-url= Project URL, the Meilisearch instance to talk to. diff --git a/test/commands/get/__snapshots__/indexes.test.ts.snap b/test/commands/get/__snapshots__/indexes.test.ts.snap index 2c725d2..5207f48 100644 --- a/test/commands/get/__snapshots__/indexes.test.ts.snap +++ b/test/commands/get/__snapshots__/indexes.test.ts.snap @@ -51,9 +51,11 @@ FLAGS --management-url= Management API base URL. Default https://api.meilisearch.com. --offset= Passed to the API. - --project= A project of the config file. Cannot be combined - with --project-url. Required when the file has - several projects. + --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 or --id. Required when the file + has several projects. --project-api-key= Project API key. \`-\` reads it from stdin. --project-url= Project URL, the Meilisearch instance to talk to. diff --git a/test/commands/update/__snapshots__/projects.test.ts.snap b/test/commands/update/__snapshots__/projects.test.ts.snap index e137bc4..ca543d1 100644 --- a/test/commands/update/__snapshots__/projects.test.ts.snap +++ b/test/commands/update/__snapshots__/projects.test.ts.snap @@ -13,9 +13,10 @@ exports[`meili update projects > prints its help without any network call 1`] = "Rename a Meilisearch Cloud project. USAGE - $ meili update projects --id [--project-url ] [--project-api-key - ] [--project ] [--management-token ] [--management-url - ] [-o ] [--debug] [--name ] [--file | --data ] + $ meili update projects [--project-url ] [--project-api-key ] + [--project ] [--management-token ] [--management-url ] [-o + ] [--debug] [--id ] [--name ] [--file | --data + ] FLAGS -o, --output= json (default): one document, the API body as it @@ -26,14 +27,17 @@ FLAGS --debug Print every HTTP request and response on stderr, secrets redacted. --file= Read the body from a file. \`-\` reads from stdin. - --id= (required) The project to update. + --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. Cannot be combined - with --project-url. Required when the file has - several projects. + --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 or --id. Required when the file + has several projects. --project-api-key= Project API key. \`-\` reads it from stdin. --project-url= Project URL, the Meilisearch instance to talk to. @@ -46,6 +50,8 @@ EXAMPLES $ meili update projects --id 343 --name renamed --output json + $ meili update projects --project prod --name renamed + $ meili update projects --id 343 --data '{"name":"renamed"}' " diff --git a/test/commands/update/projects.test.ts b/test/commands/update/projects.test.ts index 96a8338..12ffac3 100644 --- a/test/commands/update/projects.test.ts +++ b/test/commands/update/projects.test.ts @@ -2,11 +2,12 @@ import { describe, expect, it } from "vitest"; import UpdateProjects from "../../../src/commands/update/projects.js"; import { managementEnv as env, + MANAGEMENT_URL, managementHandlers, type ReceivedRequest, } from "../../helpers/management-handlers.js"; import { server, useMockServer } from "../../helpers/msw.js"; -import { runCli, runCommand } from "../../helpers/run-command.js"; +import { runCli, runCommand, tempConfig } from "../../helpers/run-command.js"; useMockServer(); @@ -30,6 +31,27 @@ describe("meili update projects", () => { expect(received[0]?.body).toEqual({ project: { 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("maps a 403 to management_forbidden and exit 3", async () => { server.use(managementHandlers.apiError("/api/v2/projects/343", 403, { error: "Forbidden" })); const result = await runCommand( diff --git a/test/contract/__snapshots__/commands.test.ts.snap b/test/contract/__snapshots__/commands.test.ts.snap index 89d2350..8003a1b 100644 --- a/test/contract/__snapshots__/commands.test.ts.snap +++ b/test/contract/__snapshots__/commands.test.ts.snap @@ -10,6 +10,7 @@ The file holds API keys and decides where every later command goes, for every pr "examples": [ "meili config add --project local --project-url http://localhost:7700", "meili config add --project prod --project-url https://ms-abc.sfo.meilisearch.io --project-api-key - < key.txt", + "meili config add --project prod --project-url https://ms-abc.sfo.meilisearch.io --project-cloud-id 343", "meili config add --project local --project-url http://localhost:7700 --output json", ], "flags": [ @@ -66,6 +67,13 @@ The file holds API keys and decides where every later command goes, for every pr "required": false, "type": "boolean", }, + { + "description": "Id of the project in Meilisearch Cloud, for the management commands.", + "helpValue": "", + "name": "project-cloud-id", + "required": false, + "type": "option", + }, ], "id": "config add", }, @@ -92,7 +100,7 @@ The file holds API keys and decides where every later command goes, for every pr "type": "option", }, { - "description": "A project of the config file. Cannot be combined with --project-url. Required when the file has several projects.", + "description": "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 or --id. Required when the file has several projects.", "helpValue": "", "name": "project", "required": false, @@ -198,11 +206,158 @@ The file holds API keys and decides where every later command goes, for every pr ], "id": "config remove", }, + { + "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": "Project URL, the Meilisearch instance to talk to.", + "helpValue": "", + "name": "project-url", + "required": false, + "type": "option", + }, + { + "description": "Project API key. \`-\` reads it from stdin.", + "helpValue": "", + "name": "project-api-key", + "required": false, + "type": "option", + }, + { + "description": "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 or --id. Required when the file has several projects.", + "helpValue": "", + "name": "project", + "required": false, + "type": "option", + }, + { + "description": "Management API personal access token.", + "helpValue": "", + "name": "management-token", + "required": false, + "type": "option", + }, + { + "description": "Management API base URL. Default https://api.meilisearch.com.", + "helpValue": "", + "name": "management-url", + "required": false, + "type": "option", + }, + { + "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.", + "helpValue": "", + "name": "output", + "options": [ + "json", + "ndjson", + ], + "required": false, + "type": "option", + }, + { + "description": "Print every HTTP request and response on stderr, secrets redacted.", + "name": "debug", + "required": false, + "type": "boolean", + }, + { + "description": "Only the projects of this team; missing entries are then not reported.", + "helpValue": "", + "name": "team-id", + "required": false, + "type": "option", + }, + ], + "id": "config sync-with-cloud", + }, + { + "description": "Change the URL, the key or the cloudId of a project of 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.", + "examples": [ + "meili config update --project prod --project-cloud-id 343", + "meili config update --project prod --project-api-key - < key.txt", + "meili config update --project prod --project-url https://ms-abc.sfo.meilisearch.io --output json", + ], + "flags": [ + { + "description": "New URL of the project.", + "helpValue": "", + "name": "project-url", + "required": false, + "type": "option", + }, + { + "description": "Project API key. \`-\` reads it from stdin.", + "helpValue": "", + "name": "project-api-key", + "required": false, + "type": "option", + }, + { + "description": "Name of the project to change.", + "helpValue": "", + "name": "project", + "required": true, + "type": "option", + }, + { + "description": "Management API personal access token.", + "helpValue": "", + "name": "management-token", + "required": false, + "type": "option", + }, + { + "description": "Management API base URL. Default https://api.meilisearch.com.", + "helpValue": "", + "name": "management-url", + "required": false, + "type": "option", + }, + { + "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.", + "helpValue": "", + "name": "output", + "options": [ + "json", + "ndjson", + ], + "required": false, + "type": "option", + }, + { + "description": "Print every HTTP request and response on stderr, secrets redacted.", + "name": "debug", + "required": false, + "type": "boolean", + }, + { + "description": "Id of the project in Meilisearch Cloud, for the management commands.", + "helpValue": "", + "name": "project-cloud-id", + "required": false, + "type": "option", + }, + ], + "id": "config update", + }, { "description": "Create a Meilisearch Cloud project in a team, on a resource.", "examples": [ "meili create projects --name demo --team-id 12 --resource-id 3", "meili create projects --name demo --team-id 12 --resource-id 3 --output json", + "meili create projects --name demo --team-id 12 --resource-id 3 --add-to-config", "meili create projects --name demo --team-id 12 --resource-id 3 --ms-version v1.54.0", "meili create projects --data '{"name":"demo","teamId":12,"resourceId":3}'", "meili create projects --file project.json --name other-name", @@ -223,7 +378,7 @@ The file holds API keys and decides where every later command goes, for every pr "type": "option", }, { - "description": "A project of the config file. Cannot be combined with --project-url. Required when the file has several projects.", + "description": "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 or --id. Required when the file has several projects.", "helpValue": "", "name": "project", "required": false, @@ -289,6 +444,12 @@ The file holds API keys and decides where every later command goes, for every pr "required": false, "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", + "required": false, + "type": "boolean", + }, { "description": "Read the body from a file. \`-\` reads from stdin.", "exclusive": [ @@ -314,6 +475,7 @@ The file holds API keys and decides where every later command goes, for every pr "examples": [ "meili delete projects --id 343", "meili delete projects --id 343 --output json", + "meili delete projects --project staging", ], "flags": [ { @@ -331,7 +493,7 @@ The file holds API keys and decides where every later command goes, for every pr "type": "option", }, { - "description": "A project of the config file. Cannot be combined with --project-url. Required when the file has several projects.", + "description": "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 or --id. Required when the file has several projects.", "helpValue": "", "name": "project", "required": false, @@ -370,10 +532,10 @@ The file holds API keys and decides where every later command goes, for every pr "type": "boolean", }, { - "description": "The project to delete.", + "description": "The project to delete, or give --project and its cloudId from the config file.", "helpValue": "", "name": "id", - "required": true, + "required": false, "type": "option", }, ], @@ -402,7 +564,7 @@ The file holds API keys and decides where every later command goes, for every pr "type": "option", }, { - "description": "A project of the config file. Cannot be combined with --project-url. Required when the file has several projects.", + "description": "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 or --id. Required when the file has several projects.", "helpValue": "", "name": "project", "required": false, @@ -468,7 +630,7 @@ The file holds API keys and decides where every later command goes, for every pr "type": "option", }, { - "description": "A project of the config file. Cannot be combined with --project-url. Required when the file has several projects.", + "description": "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 or --id. Required when the file has several projects.", "helpValue": "", "name": "project", "required": false, @@ -559,7 +721,7 @@ The file holds API keys and decides where every later command goes, for every pr "type": "option", }, { - "description": "A project of the config file. Cannot be combined with --project-url. Required when the file has several projects.", + "description": "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 or --id. Required when the file has several projects.", "helpValue": "", "name": "project", "required": false, @@ -605,6 +767,7 @@ The file holds API keys and decides where every later command goes, for every pr "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": [ @@ -623,7 +786,7 @@ The file holds API keys and decides where every later command goes, for every pr "type": "option", }, { - "description": "A project of the config file. Cannot be combined with --project-url. Required when the file has several projects.", + "description": "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 or --id. Required when the file has several projects.", "helpValue": "", "name": "project", "required": false, @@ -662,10 +825,10 @@ The file holds API keys and decides where every later command goes, for every pr "type": "boolean", }, { - "description": "The project to update.", + "description": "The project to update, or give --project and its cloudId from the config file.", "helpValue": "", "name": "id", - "required": true, + "required": false, "type": "option", }, { diff --git a/test/contract/api.test.ts b/test/contract/api.test.ts index 2f57c05..cdbee1e 100644 --- a/test/contract/api.test.ts +++ b/test/contract/api.test.ts @@ -20,6 +20,7 @@ import { type OpenApiDocument, parseRoute, pathFlagsForRoute, + projectFlagForRoute, ROUTE_EXCEPTIONS, routeKey, } from "../../src/lib/api-naming.js"; @@ -68,9 +69,28 @@ const commands = await Promise.all( const globalFlags = Object.keys(BaseCommand.baseFlags); const VERBS = ["get", "create", "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]; @@ -80,10 +100,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( @@ -100,15 +116,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"), ); } @@ -140,18 +162,23 @@ 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 } 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(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/helpers/management-handlers.ts b/test/helpers/management-handlers.ts index 3ebda86..163bdff 100644 --- a/test/helpers/management-handlers.ts +++ b/test/helpers/management-handlers.ts @@ -32,20 +32,28 @@ export const project: Project = { resource: { id: 3, name: "build", plan: "build", regionId: 1 }, }; -/** What a handler saw: the `Authorization` header and the parsed body. */ +/** 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 and records it with the `Authorization` header. */ +/** 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 = await request.json(); - received.push({ authorization: request.headers.get("authorization"), body }); + 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` answering `all`, as the API lists what the token sees. */ + listProjects: (all: Project[], received: ReceivedRequest[] = []) => + http.get(`${MANAGEMENT_URL}/api/v2/projects`, async ({ request }) => { + await record(received, request); + return HttpResponse.json(all); + }), + /** `POST /api/v2/projects` answering 201 with `project` carrying the attributes received. */ createProject: (received: ReceivedRequest[] = []) => http.post(`${MANAGEMENT_URL}/api/v2/projects`, async ({ request }) => { diff --git a/test/helpers/run-command.ts b/test/helpers/run-command.ts index f220142..cbe6d47 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)); @@ -63,6 +64,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 c6c16d6..6cee499 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, @@ -178,6 +179,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 +218,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", ]); diff --git a/test/lib/config.test.ts b/test/lib/config.test.ts index 64ee7de..1038331 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"; @@ -11,13 +11,14 @@ import { readConfigFile, redactBody, redactHeaders, + resolveCloudProjectId, resolveManagementCredentials, resolveProjectConnection, writeConfigFile, } from "../../src/lib/config.js"; import type { MeiliCliError } from "../../src/lib/errors.js"; import { caught } from "../helpers/errors.js"; -import { scrubbedHome } from "../helpers/run-command.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 => { @@ -74,12 +75,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 }); @@ -95,7 +105,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 `.", + ); }); });