diff --git a/.changeset/first-version.md b/.changeset/first-version.md index 6eaa788..7dedb68 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. - Documents: `meili get documents --index `, narrowed by `--ids` or `--filter`; `meili add documents --index --file ` and `meili update documents --index --file `, which read the documents from a file, from stdin with `--file -` or from `--data`; `meili delete documents --index (--ids | --filter | --all) --yes`. - Indexes: `meili get indexes [--id ]`; `meili add indexes --id [--primary-key ]`, `meili update indexes --id [--primary-key ] [--new-id ]`, which renames the index, and `meili delete indexes --id --yes`. - Tasks: `meili get tasks`, narrowed by `--ids`, `--statuses`, `--types`, `--index-ids`, `--batch-ids`, `--canceled-by` and the date filters of the route, paging with `--limit` and `--from` and following the `next` cursor with `--all`; `meili cancel tasks` and `meili delete tasks --yes`, which take the same filters and answer a task. +- API keys: `meili get keys [--id ]`, which lists the keys of the instance or gets one by its uid or by its key value; `meili add keys --actions --indexes --expires-at `, where `--expires-at null` creates a key that never expires; `meili update keys --id `, which changes the `--name` and the `--description` the route takes, `null` removing one; `meili delete keys --id --yes`, which answers nothing, the route returning 204. The `key` field reaches stdout as the API returns it: a credential an API answers is data, and the CLI hides nothing from the agent it works for. - Search: `meili search --index --q `, on `POST /indexes/{index_uid}/search`, with every non-experimental search parameter of the route as a flag, from `--filter` and `--sort` to `--hybrid` and `--vector`; `--data` or `--file` carries a whole search body, which a flag of the same field overrides. `meili multi-search --queries ''`, on `POST /multi-search`, runs several queries in one request, each naming its index, answered side by side or merged into one ranked list with `--federation`. - Similar documents: `meili similar --index --id --embedder `, on `POST /indexes/{index_uid}/similar`, returns the documents of the index closest to one of them, narrowed by `--filter`, `--limit` and `--offset`; `--data` or `--file` carries a whole body, which a flag of the same field overrides. - Facet values: `meili facet-search --index --facet-name `, on `POST /indexes/{index_uid}/facet-search`, searches the values of one filterable attribute with `--facet-query` and counts them on the documents `--q` and `--filter` keep; `--data` or `--file` carries a whole body, which a flag of the same field overrides. diff --git a/CLAUDE.md b/CLAUDE.md index 5bf85f6..55d8a11 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,7 +22,7 @@ Package: `@meilisearch/cli` on npm. Binary: `meili`. 5. Never open `$EDITOR`. Every input comes from a flag, an environment variable, a file (`--file`), stdin (`--file -`) or the config file of `docs/contract/config.md`, from which nothing is deduced. 6. Command and flag names derive from the API routes by the rules of `docs/contract/commands.md`: verb first, resource plural, `meili get indexes --id movies`. The resource's own identifier is `--id`, or `--ids` when the list route filters by identifiers; a parent in the path is a flag named after its collection, `--index`; a query or body parameter keeps its API name in kebab-case, `primaryKey` is `--primary-key`. No positional argument, and no synonym beyond the aliases the contract lists. 7. Errors are structured. Use `MeiliCliError` from `src/lib/errors.ts` with a code and an exit code from the table in `docs/contract/output.md`. Never `throw new Error("...")`. -8. Secrets never appear in output, logs or debug traces. Use the redaction helpers in `src/lib/config.ts`. +8. The credentials the CLI holds to reach an API never appear in output, logs or debug traces. Use the redaction helpers in `src/lib/config.ts`. A credential an API returns is data: it goes to stdout unchanged, like the `key` of `get keys`. See the Secrets section of `docs/contract/config.md`. 9. No new dependency without a one line justification in the pull request. Prefer the standard library. 10. One command per pull request, or the commands of one resource when they ship together and complete: the `get`, `add`, `update` and `delete` of `documents` are one change. One file per command, covering every route of its verb and resource. Every command has a snapshot test. 11. Every command declares the API it uses with `static api = "meilisearch" | "management" | "none"`. `BaseCommand` resolves credentials and builds the client. Never resolve credentials or build a client in a command. diff --git a/docs/contract/commands.md b/docs/contract/commands.md index 382a921..de3eb09 100644 --- a/docs/contract/commands.md +++ b/docs/contract/commands.md @@ -56,7 +56,10 @@ Every API command declares its routes, `static routes = ["GET /indexes", "GET /i | `GET /tasks` | `meili get tasks [--ids ] [--statuses ]` | | `POST /tasks/cancel` | `meili cancel tasks --ids ` | | `DELETE /tasks` | `meili delete tasks --ids --yes` | -| `GET /keys`, `GET /keys/{key}` | `meili get keys [--id ]` | +| `GET /keys`, `GET /keys/{key}` | `meili get keys [--id ]` | +| `POST /keys` | `meili add keys --actions --indexes --expires-at ` | +| `PATCH /keys/{key}` | `meili update keys --id [--name ] [--description ]` | +| `DELETE /keys/{key}` | `meili delete keys --id --yes` | | `POST /dumps` | `meili add dumps` | | `GET /api/v2/projects`, `GET /api/v2/projects/{id}` | `meili get projects [--id ] [--team-id ]` | | `POST /api/v2/projects` | `meili add projects --name --team-id --resource-id ` | diff --git a/docs/contract/config.md b/docs/contract/config.md index 0de4d4b..99f326a 100644 --- a/docs/contract/config.md +++ b/docs/contract/config.md @@ -127,6 +127,7 @@ A variable that doubles a flag takes its name from the flag: `MEILI_` followed b ## Secrets -- API keys and tokens never appear on stdout, on stderr, in `--debug` traces, in error messages or in snapshots. When a secret must be shown at all, only its last four characters are: `****1a2b`. `meili config list` masks keys the same way. +- The credentials the CLI holds to reach an API, the keys of the config file and the tokens of the credentials file, never appear on stdout, on stderr, in `--debug` traces, in error messages or in snapshots. When one must be shown at all, only its last four characters are: `****1a2b`. `meili config list` masks keys the same way. +- A credential an API *returns* is data, not one of those: the `keys` commands print the `key` field as the API answers it, like every field of every other response (`output.md`, Pass-through). The CLI hides nothing from the agent it works for; redaction applies to the connection the CLI was given, and to stderr, `--debug` traces and logs, never to the data stream. - `--debug` redacts the `Authorization`, `Cookie` and `Set-Cookie` headers, a session cookie of the authorization server being a credential like any other, and every field named `apiKey`, `apikey`, `key`, `token`, `access_token`, `refresh_token` in request and response bodies. `apikey` is the Management API's spelling of a project's master key, `access_token` and `refresh_token` the spelling of the authorization server. A form-encoded body, which is what the token endpoint takes, is never printed. The redaction lives in `src/lib/config.ts` and is tested. - The CLI reads keys from the config file, and from nowhere else on disk, and warns when the file is readable by others. Passing a key in a flag is allowed and documented as visible in the process list and in the shell history; `--project-api-key -` reads it from stdin instead. diff --git a/oclif.manifest.json b/oclif.manifest.json index eed70e6..3900d89 100644 --- a/oclif.manifest.json +++ b/oclif.manifest.json @@ -497,6 +497,263 @@ "indexes.js" ] }, + "add:keys": { + "aliases": [], + "args": {}, + "description": "Create an API key that runs --actions on --indexes until --expires-at.\n\nThe answer carries the `key` field, the credential itself, on stdout like any other data.", + "examples": [ + "<%= config.bin %> <%= command.id %> --actions search --indexes movies --expires-at null", + "<%= config.bin %> <%= command.id %> --actions search --indexes '*' --expires-at 2027-01-01T00:00:00Z --output json", + "<%= config.bin %> <%= command.id %> --actions documents.add,documents.delete --indexes movies --expires-at null --name 'CI indexer'", + "<%= config.bin %> <%= command.id %> --data '{\"actions\":[\"search\"],\"indexes\":[\"movies\"],\"expiresAt\":null}'" + ], + "flags": { + "output": { + "char": "o", + "description": "json (default): one document, the API body as it is, indented in a terminal, compact when piped. ndjson: one item per line, list commands only, streams with --all.", + "name": "output", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "options": [ + "json", + "ndjson" + ], + "type": "option" + }, + "debug": { + "description": "Print every HTTP request and response on stderr, secrets redacted.", + "name": "debug", + "allowNo": false, + "type": "boolean" + }, + "project-url": { + "description": "Project URL, the Meilisearch instance to talk to.", + "name": "project-url", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "project-api-key": { + "description": "Project API key. `-` reads it from stdin.", + "name": "project-api-key", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "project": { + "description": "A project of the config file. Cannot be combined with --project-url. Required when the file has several projects.", + "name": "project", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "id": { + "aliases": [ + "uid" + ], + "description": "Uid of the key, a uuid v4. Without it, Meilisearch generates one.", + "name": "id", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "actions": { + "description": "Actions the key permits, comma-separated, for example `search` or `documents.*`. `*` permits them all.", + "name": "actions", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "indexes": { + "description": "Indexes the key reaches, comma-separated. `*` reaches them all, and `products_*` every index of that prefix.", + "name": "indexes", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "expires-at": { + "description": "Expiration date in RFC 3339 format. The value null creates a key that never expires.", + "name": "expires-at", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "name": { + "description": "Name of the key, for a human reading the list.", + "name": "name", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "description": { + "description": "Description of the key, for a human reading the list.", + "name": "description", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "file": { + "description": "Read the body from a file. `-` reads from stdin.", + "exclusive": [ + "data" + ], + "name": "file", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "data": { + "description": "Inline JSON body.", + "name": "data", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [], + "id": "add:keys", + "pluginAlias": "@meilisearch/cli", + "pluginName": "@meilisearch/cli", + "pluginType": "core", + "strict": true, + "enableJsonFlag": false, + "api": "meilisearch", + "routes": [ + "POST /keys" + ], + "keyActions": [ + "keys.create" + ], + "connectionFlags": { + "project-url": { + "description": "Project URL, the Meilisearch instance to talk to.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "project-api-key": { + "description": "Project API key. `-` reads it from stdin.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "project": { + "description": "A project of the config file. Cannot be combined with --project-url. Required when the file has several projects.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "waitFlags": { + "wait": { + "description": "Poll the task until it reaches a final status, then print the final task.", + "allowNo": false, + "type": "boolean" + }, + "timeout": { + "description": "Maximum wait time, for example 30s, 2m. Default 60s. Only with --wait.", + "helpValue": "", + "dependsOn": [ + "wait" + ], + "input": [], + "multiple": false, + "type": "option" + } + }, + "paginationFlags": { + "limit": { + "description": "Passed to the API.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "offset": { + "description": "Passed to the API.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "all": { + "description": "Fetch every page. Streams with ndjson, buffers into one array with json.", + "allowNo": false, + "type": "boolean" + } + }, + "fromPaginationFlags": { + "limit": { + "description": "Passed to the API.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "from": { + "description": "Passed to the API: the uid the page starts at, going down.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "all": { + "description": "Fetch every page. Streams with ndjson, buffers into one array with json.", + "allowNo": false, + "type": "boolean" + } + }, + "bodyInputFlags": { + "file": { + "description": "Read the body from a file. `-` reads from stdin.", + "helpValue": "", + "exclusive": [ + "data" + ], + "input": [], + "multiple": false, + "type": "option" + }, + "data": { + "description": "Inline JSON body.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "confirmFlags": { + "yes": { + "required": true, + "description": "Confirm the deletion. Nothing is sent to the API without it.", + "allowNo": false, + "type": "boolean" + } + }, + "isESM": true, + "relativePath": [ + "dist", + "commands", + "add", + "keys.js" + ] + }, "auth:login": { "aliases": [], "args": {}, @@ -2482,14 +2739,13 @@ "indexes.js" ] }, - "delete:settings": { + "delete:keys": { "aliases": [], "args": {}, - "description": "Reset every setting of an index to its default value, which reindexes the documents.", + "description": "Delete an API key, which stops authenticating every request that carries it.", "examples": [ - "<%= config.bin %> <%= command.id %> --index movies --yes", - "<%= config.bin %> <%= command.id %> --index movies --yes --output json", - "<%= config.bin %> <%= command.id %> --index movies --yes --wait" + "<%= config.bin %> <%= command.id %> --id 52787ccd-48ff-48e5-9c4b-03cd76978a60 --yes", + "<%= config.bin %> <%= command.id %> --id 52787ccd-48ff-48e5-9c4b-03cd76978a60 --yes --output json" ], "flags": { "output": { @@ -2535,29 +2791,231 @@ "multiple": false, "type": "option" }, - "index": { - "description": "The index to reset the settings of. `update settings -- null` resets one of them.", - "name": "index", + "id": { + "description": "The key to delete, by its uid or by its key value.", + "name": "id", "required": true, "hasDynamicHelp": false, - "helpValue": "", + "helpValue": "", "multiple": false, "type": "option" }, - "wait": { - "description": "Poll the task until it reaches a final status, then print the final task.", - "name": "wait", + "yes": { + "description": "Confirm the deletion. Nothing is sent to the API without it.", + "name": "yes", + "required": true, "allowNo": false, "type": "boolean" - }, - "timeout": { - "dependsOn": [ - "wait" - ], - "description": "Maximum wait time, for example 30s, 2m. Default 60s. Only with --wait.", - "name": "timeout", - "hasDynamicHelp": false, - "helpValue": "", + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [], + "id": "delete:keys", + "pluginAlias": "@meilisearch/cli", + "pluginName": "@meilisearch/cli", + "pluginType": "core", + "strict": true, + "enableJsonFlag": false, + "api": "meilisearch", + "routes": [ + "DELETE /keys/{key}" + ], + "keyActions": [ + "keys.delete" + ], + "connectionFlags": { + "project-url": { + "description": "Project URL, the Meilisearch instance to talk to.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "project-api-key": { + "description": "Project API key. `-` reads it from stdin.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "project": { + "description": "A project of the config file. Cannot be combined with --project-url. Required when the file has several projects.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "waitFlags": { + "wait": { + "description": "Poll the task until it reaches a final status, then print the final task.", + "allowNo": false, + "type": "boolean" + }, + "timeout": { + "description": "Maximum wait time, for example 30s, 2m. Default 60s. Only with --wait.", + "helpValue": "", + "dependsOn": [ + "wait" + ], + "input": [], + "multiple": false, + "type": "option" + } + }, + "paginationFlags": { + "limit": { + "description": "Passed to the API.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "offset": { + "description": "Passed to the API.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "all": { + "description": "Fetch every page. Streams with ndjson, buffers into one array with json.", + "allowNo": false, + "type": "boolean" + } + }, + "fromPaginationFlags": { + "limit": { + "description": "Passed to the API.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "from": { + "description": "Passed to the API: the uid the page starts at, going down.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "all": { + "description": "Fetch every page. Streams with ndjson, buffers into one array with json.", + "allowNo": false, + "type": "boolean" + } + }, + "bodyInputFlags": { + "file": { + "description": "Read the body from a file. `-` reads from stdin.", + "helpValue": "", + "exclusive": [ + "data" + ], + "input": [], + "multiple": false, + "type": "option" + }, + "data": { + "description": "Inline JSON body.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "confirmFlags": { + "yes": { + "required": true, + "description": "Confirm the deletion. Nothing is sent to the API without it.", + "allowNo": false, + "type": "boolean" + } + }, + "isESM": true, + "relativePath": [ + "dist", + "commands", + "delete", + "keys.js" + ] + }, + "delete:settings": { + "aliases": [], + "args": {}, + "description": "Reset every setting of an index to its default value, which reindexes the documents.", + "examples": [ + "<%= config.bin %> <%= command.id %> --index movies --yes", + "<%= config.bin %> <%= command.id %> --index movies --yes --output json", + "<%= config.bin %> <%= command.id %> --index movies --yes --wait" + ], + "flags": { + "output": { + "char": "o", + "description": "json (default): one document, the API body as it is, indented in a terminal, compact when piped. ndjson: one item per line, list commands only, streams with --all.", + "name": "output", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "options": [ + "json", + "ndjson" + ], + "type": "option" + }, + "debug": { + "description": "Print every HTTP request and response on stderr, secrets redacted.", + "name": "debug", + "allowNo": false, + "type": "boolean" + }, + "project-url": { + "description": "Project URL, the Meilisearch instance to talk to.", + "name": "project-url", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "project-api-key": { + "description": "Project API key. `-` reads it from stdin.", + "name": "project-api-key", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "project": { + "description": "A project of the config file. Cannot be combined with --project-url. Required when the file has several projects.", + "name": "project", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "index": { + "description": "The index to reset the settings of. `update settings -- null` resets one of them.", + "name": "index", + "required": true, + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "wait": { + "description": "Poll the task until it reaches a final status, then print the final task.", + "name": "wait", + "allowNo": false, + "type": "boolean" + }, + "timeout": { + "dependsOn": [ + "wait" + ], + "description": "Maximum wait time, for example 30s, 2m. Default 60s. Only with --wait.", + "name": "timeout", + "hasDynamicHelp": false, + "helpValue": "", "multiple": false, "type": "option" }, @@ -3642,22 +4100,238 @@ "description": "Project API key. `-` reads it from stdin.", "name": "project-api-key", "hasDynamicHelp": false, - "helpValue": "", + "helpValue": "", + "multiple": false, + "type": "option" + }, + "project": { + "description": "A project of the config file. Cannot be combined with --project-url. Required when the file has several projects.", + "name": "project", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [], + "id": "get:health", + "pluginAlias": "@meilisearch/cli", + "pluginName": "@meilisearch/cli", + "pluginType": "core", + "strict": true, + "enableJsonFlag": false, + "api": "meilisearch", + "routes": [ + "GET /health" + ], + "keyActions": [], + "connectionFlags": { + "project-url": { + "description": "Project URL, the Meilisearch instance to talk to.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "project-api-key": { + "description": "Project API key. `-` reads it from stdin.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "project": { + "description": "A project of the config file. Cannot be combined with --project-url. Required when the file has several projects.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "waitFlags": { + "wait": { + "description": "Poll the task until it reaches a final status, then print the final task.", + "allowNo": false, + "type": "boolean" + }, + "timeout": { + "description": "Maximum wait time, for example 30s, 2m. Default 60s. Only with --wait.", + "helpValue": "", + "dependsOn": [ + "wait" + ], + "input": [], + "multiple": false, + "type": "option" + } + }, + "paginationFlags": { + "limit": { + "description": "Passed to the API.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "offset": { + "description": "Passed to the API.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "all": { + "description": "Fetch every page. Streams with ndjson, buffers into one array with json.", + "allowNo": false, + "type": "boolean" + } + }, + "fromPaginationFlags": { + "limit": { + "description": "Passed to the API.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "from": { + "description": "Passed to the API: the uid the page starts at, going down.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "all": { + "description": "Fetch every page. Streams with ndjson, buffers into one array with json.", + "allowNo": false, + "type": "boolean" + } + }, + "bodyInputFlags": { + "file": { + "description": "Read the body from a file. `-` reads from stdin.", + "helpValue": "", + "exclusive": [ + "data" + ], + "input": [], + "multiple": false, + "type": "option" + }, + "data": { + "description": "Inline JSON body.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "confirmFlags": { + "yes": { + "required": true, + "description": "Confirm the deletion. Nothing is sent to the API without it.", + "allowNo": false, + "type": "boolean" + } + }, + "isESM": true, + "relativePath": [ + "dist", + "commands", + "get", + "health.js" + ] + }, + "get:indexes": { + "aliases": [], + "args": {}, + "description": "List the indexes of the instance, or get one with --id.", + "examples": [ + "<%= config.bin %> <%= command.id %>", + "<%= config.bin %> <%= command.id %> --output json", + "<%= config.bin %> <%= command.id %> --id movies", + "<%= config.bin %> <%= command.id %> --limit 5 --offset 10", + "<%= config.bin %> <%= command.id %> --all --output ndjson" + ], + "flags": { + "output": { + "char": "o", + "description": "json (default): one document, the API body as it is, indented in a terminal, compact when piped. ndjson: one item per line, list commands only, streams with --all.", + "name": "output", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "options": [ + "json", + "ndjson" + ], + "type": "option" + }, + "debug": { + "description": "Print every HTTP request and response on stderr, secrets redacted.", + "name": "debug", + "allowNo": false, + "type": "boolean" + }, + "project-url": { + "description": "Project URL, the Meilisearch instance to talk to.", + "name": "project-url", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "project-api-key": { + "description": "Project API key. `-` reads it from stdin.", + "name": "project-api-key", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "project": { + "description": "A project of the config file. Cannot be combined with --project-url. Required when the file has several projects.", + "name": "project", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "id": { + "description": "The index to get. Without it, the command lists the indexes.", + "name": "id", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "limit": { + "description": "Passed to the API.", + "name": "limit", + "hasDynamicHelp": false, + "helpValue": "", "multiple": false, "type": "option" }, - "project": { - "description": "A project of the config file. Cannot be combined with --project-url. Required when the file has several projects.", - "name": "project", + "offset": { + "description": "Passed to the API.", + "name": "offset", "hasDynamicHelp": false, - "helpValue": "", + "helpValue": "", "multiple": false, "type": "option" + }, + "all": { + "description": "Fetch every page. Streams with ndjson, buffers into one array with json.", + "name": "all", + "allowNo": false, + "type": "boolean" } }, "hasDynamicHelp": false, "hiddenAliases": [], - "id": "get:health", + "id": "get:indexes", "pluginAlias": "@meilisearch/cli", "pluginName": "@meilisearch/cli", "pluginType": "core", @@ -3665,9 +4339,12 @@ "enableJsonFlag": false, "api": "meilisearch", "routes": [ - "GET /health" + "GET /indexes", + "GET /indexes/{index_uid}" + ], + "keyActions": [ + "indexes.get" ], - "keyActions": [], "connectionFlags": { "project-url": { "description": "Project URL, the Meilisearch instance to talk to.", @@ -3782,17 +4459,17 @@ "dist", "commands", "get", - "health.js" + "indexes.js" ] }, - "get:indexes": { + "get:keys": { "aliases": [], "args": {}, - "description": "List the indexes of the instance, or get one with --id.", + "description": "List the API keys of the instance, or get one with --id.\n\nThe answer is the API body as it is, so it carries the `key` field of every key it returns: credentials that work, on stdout, like any other data.", "examples": [ "<%= config.bin %> <%= command.id %>", "<%= config.bin %> <%= command.id %> --output json", - "<%= config.bin %> <%= command.id %> --id movies", + "<%= config.bin %> <%= command.id %> --id 52787ccd-48ff-48e5-9c4b-03cd76978a60", "<%= config.bin %> <%= command.id %> --limit 5 --offset 10", "<%= config.bin %> <%= command.id %> --all --output ndjson" ], @@ -3841,10 +4518,10 @@ "type": "option" }, "id": { - "description": "The index to get. Without it, the command lists the indexes.", + "description": "The key to get, by its uid or by its key value. Without it, the command lists the keys.", "name": "id", "hasDynamicHelp": false, - "helpValue": "", + "helpValue": "", "multiple": false, "type": "option" }, @@ -3873,7 +4550,7 @@ }, "hasDynamicHelp": false, "hiddenAliases": [], - "id": "get:indexes", + "id": "get:keys", "pluginAlias": "@meilisearch/cli", "pluginName": "@meilisearch/cli", "pluginType": "core", @@ -3881,11 +4558,11 @@ "enableJsonFlag": false, "api": "meilisearch", "routes": [ - "GET /indexes", - "GET /indexes/{index_uid}" + "GET /keys", + "GET /keys/{key}" ], "keyActions": [ - "indexes.get" + "keys.get" ], "connectionFlags": { "project-url": { @@ -4001,7 +4678,7 @@ "dist", "commands", "get", - "indexes.js" + "keys.js" ] }, "get:settings": { @@ -6531,6 +7208,236 @@ "indexes.js" ] }, + "update:keys": { + "aliases": [], + "args": {}, + "description": "Change the name or the description of an API key, the two fields the route takes, and reset one with the value null.", + "examples": [ + "<%= config.bin %> <%= command.id %> --id 52787ccd-48ff-48e5-9c4b-03cd76978a60 --name 'CI indexer'", + "<%= config.bin %> <%= command.id %> --id 52787ccd-48ff-48e5-9c4b-03cd76978a60 --description 'Indexes the catalog nightly' --output json", + "<%= config.bin %> <%= command.id %> --id 52787ccd-48ff-48e5-9c4b-03cd76978a60 --description null" + ], + "flags": { + "output": { + "char": "o", + "description": "json (default): one document, the API body as it is, indented in a terminal, compact when piped. ndjson: one item per line, list commands only, streams with --all.", + "name": "output", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "options": [ + "json", + "ndjson" + ], + "type": "option" + }, + "debug": { + "description": "Print every HTTP request and response on stderr, secrets redacted.", + "name": "debug", + "allowNo": false, + "type": "boolean" + }, + "project-url": { + "description": "Project URL, the Meilisearch instance to talk to.", + "name": "project-url", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "project-api-key": { + "description": "Project API key. `-` reads it from stdin.", + "name": "project-api-key", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "project": { + "description": "A project of the config file. Cannot be combined with --project-url. Required when the file has several projects.", + "name": "project", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "id": { + "description": "The key to update, by its uid or by its key value.", + "name": "id", + "required": true, + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "name": { + "description": "New name of the key. The value null removes the one it has.", + "name": "name", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "description": { + "description": "New description of the key. The value null removes the one it has.", + "name": "description", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "file": { + "description": "Read the body from a file. `-` reads from stdin.", + "exclusive": [ + "data" + ], + "name": "file", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "data": { + "description": "Inline JSON body.", + "name": "data", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [], + "id": "update:keys", + "pluginAlias": "@meilisearch/cli", + "pluginName": "@meilisearch/cli", + "pluginType": "core", + "strict": true, + "enableJsonFlag": false, + "api": "meilisearch", + "routes": [ + "PATCH /keys/{key}" + ], + "keyActions": [ + "keys.update" + ], + "connectionFlags": { + "project-url": { + "description": "Project URL, the Meilisearch instance to talk to.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "project-api-key": { + "description": "Project API key. `-` reads it from stdin.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "project": { + "description": "A project of the config file. Cannot be combined with --project-url. Required when the file has several projects.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "waitFlags": { + "wait": { + "description": "Poll the task until it reaches a final status, then print the final task.", + "allowNo": false, + "type": "boolean" + }, + "timeout": { + "description": "Maximum wait time, for example 30s, 2m. Default 60s. Only with --wait.", + "helpValue": "", + "dependsOn": [ + "wait" + ], + "input": [], + "multiple": false, + "type": "option" + } + }, + "paginationFlags": { + "limit": { + "description": "Passed to the API.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "offset": { + "description": "Passed to the API.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "all": { + "description": "Fetch every page. Streams with ndjson, buffers into one array with json.", + "allowNo": false, + "type": "boolean" + } + }, + "fromPaginationFlags": { + "limit": { + "description": "Passed to the API.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "from": { + "description": "Passed to the API: the uid the page starts at, going down.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "all": { + "description": "Fetch every page. Streams with ndjson, buffers into one array with json.", + "allowNo": false, + "type": "boolean" + } + }, + "bodyInputFlags": { + "file": { + "description": "Read the body from a file. `-` reads from stdin.", + "helpValue": "", + "exclusive": [ + "data" + ], + "input": [], + "multiple": false, + "type": "option" + }, + "data": { + "description": "Inline JSON body.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "confirmFlags": { + "yes": { + "required": true, + "description": "Confirm the deletion. Nothing is sent to the API without it.", + "allowNo": false, + "type": "boolean" + } + }, + "isESM": true, + "relativePath": [ + "dist", + "commands", + "update", + "keys.js" + ] + }, "update:settings": { "aliases": [], "args": {}, diff --git a/src/commands/add/keys.ts b/src/commands/add/keys.ts new file mode 100644 index 0000000..74d5556 --- /dev/null +++ b/src/commands/add/keys.ts @@ -0,0 +1,78 @@ +import { Flags } from "@oclif/core"; +import type { KeyCreation } from "meilisearch"; +import { type Api, BaseCommand } from "../../lib/base-command.js"; +import { readJsonObjectBody } from "../../lib/input.js"; +import { asList, asText } from "../../lib/values.js"; + +export default class AddKeys extends BaseCommand { + static api: Api = "meilisearch"; + + static routes = ["POST /keys"]; + + static keyActions = ["keys.create"]; + + static override description = + `Create an API key that runs --actions on --indexes until --expires-at. + +The answer carries the \`key\` field, the credential itself, on stdout like any other data.`; + + static override examples = [ + "<%= config.bin %> <%= command.id %> --actions search --indexes movies --expires-at null", + "<%= config.bin %> <%= command.id %> --actions search --indexes '*' --expires-at 2027-01-01T00:00:00Z --output json", + "<%= config.bin %> <%= command.id %> --actions documents.add,documents.delete --indexes movies --expires-at null --name 'CI indexer'", + '<%= config.bin %> <%= command.id %> --data \'{"actions":["search"],"indexes":["movies"],"expiresAt":null}\'', + ]; + + static override flags = { + ...BaseCommand.connectionFlags, + id: Flags.string({ + aliases: ["uid"], + description: "Uid of the key, a uuid v4. Without it, Meilisearch generates one.", + helpValue: "", + }), + actions: Flags.string({ + description: + "Actions the key permits, comma-separated, for example `search` or `documents.*`. `*` permits them all.", + helpValue: "", + }), + indexes: Flags.string({ + description: + "Indexes the key reaches, comma-separated. `*` reaches them all, and `products_*` every index of that prefix.", + helpValue: "", + }), + "expires-at": Flags.string({ + description: + "Expiration date in RFC 3339 format. The value null creates a key that never expires.", + helpValue: "", + }), + name: Flags.string({ + description: "Name of the key, for a human reading the list.", + helpValue: "", + }), + description: Flags.string({ + description: "Description of the key, for a human reading the list.", + helpValue: "", + }), + ...BaseCommand.bodyInputFlags, + }; + + async run(): Promise { + const { flags } = await this.parse(AddKeys); + const fromFlags: Record = { + uid: flags.id, + actions: asList(flags.actions), + indexes: asList(flags.indexes), + expiresAt: asText(flags["expires-at"]), + name: asText(flags.name), + description: asText(flags.description), + }; + // The body of --file or --data first, then the flags, which override a field of it (rule 8). + // The API refuses a body missing actions, indexes or expiresAt, with its own code: the CLI + // judges no value it can send. + const key = { + ...readJsonObjectBody(flags), + ...Object.fromEntries(Object.entries(fromFlags).filter(([, value]) => value !== undefined)), + } as KeyCreation; + await this.output(await this.meilisearch.createKey(key)); + } +} diff --git a/src/commands/delete/keys.ts b/src/commands/delete/keys.ts new file mode 100644 index 0000000..f1eda8d --- /dev/null +++ b/src/commands/delete/keys.ts @@ -0,0 +1,35 @@ +import { Flags } from "@oclif/core"; +import { type Api, BaseCommand } from "../../lib/base-command.js"; + +export default class DeleteKeys extends BaseCommand { + static api: Api = "meilisearch"; + + static routes = ["DELETE /keys/{key}"]; + + static keyActions = ["keys.delete"]; + + static override description = + "Delete an API key, which stops authenticating every request that carries it."; + + static override examples = [ + "<%= config.bin %> <%= command.id %> --id 52787ccd-48ff-48e5-9c4b-03cd76978a60 --yes", + "<%= config.bin %> <%= command.id %> --id 52787ccd-48ff-48e5-9c4b-03cd76978a60 --yes --output json", + ]; + + static override flags = { + ...BaseCommand.connectionFlags, + id: Flags.string({ + required: true, + description: "The key to delete, by its uid or by its key value.", + helpValue: "", + }), + ...BaseCommand.confirmFlags, + }; + + async run(): Promise { + const { flags } = await this.parse(DeleteKeys); + // The route answers 204 with no body, so nothing goes to stdout + // (docs/contract/output.md, Streams). + await this.meilisearch.deleteKey(flags.id); + } +} diff --git a/src/commands/get/keys.ts b/src/commands/get/keys.ts new file mode 100644 index 0000000..46cd6e7 --- /dev/null +++ b/src/commands/get/keys.ts @@ -0,0 +1,48 @@ +import { Flags } from "@oclif/core"; +import { type Api, BaseCommand } from "../../lib/base-command.js"; +import { fetchKeys } from "../../lib/clients/meilisearch.js"; +import { nextOffsetQuery } from "../../lib/pagination.js"; + +export default class GetKeys extends BaseCommand { + static api: Api = "meilisearch"; + + static routes = ["GET /keys", "GET /keys/{key}"]; + + static keyActions = ["keys.get"]; + + static override description = `List the API keys of the instance, or get one with --id. + +The answer is the API body as it is, so it carries the \`key\` field of every key it returns: credentials that work, on stdout, like any other data.`; + + static override examples = [ + "<%= config.bin %> <%= command.id %>", + "<%= config.bin %> <%= command.id %> --output json", + "<%= config.bin %> <%= command.id %> --id 52787ccd-48ff-48e5-9c4b-03cd76978a60", + "<%= config.bin %> <%= command.id %> --limit 5 --offset 10", + "<%= config.bin %> <%= command.id %> --all --output ndjson", + ]; + + static override flags = { + ...BaseCommand.connectionFlags, + id: Flags.string({ + description: + "The key to get, by its uid or by its key value. Without it, the command lists the keys.", + helpValue: "", + }), + ...BaseCommand.paginationFlags, + }; + + async run(): Promise { + const { flags } = await this.parse(GetKeys); + if (flags.id !== undefined) { + await this.output(await this.meilisearch.getKey(flags.id)); + return; + } + await this.outputList( + (query) => fetchKeys(this.meilisearch, query), + { limit: flags.limit, offset: flags.offset }, + flags.all, + nextOffsetQuery, + ); + } +} diff --git a/src/commands/update/keys.ts b/src/commands/update/keys.ts new file mode 100644 index 0000000..e27c808 --- /dev/null +++ b/src/commands/update/keys.ts @@ -0,0 +1,53 @@ +import { Flags } from "@oclif/core"; +import type { KeyUpdate } from "meilisearch"; +import { type Api, BaseCommand } from "../../lib/base-command.js"; +import { readJsonObjectBody } from "../../lib/input.js"; +import { asText } from "../../lib/values.js"; + +export default class UpdateKeys extends BaseCommand { + static api: Api = "meilisearch"; + + static routes = ["PATCH /keys/{key}"]; + + static keyActions = ["keys.update"]; + + static override description = + "Change the name or the description of an API key, the two fields the route takes, and reset one with the value null."; + + static override examples = [ + "<%= config.bin %> <%= command.id %> --id 52787ccd-48ff-48e5-9c4b-03cd76978a60 --name 'CI indexer'", + "<%= config.bin %> <%= command.id %> --id 52787ccd-48ff-48e5-9c4b-03cd76978a60 --description 'Indexes the catalog nightly' --output json", + "<%= config.bin %> <%= command.id %> --id 52787ccd-48ff-48e5-9c4b-03cd76978a60 --description null", + ]; + + static override flags = { + ...BaseCommand.connectionFlags, + id: Flags.string({ + required: true, + description: "The key to update, by its uid or by its key value.", + helpValue: "", + }), + name: Flags.string({ + description: "New name of the key. The value null removes the one it has.", + helpValue: "", + }), + description: Flags.string({ + description: "New description of the key. The value null removes the one it has.", + helpValue: "", + }), + ...BaseCommand.bodyInputFlags, + }; + + async run(): Promise { + const { flags } = await this.parse(UpdateKeys); + const fromFlags: Record = { + name: asText(flags.name), + description: asText(flags.description), + }; + const update = { + ...readJsonObjectBody(flags), + ...Object.fromEntries(Object.entries(fromFlags).filter(([, value]) => value !== undefined)), + } as KeyUpdate; + await this.output(await this.meilisearch.updateKey(flags.id, update)); + } +} diff --git a/src/lib/clients/meilisearch.ts b/src/lib/clients/meilisearch.ts index ac02d94..9fdc7f7 100644 --- a/src/lib/clients/meilisearch.ts +++ b/src/lib/clients/meilisearch.ts @@ -49,3 +49,19 @@ export function fetchStats( params: query, }); } + +/** + * `GET /keys`, the list route of `get keys`. `getKeys` of meilisearch-js + * rewrites `createdAt` and `updatedAt` into `Date` objects, which JSON writes + * back with milliseconds where the API answered nanoseconds: the agent would + * read a value the API never sent, against the pass-through of + * docs/contract/output.md. ADR 0003 allows `client.httpRequest` until the SDK + * leaves the body alone, as `fetchStats` does. The other four routes of the + * resource return the body as it comes, so they call the SDK. + */ +export function fetchKeys( + client: Meilisearch, + query: { limit?: number | undefined; offset?: number | undefined }, +): Promise { + return client.httpRequest.get({ path: "keys", params: query }); +} diff --git a/test/commands/add/__snapshots__/keys.test.ts.snap b/test/commands/add/__snapshots__/keys.test.ts.snap new file mode 100644 index 0000000..93444af --- /dev/null +++ b/test/commands/add/__snapshots__/keys.test.ts.snap @@ -0,0 +1,74 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`meili add keys > passes an authentication error through with a hint and exits 3 1`] = ` +{ + "exitCode": 3, + "stderr": "{"error":{"code":"invalid_api_key","type":"auth","message":"The provided API key is invalid.","link":"https://docs.meilisearch.com/errors#invalid_api_key","hint":"Provide an API key with \`--project-api-key \` or \`MEILI_PROJECT_API_KEY\`."}} +", + "stdout": "", +} +`; + +exports[`meili add keys > prints its help without any network call 1`] = ` +"Create an API key that runs --actions on --indexes until --expires-at. + +USAGE + $ meili add keys [-o ] [--debug] [--project-url ] + [--project-api-key ] [--project ] [--id ] [--actions + ] [--indexes ] [--expires-at ] [--name ] + [--description ] [--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. + --actions= Actions the key permits, comma-separated, for + example \`search\` or \`documents.*\`. \`*\` permits + them all. + --data= Inline JSON body. + --debug Print every HTTP request and response on stderr, + secrets redacted. + --description= Description of the key, for a human reading the + list. + --expires-at= Expiration date in RFC 3339 format. The value + null creates a key that never expires. + --file= Read the body from a file. \`-\` reads from stdin. + --id= Uid of the key, a uuid v4. Without it, + Meilisearch generates one. + --indexes= Indexes the key reaches, comma-separated. \`*\` + reaches them all, and \`products_*\` every index of + that prefix. + --name= Name of the key, for a human reading the list. + --project= A project of the config file. Cannot be combined + with --project-url. 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. + +DESCRIPTION + Create an API key that runs --actions on --indexes until --expires-at. + + The answer carries the \`key\` field, the credential itself, on stdout like any + other data. + +EXAMPLES + $ meili add keys --actions search --indexes movies --expires-at null + + $ meili add keys --actions search --indexes '*' --expires-at 2027-01-01T00:00:00Z --output json + + $ meili add keys --actions documents.add,documents.delete --indexes movies --expires-at null --name 'CI indexer' + + $ meili add keys --data '{"actions":["search"],"indexes":["movies"],"expiresAt":null}' + +" +`; + +exports[`meili add keys > prints the key the API created, its key value included 1`] = ` +{ + "exitCode": 0, + "stderr": "", + "stdout": "{"name":"CI indexer","description":null,"key":"115f7b0806e6eb3409870fbfef319371dff43dd036746816358c1d71ad4e2221","actions":["documents.add"],"indexes":["movies"],"expiresAt":null,"createdAt":"2026-09-24T10:00:00.000000Z","updatedAt":"2026-09-24T10:00:00.000000Z","uid":"52787ccd-48ff-48e5-9c4b-03cd76978a60"} +", +} +`; diff --git a/test/commands/add/keys.test.ts b/test/commands/add/keys.test.ts new file mode 100644 index 0000000..1b352d5 --- /dev/null +++ b/test/commands/add/keys.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "vitest"; +import AddKeys from "../../../src/commands/add/keys.js"; +import { + invalidApiKeyError, + MEILISEARCH_URL, + makeKey, + meilisearchHandlers, +} from "../../helpers/meilisearch-handlers.js"; +import { countRequests, recordRequests, server, useMockServer } from "../../helpers/msw.js"; +import { runCli, runCommand } from "../../helpers/run-command.js"; + +useMockServer(); + +const env = { MEILI_PROJECT_URL: MEILISEARCH_URL }; +const created = makeKey({ + uid: "52787ccd-48ff-48e5-9c4b-03cd76978a60", + name: "CI indexer", + description: null, + actions: ["documents.add"], + indexes: ["movies"], +}); + +/** The body the command sent to `POST /keys`. */ +async function sentBody(argv: string[]): Promise { + server.use(meilisearchHandlers.keyCreate(created)); + const requests = await recordRequests(async () => { + await runCommand(AddKeys, argv, { env }); + }); + expect(requests).toHaveLength(1); + expect(requests[0]?.method).toBe("POST"); + expect(requests[0]?.url.pathname).toBe("/keys"); + return JSON.parse(requests[0]?.body ?? ""); +} + +describe("meili add keys", () => { + it("prints its help without any network call", async () => { + const result = await runCli(["add", "keys", "--help"]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toMatchSnapshot(); + }); + + it("prints the key the API created, its key value included", async () => { + server.use(meilisearchHandlers.keyCreate(created)); + const result = await runCommand( + AddKeys, + [ + "--actions", + "documents.add", + "--indexes", + "movies", + "--expires-at", + "null", + "--output", + "json", + ], + { env }, + ); + expect(result).toMatchSnapshot(); + expect(result.stdout).toContain(created.key); + }); + + it("sends the actions and the indexes as arrays, and null as a JSON null", async () => { + expect( + await sentBody([ + "--actions", + "search,documents.add", + "--indexes", + "movies,*", + "--expires-at", + "null", + ]), + ).toEqual({ + actions: ["search", "documents.add"], + indexes: ["movies", "*"], + expiresAt: null, + }); + }); + + it("sends the uid of --id, the name and the description", async () => { + expect( + await sentBody([ + "--id", + created.uid, + "--actions", + "search", + "--indexes", + "movies", + "--expires-at", + "2027-01-01T00:00:00Z", + "--name", + "CI indexer", + "--description", + "Indexes the catalog nightly", + ]), + ).toEqual({ + uid: created.uid, + actions: ["search"], + indexes: ["movies"], + expiresAt: "2027-01-01T00:00:00Z", + name: "CI indexer", + description: "Indexes the catalog nightly", + }); + }); + + it("accepts --uid, the API spelling of --id", async () => { + const body = await sentBody(["--uid", created.uid, "--actions", "search", "--indexes", "*"]); + expect(body).toMatchObject({ uid: created.uid }); + }); + + it("sends the fields of --data, and lets a flag override the one it carries", async () => { + expect( + await sentBody([ + "--data", + '{"actions":["*"],"indexes":["*"],"expiresAt":null}', + "--actions", + "search", + ]), + ).toEqual({ actions: ["search"], indexes: ["*"], expiresAt: null }); + }); + + it("refuses a body that is not a JSON object, before any network call", async () => { + let result: Awaited> | undefined; + const requests = await countRequests(async () => { + result = await runCommand(AddKeys, ["--data", "[1,2]"], { env }); + }); + expect(requests).toBe(0); + expect(result?.exitCode).toBe(2); + expect(JSON.parse(result?.stderr ?? "").error.code).toBe("cli_input_invalid"); + }); + + it("passes an authentication error through with a hint and exits 3", async () => { + server.use(meilisearchHandlers.apiError("/keys", 403, invalidApiKeyError)); + const result = await runCommand( + AddKeys, + ["--actions", "search", "--indexes", "*", "--expires-at", "null", "--output", "json"], + { env }, + ); + expect(result).toMatchSnapshot(); + }); +}); diff --git a/test/commands/delete/__snapshots__/keys.test.ts.snap b/test/commands/delete/__snapshots__/keys.test.ts.snap new file mode 100644 index 0000000..26f7d14 --- /dev/null +++ b/test/commands/delete/__snapshots__/keys.test.ts.snap @@ -0,0 +1,53 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`meili delete keys > passes an authentication error through with a hint and exits 3 1`] = ` +{ + "exitCode": 3, + "stderr": "{"error":{"code":"invalid_api_key","type":"auth","message":"The provided API key is invalid.","link":"https://docs.meilisearch.com/errors#invalid_api_key","hint":"Provide an API key with \`--project-api-key \` or \`MEILI_PROJECT_API_KEY\`."}} +", + "stdout": "", +} +`; + +exports[`meili delete keys > prints its help without any network call 1`] = ` +"Delete an API key, which stops authenticating every request that carries it. + +USAGE + $ meili delete keys --id --yes [-o ] [--debug] + [--project-url ] [--project-api-key ] [--project ] + +FLAGS + -o, --output= json (default): one document, the API body as it + is, indented in a terminal, compact when piped. + ndjson: one item per line, list commands only, + streams with --all. + --debug Print every HTTP request and response on stderr, + secrets redacted. + --id= (required) The key to delete, by its uid or by + its key value. + --project= A project of the config file. Cannot be combined + with --project-url. 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. + --yes (required) Confirm the deletion. Nothing is sent + to the API without it. + +DESCRIPTION + Delete an API key, which stops authenticating every request that carries it. + +EXAMPLES + $ meili delete keys --id 52787ccd-48ff-48e5-9c4b-03cd76978a60 --yes + + $ meili delete keys --id 52787ccd-48ff-48e5-9c4b-03cd76978a60 --yes --output json + +" +`; + +exports[`meili delete keys > writes nothing on stdout and exits 0, the route answering 204 1`] = ` +{ + "exitCode": 0, + "stderr": "", + "stdout": "", +} +`; diff --git a/test/commands/delete/keys.test.ts b/test/commands/delete/keys.test.ts new file mode 100644 index 0000000..f3d8188 --- /dev/null +++ b/test/commands/delete/keys.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import DeleteKeys from "../../../src/commands/delete/keys.js"; +import { + invalidApiKeyError, + MEILISEARCH_URL, + meilisearchHandlers, +} from "../../helpers/meilisearch-handlers.js"; +import { countRequests, recordRequests, server, useMockServer } from "../../helpers/msw.js"; +import { runCli, runCommand } from "../../helpers/run-command.js"; + +useMockServer(); + +const env = { MEILI_PROJECT_URL: MEILISEARCH_URL }; +const uid = "52787ccd-48ff-48e5-9c4b-03cd76978a60"; + +describe("meili delete keys", () => { + it("prints its help without any network call", async () => { + const result = await runCli(["delete", "keys", "--help"]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toMatchSnapshot(); + }); + + it("writes nothing on stdout and exits 0, the route answering 204", async () => { + server.use(meilisearchHandlers.keyDelete()); + const result = await runCommand(DeleteKeys, ["--id", uid, "--yes", "--output", "json"], { + env, + }); + expect(result).toMatchSnapshot(); + }); + + it("names the key in the path", async () => { + server.use(meilisearchHandlers.keyDelete()); + const requests = await recordRequests(async () => { + await runCommand(DeleteKeys, ["--id", uid, "--yes"], { env }); + }); + expect(requests).toHaveLength(1); + expect(requests[0]?.method).toBe("DELETE"); + expect(requests[0]?.url.pathname).toBe(`/keys/${uid}`); + }); + + it("refuses to run without --yes, before any network call", async () => { + let result: Awaited> | undefined; + const requests = await countRequests(async () => { + result = await runCommand(DeleteKeys, ["--id", uid], { env }); + }); + expect(requests).toBe(0); + expect(result?.stdout).toBe(""); + expect(result?.exitCode).toBe(2); + expect(JSON.parse(result?.stderr ?? "").error.code).toBe("cli_usage"); + }); + + it("passes an authentication error through with a hint and exits 3", async () => { + server.use(meilisearchHandlers.apiError(`/keys/${uid}`, 403, invalidApiKeyError)); + const result = await runCommand(DeleteKeys, ["--id", uid, "--yes", "--output", "json"], { + env, + }); + expect(result).toMatchSnapshot(); + }); +}); diff --git a/test/commands/get/__snapshots__/keys.test.ts.snap b/test/commands/get/__snapshots__/keys.test.ts.snap new file mode 100644 index 0000000..83ffd8d --- /dev/null +++ b/test/commands/get/__snapshots__/keys.test.ts.snap @@ -0,0 +1,84 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`meili get keys > gets one key by its uid through the unit route 1`] = ` +{ + "exitCode": 0, + "stderr": "", + "stdout": "{"name":"Default Search API Key","description":"Use it to search from the frontend","key":"115f7b0806e6eb3409870fbfef319371dff43dd036746816358c1d71ad4e2221","actions":["search"],"indexes":["*"],"expiresAt":null,"createdAt":"2026-09-24T10:00:00.000000Z","updatedAt":"2026-09-24T10:00:00.000000Z","uid":"52787ccd-48ff-48e5-9c4b-03cd76978a60"} +", +} +`; + +exports[`meili get keys > passes an authentication error through with a hint and exits 3 1`] = ` +{ + "exitCode": 3, + "stderr": "{"error":{"code":"invalid_api_key","type":"auth","message":"The provided API key is invalid.","link":"https://docs.meilisearch.com/errors#invalid_api_key","hint":"Provide an API key with \`--project-api-key \` or \`MEILI_PROJECT_API_KEY\`."}} +", + "stdout": "", +} +`; + +exports[`meili get keys > passes the 404 of an unknown key through and exits 4 1`] = ` +{ + "exitCode": 4, + "stderr": "{"error":{"code":"api_key_not_found","type":"invalid_request","message":"API key not found.","link":"https://docs.meilisearch.com/errors#api_key_not_found"}} +", + "stdout": "", +} +`; + +exports[`meili get keys > prints its help without any network call 1`] = ` +"List the API keys of the instance, or get one with --id. + +USAGE + $ meili get keys [-o ] [--debug] [--project-url ] + [--project-api-key ] [--project ] [--id ] [--limit ] + [--offset ] [--all] + +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. + --all Fetch every page. Streams with ndjson, buffers + into one array with json. + --debug Print every HTTP request and response on stderr, + secrets redacted. + --id= The key to get, by its uid or by its key value. + Without it, the command lists the keys. + --limit= Passed to the API. + --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-api-key= Project API key. \`-\` reads it from stdin. + --project-url= Project URL, the Meilisearch instance to talk to. + +DESCRIPTION + List the API keys of the instance, or get one with --id. + + The answer is the API body as it is, so it carries the \`key\` field of every + key it returns: credentials that work, on stdout, like any other data. + +EXAMPLES + $ meili get keys + + $ meili get keys --output json + + $ meili get keys --id 52787ccd-48ff-48e5-9c4b-03cd76978a60 + + $ meili get keys --limit 5 --offset 10 + + $ meili get keys --all --output ndjson + +" +`; + +exports[`meili get keys > prints the API envelope as it is, the key of every key included 1`] = ` +{ + "exitCode": 0, + "stderr": "", + "stdout": "{"results":[{"name":"Default Search API Key","description":"Use it to search from the frontend","key":"115f7b0806e6eb3409870fbfef319371dff43dd036746816358c1d71ad4e2221","actions":["search"],"indexes":["*"],"expiresAt":null,"createdAt":"2026-09-24T10:00:00.000000Z","updatedAt":"2026-09-24T10:00:00.000000Z","uid":"52787ccd-48ff-48e5-9c4b-03cd76978a60"},{"name":"Default Admin API Key","description":"Use it for anything that is not a search operation","key":"5dad55f9b953543783f152231fae2213ecc10c218498369b50e6925b1163c657","actions":["*"],"indexes":["*"],"expiresAt":null,"createdAt":"2026-09-24T10:00:00.000000Z","updatedAt":"2026-09-24T10:00:00.000000Z","uid":"89208a34-d4e0-4e00-80e0-f21039c312b8"}],"offset":0,"limit":20,"total":2} +", +} +`; diff --git a/test/commands/get/keys.test.ts b/test/commands/get/keys.test.ts new file mode 100644 index 0000000..9cbc089 --- /dev/null +++ b/test/commands/get/keys.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import GetKeys from "../../../src/commands/get/keys.js"; +import { + invalidApiKeyError, + MEILISEARCH_URL, + makeKey, + meilisearchHandlers, +} from "../../helpers/meilisearch-handlers.js"; +import { countRequests, recordRequests, server, useMockServer } from "../../helpers/msw.js"; +import { runCli, runCommand } from "../../helpers/run-command.js"; + +useMockServer(); + +const env = { MEILI_PROJECT_URL: MEILISEARCH_URL }; +const search = makeKey({ uid: "52787ccd-48ff-48e5-9c4b-03cd76978a60" }); +const admin = makeKey({ + uid: "89208a34-d4e0-4e00-80e0-f21039c312b8", + name: "Default Admin API Key", + description: "Use it for anything that is not a search operation", + key: "5dad55f9b953543783f152231fae2213ecc10c218498369b50e6925b1163c657", + actions: ["*"], +}); +const both = [search, admin]; + +describe("meili get keys", () => { + it("prints its help without any network call", async () => { + const result = await runCli(["get", "keys", "--help"]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toMatchSnapshot(); + }); + + it("prints the API envelope as it is, the key of every key included", async () => { + server.use(meilisearchHandlers.keys(both)); + const result = await runCommand(GetKeys, ["--output", "json"], { env }); + expect(result).toMatchSnapshot(); + expect(result.stdout).toContain(search.key); + }); + + it("gets one key by its uid through the unit route", async () => { + server.use(meilisearchHandlers.key(both)); + const result = await runCommand(GetKeys, ["--id", search.uid, "--output", "json"], { env }); + expect(result).toMatchSnapshot(); + }); + + it("gets one key by its key value, which the route takes too", async () => { + server.use(meilisearchHandlers.key(both)); + const requests = await recordRequests(async () => { + await runCommand(GetKeys, ["--id", admin.key], { env }); + }); + expect(requests).toHaveLength(1); + expect(requests[0]?.url.pathname).toBe(`/keys/${admin.key}`); + }); + + it("ignores the pagination flags when --id selects the unit route", async () => { + server.use(meilisearchHandlers.key(both)); + const requests = await recordRequests(async () => { + await runCommand(GetKeys, ["--id", search.uid, "--limit", "5"], { env }); + }); + expect(Object.fromEntries(requests[0]?.url.searchParams ?? [])).toEqual({}); + }); + + it("follows the pagination with --all and merges every page into one envelope", async () => { + server.use(meilisearchHandlers.keys(both)); + let result: Awaited> | undefined; + const requests = await countRequests(async () => { + result = await runCommand(GetKeys, ["--all", "--limit", "1", "--output", "json"], { env }); + }); + expect(requests).toBe(2); + expect(JSON.parse(result?.stdout ?? "")).toEqual({ results: both, total: 2 }); + }); + + it("passes an authentication error through with a hint and exits 3", async () => { + server.use(meilisearchHandlers.apiError("/keys", 403, invalidApiKeyError)); + const result = await runCommand(GetKeys, ["--output", "json"], { env }); + expect(result).toMatchSnapshot(); + }); + + it("passes the 404 of an unknown key through and exits 4", async () => { + server.use(meilisearchHandlers.key(both)); + const result = await runCommand(GetKeys, ["--id", "nope", "--output", "json"], { env }); + expect(result).toMatchSnapshot(); + }); +}); diff --git a/test/commands/update/__snapshots__/keys.test.ts.snap b/test/commands/update/__snapshots__/keys.test.ts.snap new file mode 100644 index 0000000..f74ab7f --- /dev/null +++ b/test/commands/update/__snapshots__/keys.test.ts.snap @@ -0,0 +1,71 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`meili update keys > passes an authentication error through with a hint and exits 3 1`] = ` +{ + "exitCode": 3, + "stderr": "{"error":{"code":"invalid_api_key","type":"auth","message":"The provided API key is invalid.","link":"https://docs.meilisearch.com/errors#invalid_api_key","hint":"Provide an API key with \`--project-api-key \` or \`MEILI_PROJECT_API_KEY\`."}} +", + "stdout": "", +} +`; + +exports[`meili update keys > passes the 404 of an unknown key through and exits 4 1`] = ` +{ + "exitCode": 4, + "stderr": "{"error":{"code":"api_key_not_found","type":"invalid_request","message":"API key not found.","link":"https://docs.meilisearch.com/errors#api_key_not_found"}} +", + "stdout": "", +} +`; + +exports[`meili update keys > prints its help without any network call 1`] = ` +"Change the name or the description of an API key, the two fields the route takes, and reset one with the value null. + +USAGE + $ meili update keys --id [-o ] [--debug] [--project-url + ] [--project-api-key ] [--project ] [--name ] + [--description ] [--file | --data ] + +FLAGS + -o, --output= json (default): one document, the API body as it + is, indented in a terminal, compact when piped. + ndjson: one item per line, list commands only, + streams with --all. + --data= Inline JSON body. + --debug Print every HTTP request and response on stderr, + secrets redacted. + --description= New description of the key. The value null + removes the one it has. + --file= Read the body from a file. \`-\` reads from stdin. + --id= (required) The key to update, by its uid or by + its key value. + --name= New name of the key. The value null removes the + one it has. + --project= A project of the config file. Cannot be combined + with --project-url. 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. + +DESCRIPTION + Change the name or the description of an API key, the two fields the route + takes, and reset one with the value null. + +EXAMPLES + $ meili update keys --id 52787ccd-48ff-48e5-9c4b-03cd76978a60 --name 'CI indexer' + + $ meili update keys --id 52787ccd-48ff-48e5-9c4b-03cd76978a60 --description 'Indexes the catalog nightly' --output json + + $ meili update keys --id 52787ccd-48ff-48e5-9c4b-03cd76978a60 --description null + +" +`; + +exports[`meili update keys > prints the key the API returned, as it returned it 1`] = ` +{ + "exitCode": 0, + "stderr": "", + "stdout": "{"name":"CI indexer","description":null,"key":"115f7b0806e6eb3409870fbfef319371dff43dd036746816358c1d71ad4e2221","actions":["search"],"indexes":["*"],"expiresAt":null,"createdAt":"2026-09-24T10:00:00.000000Z","updatedAt":"2026-09-24T10:00:00.000000Z","uid":"52787ccd-48ff-48e5-9c4b-03cd76978a60"} +", +} +`; diff --git a/test/commands/update/keys.test.ts b/test/commands/update/keys.test.ts new file mode 100644 index 0000000..d6c526d --- /dev/null +++ b/test/commands/update/keys.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import UpdateKeys from "../../../src/commands/update/keys.js"; +import { + apiKeyNotFoundError, + invalidApiKeyError, + MEILISEARCH_URL, + makeKey, + meilisearchHandlers, +} from "../../helpers/meilisearch-handlers.js"; +import { recordRequests, server, useMockServer } from "../../helpers/msw.js"; +import { runCli, runCommand } from "../../helpers/run-command.js"; + +useMockServer(); + +const env = { MEILI_PROJECT_URL: MEILISEARCH_URL }; +const uid = "52787ccd-48ff-48e5-9c4b-03cd76978a60"; +const updated = makeKey({ uid, name: "CI indexer", description: null }); + +/** The body the command sent to `PATCH /keys/{key}`. */ +async function sentBody(argv: string[]): Promise { + server.use(meilisearchHandlers.keyUpdate(updated)); + const requests = await recordRequests(async () => { + await runCommand(UpdateKeys, argv, { env }); + }); + expect(requests).toHaveLength(1); + expect(requests[0]?.method).toBe("PATCH"); + expect(requests[0]?.url.pathname).toBe(`/keys/${uid}`); + return JSON.parse(requests[0]?.body ?? ""); +} + +describe("meili update keys", () => { + it("prints its help without any network call", async () => { + const result = await runCli(["update", "keys", "--help"]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toMatchSnapshot(); + }); + + it("prints the key the API returned, as it returned it", async () => { + server.use(meilisearchHandlers.keyUpdate(updated)); + const result = await runCommand( + UpdateKeys, + ["--id", uid, "--name", "CI indexer", "--output", "json"], + { env }, + ); + expect(result).toMatchSnapshot(); + }); + + it("sends the two fields the route takes", async () => { + expect( + await sentBody(["--id", uid, "--name", "CI indexer", "--description", "Nightly"]), + ).toEqual({ name: "CI indexer", description: "Nightly" }); + }); + + it("sends a JSON null for the value null, which removes the field", async () => { + expect(await sentBody(["--id", uid, "--description", "null"])).toEqual({ description: null }); + }); + + it("passes the 404 of an unknown key through and exits 4", async () => { + server.use(meilisearchHandlers.apiError("/keys/nope", 404, apiKeyNotFoundError)); + const result = await runCommand( + UpdateKeys, + ["--id", "nope", "--name", "CI indexer", "--output", "json"], + { env }, + ); + expect(result).toMatchSnapshot(); + }); + + it("passes an authentication error through with a hint and exits 3", async () => { + server.use(meilisearchHandlers.apiError(`/keys/${uid}`, 403, invalidApiKeyError)); + const result = await runCommand( + UpdateKeys, + ["--id", uid, "--name", "CI indexer", "--output", "json"], + { env }, + ); + expect(result).toMatchSnapshot(); + }); +}); diff --git a/test/contract/__snapshots__/commands.test.ts.snap b/test/contract/__snapshots__/commands.test.ts.snap index 93f73e8..73ca8c6 100644 --- a/test/contract/__snapshots__/commands.test.ts.snap +++ b/test/contract/__snapshots__/commands.test.ts.snap @@ -174,6 +174,96 @@ exports[`oclif.manifest.json > \`meili schema\` is a snapshot, the index and eve "POST /indexes", ], }, + { + "api": "meilisearch", + "description": "Create an API key that runs --actions on --indexes until --expires-at. + +The answer carries the \`key\` field, the credential itself, on stdout like any other data.", + "examples": [ + "meili add keys --actions search --indexes movies --expires-at null", + "meili add keys --actions search --indexes '*' --expires-at 2027-01-01T00:00:00Z --output json", + "meili add keys --actions documents.add,documents.delete --indexes movies --expires-at null --name 'CI indexer'", + "meili add keys --data '{"actions":["search"],"indexes":["movies"],"expiresAt":null}'", + ], + "flags": [ + { + "description": "Project URL, the Meilisearch instance to talk to.", + "helpValue": "", + "name": "project-url", + "type": "option", + }, + { + "description": "Project API key. \`-\` reads it from stdin.", + "helpValue": "", + "name": "project-api-key", + "type": "option", + }, + { + "description": "A project of the config file. Cannot be combined with --project-url. Required when the file has several projects.", + "helpValue": "", + "name": "project", + "type": "option", + }, + { + "description": "Uid of the key, a uuid v4. Without it, Meilisearch generates one.", + "helpValue": "", + "name": "id", + "type": "option", + }, + { + "description": "Actions the key permits, comma-separated, for example \`search\` or \`documents.*\`. \`*\` permits them all.", + "helpValue": "", + "name": "actions", + "type": "option", + }, + { + "description": "Indexes the key reaches, comma-separated. \`*\` reaches them all, and \`products_*\` every index of that prefix.", + "helpValue": "", + "name": "indexes", + "type": "option", + }, + { + "description": "Expiration date in RFC 3339 format. The value null creates a key that never expires.", + "helpValue": "", + "name": "expires-at", + "type": "option", + }, + { + "description": "Name of the key, for a human reading the list.", + "helpValue": "", + "name": "name", + "type": "option", + }, + { + "description": "Description of the key, for a human reading the list.", + "helpValue": "", + "name": "description", + "type": "option", + }, + { + "description": "Read the body from a file. \`-\` reads from stdin.", + "exclusive": [ + "data", + ], + "helpValue": "", + "name": "file", + "type": "option", + }, + { + "description": "Inline JSON body.", + "helpValue": "", + "name": "data", + "type": "option", + }, + ], + "id": "add keys", + "keyActions": [ + "keys.create", + ], + "routes": [ + "POST /keys", + ], + }, { "api": "none", "description": "Log in to Meilisearch Cloud with a browser, and store the token for the management commands.", @@ -612,6 +702,54 @@ The file holds API keys and decides where every later command goes, for every pr "DELETE /indexes/{index_uid}", ], }, + { + "api": "meilisearch", + "description": "Delete an API key, which stops authenticating every request that carries it.", + "examples": [ + "meili delete keys --id 52787ccd-48ff-48e5-9c4b-03cd76978a60 --yes", + "meili delete keys --id 52787ccd-48ff-48e5-9c4b-03cd76978a60 --yes --output json", + ], + "flags": [ + { + "description": "Project URL, the Meilisearch instance to talk to.", + "helpValue": "", + "name": "project-url", + "type": "option", + }, + { + "description": "Project API key. \`-\` reads it from stdin.", + "helpValue": "", + "name": "project-api-key", + "type": "option", + }, + { + "description": "A project of the config file. Cannot be combined with --project-url. Required when the file has several projects.", + "helpValue": "", + "name": "project", + "type": "option", + }, + { + "description": "The key to delete, by its uid or by its key value.", + "helpValue": "", + "name": "id", + "required": true, + "type": "option", + }, + { + "description": "Confirm the deletion. Nothing is sent to the API without it.", + "name": "yes", + "required": true, + "type": "boolean", + }, + ], + "id": "delete keys", + "keyActions": [ + "keys.delete", + ], + "routes": [ + "DELETE /keys/{key}", + ], + }, { "api": "meilisearch", "async": true, @@ -1135,6 +1273,70 @@ The file holds API keys and decides where every later command goes, for every pr "GET /indexes/{index_uid}", ], }, + { + "api": "meilisearch", + "description": "List the API keys of the instance, or get one with --id. + +The answer is the API body as it is, so it carries the \`key\` field of every key it returns: credentials that work, on stdout, like any other data.", + "examples": [ + "meili get keys", + "meili get keys --output json", + "meili get keys --id 52787ccd-48ff-48e5-9c4b-03cd76978a60", + "meili get keys --limit 5 --offset 10", + "meili get keys --all --output ndjson", + ], + "flags": [ + { + "description": "Project URL, the Meilisearch instance to talk to.", + "helpValue": "", + "name": "project-url", + "type": "option", + }, + { + "description": "Project API key. \`-\` reads it from stdin.", + "helpValue": "", + "name": "project-api-key", + "type": "option", + }, + { + "description": "A project of the config file. Cannot be combined with --project-url. Required when the file has several projects.", + "helpValue": "", + "name": "project", + "type": "option", + }, + { + "description": "The key to get, by its uid or by its key value. Without it, the command lists the keys.", + "helpValue": "", + "name": "id", + "type": "option", + }, + { + "description": "Passed to the API.", + "helpValue": "", + "name": "limit", + "type": "option", + }, + { + "description": "Passed to the API.", + "helpValue": "", + "name": "offset", + "type": "option", + }, + { + "description": "Fetch every page. Streams with ndjson, buffers into one array with json.", + "name": "all", + "type": "boolean", + }, + ], + "id": "get keys", + "keyActions": [ + "keys.get", + ], + "routes": [ + "GET /keys", + "GET /keys/{key}", + ], + }, { "api": "meilisearch", "description": "Get every setting of an index, each with its current value or its default.", @@ -2006,6 +2208,76 @@ The file holds API keys and decides where every later command goes, for every pr "PATCH /indexes/{index_uid}", ], }, + { + "api": "meilisearch", + "description": "Change the name or the description of an API key, the two fields the route takes, and reset one with the value null.", + "examples": [ + "meili update keys --id 52787ccd-48ff-48e5-9c4b-03cd76978a60 --name 'CI indexer'", + "meili update keys --id 52787ccd-48ff-48e5-9c4b-03cd76978a60 --description 'Indexes the catalog nightly' --output json", + "meili update keys --id 52787ccd-48ff-48e5-9c4b-03cd76978a60 --description null", + ], + "flags": [ + { + "description": "Project URL, the Meilisearch instance to talk to.", + "helpValue": "", + "name": "project-url", + "type": "option", + }, + { + "description": "Project API key. \`-\` reads it from stdin.", + "helpValue": "", + "name": "project-api-key", + "type": "option", + }, + { + "description": "A project of the config file. Cannot be combined with --project-url. Required when the file has several projects.", + "helpValue": "", + "name": "project", + "type": "option", + }, + { + "description": "The key to update, by its uid or by its key value.", + "helpValue": "", + "name": "id", + "required": true, + "type": "option", + }, + { + "description": "New name of the key. The value null removes the one it has.", + "helpValue": "", + "name": "name", + "type": "option", + }, + { + "description": "New description of the key. The value null removes the one it has.", + "helpValue": "", + "name": "description", + "type": "option", + }, + { + "description": "Read the body from a file. \`-\` reads from stdin.", + "exclusive": [ + "data", + ], + "helpValue": "", + "name": "file", + "type": "option", + }, + { + "description": "Inline JSON body.", + "helpValue": "", + "name": "data", + "type": "option", + }, + ], + "id": "update keys", + "keyActions": [ + "keys.update", + ], + "routes": [ + "PATCH /keys/{key}", + ], + }, { "api": "meilisearch", "async": true, @@ -2230,6 +2502,13 @@ The file holds API keys and decides where every later command goes, for every pr "POST /indexes", ], }, + { + "description": "Create an API key that runs --actions on --indexes until --expires-at.", + "id": "add keys", + "routes": [ + "POST /keys", + ], + }, { "description": "Log in to Meilisearch Cloud with a browser, and store the token for the management commands.", "id": "auth login", @@ -2281,6 +2560,13 @@ The file holds API keys and decides where every later command goes, for every pr "DELETE /indexes/{index_uid}", ], }, + { + "description": "Delete an API key, which stops authenticating every request that carries it.", + "id": "delete keys", + "routes": [ + "DELETE /keys/{key}", + ], + }, { "description": "Reset every setting of an index to its default value, which reindexes the documents.", "id": "delete settings", @@ -2324,6 +2610,14 @@ The file holds API keys and decides where every later command goes, for every pr "GET /indexes/{index_uid}", ], }, + { + "description": "List the API keys of the instance, or get one with --id.", + "id": "get keys", + "routes": [ + "GET /keys", + "GET /keys/{key}", + ], + }, { "description": "Get every setting of an index, each with its current value or its default.", "id": "get settings", @@ -2392,6 +2686,13 @@ The file holds API keys and decides where every later command goes, for every pr "PATCH /indexes/{index_uid}", ], }, + { + "description": "Change the name or the description of an API key, the two fields the route takes, and reset one with the value null.", + "id": "update keys", + "routes": [ + "PATCH /keys/{key}", + ], + }, { "description": "Change the settings given, leave the others as they are, and reset one with the value null.", "id": "update settings", diff --git a/test/contract/__snapshots__/help.test.ts.snap b/test/contract/__snapshots__/help.test.ts.snap index 8f14f43..144b95d 100644 --- a/test/contract/__snapshots__/help.test.ts.snap +++ b/test/contract/__snapshots__/help.test.ts.snap @@ -18,6 +18,7 @@ COMMANDS --filter. get health Check that the Meilisearch instance is available. get indexes List the indexes of the instance, or get one with --id. + get keys List the API keys of the instance, or get one with --id. get settings Get every setting of an index, each with its current value or its default. get stats Get the statistics of the whole instance, or those of one index diff --git a/test/helpers/meilisearch-handlers.ts b/test/helpers/meilisearch-handlers.ts index 9d98e8e..10bda5b 100644 --- a/test/helpers/meilisearch-handlers.ts +++ b/test/helpers/meilisearch-handlers.ts @@ -5,6 +5,7 @@ import type { EnqueuedTask, IndexObject, + Key, MeilisearchErrorResponse, RecordAny, Settings, @@ -14,6 +15,18 @@ import { HttpResponse, http } from "msw"; export const MEILISEARCH_URL = "http://meili.test:7700"; +/** + * One page of a route that pages with an offset, `GET /indexes` and `GET /keys`: + * `limit` defaults to 20, `offset` to 0, and the envelope carries `offset`, + * `limit` and `total`. + */ +function offsetPage(all: T[], url: string) { + const params = new URL(url).searchParams; + const limit = Number(params.get("limit") ?? 20); + const offset = Number(params.get("offset") ?? 0); + return { results: all.slice(offset, offset + limit), offset, limit, total: all.length }; +} + export const meilisearchHandlers = { /** `GET /health` answering `{ "status": "available" }`. */ health: (url = MEILISEARCH_URL) => @@ -29,22 +42,11 @@ export const meilisearchHandlers = { }), ), - /** - * `GET /indexes` paginating over `all` like the API does: `limit` defaults to - * 20, `offset` to 0, and the envelope carries `offset`, `limit` and `total`. - */ + /** `GET /indexes`, paginating over `all` with the offset of the route. */ indexes: (all: IndexObject[]) => - http.get(`${MEILISEARCH_URL}/indexes`, ({ request }) => { - const params = new URL(request.url).searchParams; - const limit = Number(params.get("limit") ?? 20); - const offset = Number(params.get("offset") ?? 0); - return HttpResponse.json({ - results: all.slice(offset, offset + limit), - offset, - limit, - total: all.length, - }); - }), + http.get(`${MEILISEARCH_URL}/indexes`, ({ request }) => + HttpResponse.json(offsetPage(all, request.url)), + ), /** `GET /indexes/{uid}` answering the index from `all`, or the 404 body of Meilisearch. */ index: (all: IndexObject[]) => @@ -208,6 +210,31 @@ export const meilisearchHandlers = { }); }), + /** `GET /keys`, the same offset paging as the indexes. */ + keys: (all: Key[]) => + http.get(`${MEILISEARCH_URL}/keys`, ({ request }) => + HttpResponse.json(offsetPage(all, request.url)), + ), + + /** `GET /keys/{key}` answering the key from `all`, by its uid or by its key value. */ + key: (all: Key[]) => + http.get(`${MEILISEARCH_URL}/keys/:key`, ({ params }) => { + const found = all.find((key) => key.uid === params.key || key.key === params.key); + return found === undefined + ? HttpResponse.json(apiKeyNotFoundError, { status: 404 }) + : HttpResponse.json(found); + }), + + /** `POST /keys`, the creation route, which answers the key it created. */ + keyCreate: (key: Key) => http.post(`${MEILISEARCH_URL}/keys`, () => HttpResponse.json(key)), + + /** `PATCH /keys/{key}`, the update route, which answers the key it changed. */ + keyUpdate: (key: Key) => http.patch(`${MEILISEARCH_URL}/keys/:key`, () => HttpResponse.json(key)), + + /** `DELETE /keys/{key}`, which answers 204 with no body. */ + keyDelete: () => + http.delete(`${MEILISEARCH_URL}/keys/:key`, () => new HttpResponse(null, { status: 204 })), + /** `POST /tasks/cancel`, the cancelation route. */ tasksCancel: (task: EnqueuedTask) => http.post(`${MEILISEARCH_URL}/tasks/cancel`, () => HttpResponse.json(task)), @@ -242,6 +269,14 @@ export const invalidApiKeyError: MeilisearchErrorResponse = { link: "https://docs.meilisearch.com/errors#invalid_api_key", }; +/** The error body Meilisearch returns for an API key that does not exist. */ +export const apiKeyNotFoundError: MeilisearchErrorResponse = { + message: "API key not found.", + code: "api_key_not_found", + type: "invalid_request", + link: "https://docs.meilisearch.com/errors#api_key_not_found", +}; + /** The error body Meilisearch returns for an index that does not exist. */ function indexNotFoundError(uid: string): MeilisearchErrorResponse { return { @@ -288,3 +323,24 @@ export function makeTask(overrides: Partial & Pick ...overrides, } as Task; } + +/** + * An API key as the routes of `/keys` answer it, the `key` field included. The + * overrides are typed by name and not by value because meilisearch-js types + * `expiresAt` as a `Date` and `name` and `description` as strings, where the + * API answers the dates as text and both of the others as `null` when empty. + * What the API answers is what the CLI prints. + */ +export function makeKey(overrides: Partial> & Pick): Key { + return { + name: "Default Search API Key", + description: "Use it to search from the frontend", + key: "115f7b0806e6eb3409870fbfef319371dff43dd036746816358c1d71ad4e2221", + actions: ["search"], + indexes: ["*"], + expiresAt: null, + createdAt: "2026-09-24T10:00:00.000000Z", + updatedAt: "2026-09-24T10:00:00.000000Z", + ...overrides, + } as unknown as Key; +} diff --git a/test/integration/meilisearch/add-keys.test.ts b/test/integration/meilisearch/add-keys.test.ts new file mode 100644 index 0000000..c428a08 --- /dev/null +++ b/test/integration/meilisearch/add-keys.test.ts @@ -0,0 +1,77 @@ +import { randomUUID } from "node:crypto"; +import { Meilisearch } from "meilisearch"; +import { afterAll, describe, expect, it } from "vitest"; +import { runBinary } from "../../helpers/run-binary.js"; +import { MASTER_KEY, MEILISEARCH_URL, meilisearchAvailable } from "./setup.js"; + +const meilisearchUp = await meilisearchAvailable(); + +const env = { MEILI_PROJECT_URL: MEILISEARCH_URL, MEILI_PROJECT_API_KEY: MASTER_KEY }; +const created: string[] = []; + +describe.skipIf(!meilisearchUp)("meili add keys against a real Meilisearch", () => { + const client = new Meilisearch({ host: MEILISEARCH_URL, apiKey: MASTER_KEY }); + + afterAll(async () => { + for (const uid of created) await client.deleteKey(uid); + }); + + it("creates a key from the flags and answers it with its key value", async () => { + const uid = randomUUID(); + const result = await runBinary( + [ + "add", + "keys", + "--id", + uid, + "--actions", + "search,documents.add", + "--indexes", + "movies", + "--expires-at", + "null", + "--name", + `meili-cli-test-add-keys-${uid.slice(0, 8)}`, + "--output", + "json", + ], + env, + ); + created.push(uid); + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + const key = JSON.parse(result.stdout) as { uid: string; key: string; actions: string[] }; + expect(key).toMatchObject({ + uid, + actions: ["search", "documents.add"], + indexes: ["movies"], + expiresAt: null, + }); + expect(key.key).toMatch(/^[0-9a-f]{64}$/); + }); + + it("creates a key from --data alone", async () => { + const uid = randomUUID(); + const result = await runBinary( + [ + "add", + "keys", + "--data", + JSON.stringify({ uid, actions: ["search"], indexes: ["*"], expiresAt: null }), + "--output", + "json", + ], + env, + ); + created.push(uid); + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ uid, actions: ["search"] }); + }); + + it("exits 1 with the API error when the body misses a required field", async () => { + const result = await runBinary(["add", "keys", "--actions", "search", "--output", "json"], env); + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + expect(JSON.parse(result.stderr).error.code).toBe("missing_api_key_indexes"); + }); +}); diff --git a/test/integration/meilisearch/delete-keys.test.ts b/test/integration/meilisearch/delete-keys.test.ts new file mode 100644 index 0000000..09e7389 --- /dev/null +++ b/test/integration/meilisearch/delete-keys.test.ts @@ -0,0 +1,46 @@ +import { randomUUID } from "node:crypto"; +import { Meilisearch } from "meilisearch"; +import { beforeAll, describe, expect, it } from "vitest"; +import { runBinary } from "../../helpers/run-binary.js"; +import { MASTER_KEY, MEILISEARCH_URL, meilisearchAvailable } from "./setup.js"; + +const meilisearchUp = await meilisearchAvailable(); + +const env = { MEILI_PROJECT_URL: MEILISEARCH_URL, MEILI_PROJECT_API_KEY: MASTER_KEY }; +const uid = randomUUID(); + +describe.skipIf(!meilisearchUp)("meili delete keys against a real Meilisearch", () => { + const client = new Meilisearch({ host: MEILISEARCH_URL, apiKey: MASTER_KEY }); + + beforeAll(async () => { + await client.createKey({ + uid, + name: `meili-cli-test-delete-keys-${uid.slice(0, 8)}`, + actions: ["search"], + indexes: ["*"], + expiresAt: null, + }); + }); + + it("deletes the key, writes nothing on stdout and exits 0", async () => { + const result = await runBinary( + ["delete", "keys", "--id", uid, "--yes", "--output", "json"], + env, + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe(""); + expect(result.stderr).toBe(""); + const gone = await runBinary(["get", "keys", "--id", uid, "--output", "json"], env); + expect(gone.exitCode).toBe(4); + }); + + it("exits 4 with the API error when the key does not exist", async () => { + const result = await runBinary( + ["delete", "keys", "--id", randomUUID(), "--yes", "--output", "json"], + env, + ); + expect(result.exitCode).toBe(4); + expect(result.stdout).toBe(""); + expect(JSON.parse(result.stderr).error.code).toBe("api_key_not_found"); + }); +}); diff --git a/test/integration/meilisearch/get-keys.test.ts b/test/integration/meilisearch/get-keys.test.ts new file mode 100644 index 0000000..7daf17c --- /dev/null +++ b/test/integration/meilisearch/get-keys.test.ts @@ -0,0 +1,58 @@ +import { randomUUID } from "node:crypto"; +import { Meilisearch } from "meilisearch"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { runBinary } from "../../helpers/run-binary.js"; +import { MASTER_KEY, MEILISEARCH_URL, meilisearchAvailable } from "./setup.js"; + +const meilisearchUp = await meilisearchAvailable(); + +const env = { MEILI_PROJECT_URL: MEILISEARCH_URL, MEILI_PROJECT_API_KEY: MASTER_KEY }; +const uid = randomUUID(); +let value = ""; + +describe.skipIf(!meilisearchUp)("meili get keys against a real Meilisearch", () => { + const client = new Meilisearch({ host: MEILISEARCH_URL, apiKey: MASTER_KEY }); + + beforeAll(async () => { + const key = await client.createKey({ + uid, + name: `meili-cli-test-get-keys-${uid.slice(0, 8)}`, + actions: ["search"], + indexes: ["*"], + expiresAt: null, + }); + value = key.key; + }); + + afterAll(async () => { + await client.deleteKey(uid); + }); + + it("lists the keys with their key value, which the CLI does not mask", async () => { + const result = await runBinary(["get", "keys", "--all", "--output", "json"], env); + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + const page = JSON.parse(result.stdout) as { results: Array<{ uid: string; key: string }> }; + expect(page.results.map((key) => key.uid)).toContain(uid); + expect(page.results.find((key) => key.uid === uid)?.key).toBe(value); + }); + + it("gets one key by its uid", async () => { + const result = await runBinary(["get", "keys", "--id", uid, "--output", "json"], env); + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ uid, key: value, actions: ["search"] }); + }); + + it("gets the same key by its key value", async () => { + const result = await runBinary(["get", "keys", "--id", value, "--output", "json"], env); + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ uid }); + }); + + it("exits 4 with the API error when the key does not exist", async () => { + const result = await runBinary(["get", "keys", "--id", randomUUID(), "--output", "json"], env); + expect(result.exitCode).toBe(4); + expect(result.stdout).toBe(""); + expect(JSON.parse(result.stderr).error.code).toBe("api_key_not_found"); + }); +}); diff --git a/test/integration/meilisearch/update-keys.test.ts b/test/integration/meilisearch/update-keys.test.ts new file mode 100644 index 0000000..340f0e9 --- /dev/null +++ b/test/integration/meilisearch/update-keys.test.ts @@ -0,0 +1,59 @@ +import { randomUUID } from "node:crypto"; +import { Meilisearch } from "meilisearch"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { runBinary } from "../../helpers/run-binary.js"; +import { MASTER_KEY, MEILISEARCH_URL, meilisearchAvailable } from "./setup.js"; + +const meilisearchUp = await meilisearchAvailable(); + +const env = { MEILI_PROJECT_URL: MEILISEARCH_URL, MEILI_PROJECT_API_KEY: MASTER_KEY }; +const uid = randomUUID(); + +describe.skipIf(!meilisearchUp)("meili update keys against a real Meilisearch", () => { + const client = new Meilisearch({ host: MEILISEARCH_URL, apiKey: MASTER_KEY }); + + beforeAll(async () => { + await client.createKey({ + uid, + name: `meili-cli-test-update-keys-${uid.slice(0, 8)}`, + description: "Before the update", + actions: ["search"], + indexes: ["*"], + expiresAt: null, + }); + }); + + afterAll(async () => { + await client.deleteKey(uid); + }); + + it("changes the name and answers the key as the API returns it", async () => { + const name = `meili-cli-test-renamed-${uid.slice(0, 8)}`; + const result = await runBinary( + ["update", "keys", "--id", uid, "--name", name, "--output", "json"], + env, + ); + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(JSON.parse(result.stdout)).toMatchObject({ uid, name }); + }); + + it("removes the description with the value null", async () => { + const result = await runBinary( + ["update", "keys", "--id", uid, "--description", "null", "--output", "json"], + env, + ); + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ uid, description: null }); + }); + + it("exits 4 with the API error when the key does not exist", async () => { + const result = await runBinary( + ["update", "keys", "--id", randomUUID(), "--name", "nope", "--output", "json"], + env, + ); + expect(result.exitCode).toBe(4); + expect(result.stdout).toBe(""); + expect(JSON.parse(result.stderr).error.code).toBe("api_key_not_found"); + }); +});