diff --git a/.changeset/first-version.md b/.changeset/first-version.md index 78f5768..b35cc61 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. - Indexes: `meili get indexes [--id ]`; `meili add indexes --id [--primary-key ]`, `meili update indexes --id [--primary-key ] [--new-id ]`, which renames the index, and `meili delete indexes --id --yes`. - Tasks: `meili get tasks`, narrowed by `--ids`, `--statuses`, `--types`, `--index-ids`, `--batch-ids`, `--canceled-by` and the date filters of the route, paging with `--limit` and `--from` and following the `next` cursor with `--all`; `meili cancel tasks` and `meili delete tasks --yes`, which take the same filters and answer a task. - Search: `meili search --index --q `, on `POST /indexes/{index_uid}/search`, with every non-experimental search parameter of the route as a flag, from `--filter` and `--sort` to `--hybrid` and `--vector`; `--data` or `--file` carries a whole search body, which a flag of the same field overrides. +- Settings: `meili get settings --index `, which returns every setting with its current value or its default; `meili update settings --index `, with one flag per setting, from `--searchable-attributes` and `--stop-words` to `--typo-tolerance` and `--embedders`, where a value of `null` resets that one setting and `--data` or `--file` carries a whole settings body; `meili delete settings --index --yes`, which resets them all. - `meili schema` prints the index of the command tree as JSON, one entry per command with its routes, and `meili schema --command "get indexes"` prints one command in full, with its key actions and whether it returns a task. - Connection through `--project-url` and `--project-api-key`, or `MEILI_PROJECT_URL` and `MEILI_PROJECT_API_KEY`; `MEILI_MANAGEMENT_TOKEN` and `MEILI_MANAGEMENT_URL` for the Management API. - Several projects in a config file, `~/.config/meili/config.json` or `MEILI_CONFIG`, chosen with `--project` on the command line and never deduced; `meili config list`, `config set` and `config unset` manage the file. diff --git a/docs/contract/commands.md b/docs/contract/commands.md index 1fee422..5637c9c 100644 --- a/docs/contract/commands.md +++ b/docs/contract/commands.md @@ -25,7 +25,7 @@ Every API command declares its routes, `static routes = ["GET /indexes", "GET /i 4. **Identifiers.** The path parameter of the resource itself is `--id` when the unit route is implemented: `get indexes --id movies`, `delete keys --id `, `get projects --id 343`. When the list route can filter by identifiers, the unit route is not implemented and the list takes `--ids`, with `--id` accepted as an alias: `get documents --index movies --ids 42` calls the fetch route, `get tasks --ids 42` filters `GET /tasks`, `get batches --ids 7` filters `GET /batches`. The path parameter of a parent resource is a flag named after that collection, singular: `--index movies`. The verb-only commands take the index the same way: `search --index movies`. 5. **Identifiers are named by what they identify.** A query or body parameter that carries identifiers, `uid`, `uids`, `batchUids`, `indexUids`, is named after the resource it identifies: the command's own resource gives `--id` or `--ids`, another resource gives `---ids`. So `add indexes --id movies` and `add keys --id ` carry `uid`; `get tasks --ids 1,2 --batch-ids 7 --index-ids movies` carries `uids`, `batchUids` and `indexUids`; `get batches --ids 7 --task-ids 1,2` carries `batchUids` and `uids`, because bare `uids` are task identifiers everywhere. The API spellings, `--uid`, `--uids`, `--batch-uids`, `--index-uids`, are accepted as aliases, absent from `--help` and `meili schema`. One exception: on `update indexes`, `--id` is the index itself, so the new `uid` is `--new-id`, alias `--uid`. Every other body field keeps its API name, `--primary-key`, `--name`. 6. **Other flags.** The query parameters and the first-level fields of the JSON body, in kebab-case of the API name: `retrieveVectors` is `--retrieve-vectors`, `teamId` is `--team-id`, `primaryKey` is `--primary-key`. When a body has a single object property named after the resource, `{"project": {"name": …}}` in the Management API, the fields of that object are the flags. The only flags that do not come from a route are the global flags and the CLI-owned flags: `--wait`, `--timeout`, `--all`, `--file`, `--data`, `--yes`. -7. **Values.** A scalar is passed as it is. A boolean is a bare flag, `--retrieve-vectors`. An array of scalars is comma-separated, `--fields id,title`, as the API's own GET syntax. An object or a nested array is JSON text, `--typo-tolerance '{"enabled":false}'`. The value `null` sends a JSON null, `--expires-at null`, `--stop-words null`. +7. **Values.** A scalar is passed as it is. A boolean is a bare flag, `--retrieve-vectors`, unless the route also reads `false` from it, as the settings do: it then takes its value, `--facet-search false`, because a bare flag says neither `false` nor `null`. An array of scalars is comma-separated, `--fields id,title`, as the API's own GET syntax. An object or a nested array is JSON text, `--typo-tolerance '{"enabled":false}'`. The value `null` sends a JSON null, `--expires-at null`, `--stop-words null`. 8. **Body input.** On every route with a body, `--file ` or `--data ''` replaces the body fields; `--file -` reads stdin. Path and query flags stay valid next to them. A body-field flag given together with `--file` or `--data` overrides that field, as a flag overrides its environment variable. When a route's body is not an object of named fields but the value itself, the array of identifiers of `delete documents --ids` or the documents of `add documents`, the body is that value, and its shape selects the route among the ones the command covers: `delete documents --data '[1,2]'` deletes by identifiers, `--data '{"filter":…}'` deletes by filter. Documents are sent as JSON, the only content type the document declares. 9. **Routes that are not implemented.** The `GET` twin of a `POST` route that takes the same parameters in a body (search, similar, the list of documents); the unit route of a resource whose list filters by identifiers (documents, tasks, batches); the settings sub-routes, since `update settings` sends any subset and `null` resets one setting; `GET /api/v2/meilisearch-versions`. Each is recorded without command in the exceptions. 10. **Experimental routes** are out of the contract until the feature is stable. Implementing one is a contract change: an exception row with the reason. `compact` is the one exception today. A body field or a query parameter that an experimental feature gates is out of the contract the same way: the command declares no flag for it, and `--data` or `--file` still carries it to the API. `useNetwork` on `get documents`, and `media`, `personalize`, `useNetwork` and `inlineRule` on `search`. These stay out of the exceptions table, which lists routes. @@ -46,7 +46,9 @@ Every API command declares its routes, `static routes = ["GET /indexes", "GET /i | `PUT /indexes/{index_uid}/documents` | `meili update documents --index --file ` | | `POST …/documents/delete-batch`, `POST …/documents/delete`, `DELETE …/documents` | `meili delete documents --index (--ids \| --filter \| --all) --yes` | | `POST /indexes/{index_uid}/search` | `meili search --index --q ` | +| `GET /indexes/{index_uid}/settings` | `meili get settings --index ` | | `PATCH /indexes/{index_uid}/settings` | `meili update settings --index -- ` | +| `DELETE /indexes/{index_uid}/settings` | `meili delete settings --index --yes` | | `GET /tasks` | `meili get tasks [--ids ] [--statuses ]` | | `POST /tasks/cancel` | `meili cancel tasks --ids ` | | `DELETE /tasks` | `meili delete tasks --ids --yes` | diff --git a/oclif.manifest.json b/oclif.manifest.json index 9d2c452..ac57a38 100644 --- a/oclif.manifest.json +++ b/oclif.manifest.json @@ -2482,6 +2482,225 @@ "indexes.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" + }, + "yes": { + "description": "Confirm the deletion. Nothing is sent to the API without it.", + "name": "yes", + "required": true, + "allowNo": false, + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [], + "id": "delete:settings", + "pluginAlias": "@meilisearch/cli", + "pluginName": "@meilisearch/cli", + "pluginType": "core", + "strict": true, + "enableJsonFlag": false, + "api": "meilisearch", + "routes": [ + "DELETE /indexes/{index_uid}/settings" + ], + "keyActions": [ + "settings.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", + "delete", + "settings.js" + ] + }, "delete:tasks": { "aliases": [], "args": {}, @@ -3481,17 +3700,13 @@ "indexes.js" ] }, - "get:tasks": { + "get:settings": { "aliases": [], "args": {}, - "description": "List the tasks of the instance, narrowed by --ids, --statuses, --types and the other filters of the route.", + "description": "Get every setting of an index, each with its current value or its default.", "examples": [ - "<%= config.bin %> <%= command.id %>", - "<%= config.bin %> <%= command.id %> --output json", - "<%= config.bin %> <%= command.id %> --ids 42", - "<%= config.bin %> <%= command.id %> --statuses failed,canceled --limit 5", - "<%= config.bin %> <%= command.id %> --index-ids movies --types documentAdditionOrUpdate", - "<%= config.bin %> <%= command.id %> --all --output ndjson" + "<%= config.bin %> <%= command.id %> --index movies", + "<%= config.bin %> <%= command.id %> --index movies --output json" ], "flags": { "output": { @@ -3537,23 +3752,221 @@ "multiple": false, "type": "option" }, - "ids": { - "aliases": [ - "uids", - "id" - ], - "description": "Task uids to match, comma-separated. `*` matches every one.", - "name": "ids", + "index": { + "description": "The index to read the settings of.", + "name": "index", + "required": true, "hasDynamicHelp": false, - "helpValue": "", + "helpValue": "", + "multiple": false, + "type": "option" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [], + "id": "get:settings", + "pluginAlias": "@meilisearch/cli", + "pluginName": "@meilisearch/cli", + "pluginType": "core", + "strict": true, + "enableJsonFlag": false, + "api": "meilisearch", + "routes": [ + "GET /indexes/{index_uid}/settings" + ], + "keyActions": [ + "settings.get" + ], + "connectionFlags": { + "project-url": { + "description": "Project URL, the Meilisearch instance to talk to.", + "helpValue": "", + "input": [], "multiple": false, "type": "option" }, - "batch-ids": { - "aliases": [ - "batch-uids" - ], - "description": "Match the tasks of these batch uids, comma-separated. `*` matches every one.", + "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", + "settings.js" + ] + }, + "get:tasks": { + "aliases": [], + "args": {}, + "description": "List the tasks of the instance, narrowed by --ids, --statuses, --types and the other filters of the route.", + "examples": [ + "<%= config.bin %> <%= command.id %>", + "<%= config.bin %> <%= command.id %> --output json", + "<%= config.bin %> <%= command.id %> --ids 42", + "<%= config.bin %> <%= command.id %> --statuses failed,canceled --limit 5", + "<%= config.bin %> <%= command.id %> --index-ids movies --types documentAdditionOrUpdate", + "<%= 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" + }, + "ids": { + "aliases": [ + "uids", + "id" + ], + "description": "Task uids to match, comma-separated. `*` matches every one.", + "name": "ids", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "batch-ids": { + "aliases": [ + "batch-uids" + ], + "description": "Match the tasks of these batch uids, comma-separated. `*` matches every one.", "name": "batch-ids", "hasDynamicHelp": false, "helpValue": "", @@ -4900,6 +5313,415 @@ "update", "indexes.js" ] + }, + "update:settings": { + "aliases": [], + "args": {}, + "description": "Change the settings given, leave the others as they are, and reset one with the value null.", + "examples": [ + "<%= config.bin %> <%= command.id %> --index movies --searchable-attributes title,overview", + "<%= config.bin %> <%= command.id %> --index movies --filterable-attributes genres,release_date --output json", + "<%= config.bin %> <%= command.id %> --index movies --stop-words null", + "<%= config.bin %> <%= command.id %> --index movies --typo-tolerance '{\"enabled\":false}' --wait", + "<%= config.bin %> <%= command.id %> --index movies --file settings.json" + ], + "flags": { + "output": { + "char": "o", + "description": "json (default): one document, the API body as it is, indented in a terminal, compact when piped. ndjson: one item per line, list commands only, streams with --all.", + "name": "output", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "options": [ + "json", + "ndjson" + ], + "type": "option" + }, + "debug": { + "description": "Print every HTTP request and response on stderr, secrets redacted.", + "name": "debug", + "allowNo": false, + "type": "boolean" + }, + "project-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 update the settings of.", + "name": "index", + "required": true, + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "displayed-attributes": { + "description": "Fields returned in search results, comma-separated.", + "name": "displayed-attributes", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "searchable-attributes": { + "description": "Fields searched for query words, comma-separated, in order of importance for the ranking.", + "name": "searchable-attributes", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "filterable-attributes": { + "description": "Attributes usable as filters and facets, comma-separated. Their rule form goes through --data.", + "name": "filterable-attributes", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "sortable-attributes": { + "description": "Attributes usable to sort the search results, comma-separated.", + "name": "sortable-attributes", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "foreign-keys": { + "description": "Foreign keys for cross-index filtering, as JSON: `[{\"foreignIndexUid\":\"products\",\"fieldName\":\"productId\"}]`.", + "name": "foreign-keys", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "ranking-rules": { + "description": "Ranking rules in order of importance, comma-separated, built-in or `attribute:asc` and `:desc`.", + "name": "ranking-rules", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "stop-words": { + "description": "Words ignored when they appear in a search query, comma-separated.", + "name": "stop-words", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "non-separator-tokens": { + "description": "Characters removed from the default separators, comma-separated, so that they no longer split words.", + "name": "non-separator-tokens", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "separator-tokens": { + "description": "Characters added to the default separators, comma-separated.", + "name": "separator-tokens", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "dictionary": { + "description": "Strings parsed as a single term, comma-separated, such as names.", + "name": "dictionary", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "synonyms": { + "description": "Words treated as equivalent, as JSON: `{\"phone\":[\"iPhone\"]}`.", + "name": "synonyms", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "distinct-attribute": { + "description": "Field whose value must be unique in the returned documents.", + "name": "distinct-attribute", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "proximity-precision": { + "description": "Precision of the proximity ranking rule, `byWord` or `byAttribute`, which indexes faster.", + "name": "proximity-precision", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "typo-tolerance": { + "description": "Typo tolerance as JSON, for example `{\"enabled\":false}`.", + "name": "typo-tolerance", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "faceting": { + "description": "Faceting as JSON, for example `{\"maxValuesPerFacet\":200}`.", + "name": "faceting", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "pagination": { + "description": "Pagination as JSON, for example `{\"maxTotalHits\":2000}`.", + "name": "pagination", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "embedders": { + "description": "Embedders as JSON, a map of name to configuration: `{\"default\":{\"source\":\"openAi\"}}`.", + "name": "embedders", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "search-cutoff-ms": { + "description": "Maximum duration of a search in milliseconds, 1500 when it is null.", + "name": "search-cutoff-ms", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "localized-attributes": { + "description": "Locales of the attributes, as JSON: `[{\"locales\":[\"jpn\"],\"attributePatterns\":[\"*_ja\"]}]`.", + "name": "localized-attributes", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "facet-search": { + "description": "Whether the facet-search route is enabled on this index, `true` or `false`.", + "name": "facet-search", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "prefix-search": { + "description": "When prefix matches are computed, `indexingTime` or `disabled`, which indexes faster.", + "name": "prefix-search", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "chat": { + "description": "Chat settings as JSON, for example `{\"description\":\"A movie database\"}`, which the LLM reads.", + "name": "chat", + "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" + }, + "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" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [], + "id": "update:settings", + "pluginAlias": "@meilisearch/cli", + "pluginName": "@meilisearch/cli", + "pluginType": "core", + "strict": true, + "enableJsonFlag": false, + "api": "meilisearch", + "routes": [ + "PATCH /indexes/{index_uid}/settings" + ], + "keyActions": [ + "settings.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", + "settings.js" + ] } }, "version": "0.0.0" diff --git a/src/commands/delete/settings.ts b/src/commands/delete/settings.ts new file mode 100644 index 0000000..3d49e54 --- /dev/null +++ b/src/commands/delete/settings.ts @@ -0,0 +1,36 @@ +import { Flags } from "@oclif/core"; +import { type Api, BaseCommand } from "../../lib/base-command.js"; + +export default class DeleteSettings extends BaseCommand { + static api: Api = "meilisearch"; + + static routes = ["DELETE /indexes/{index_uid}/settings"]; + + static keyActions = ["settings.update"]; + + static override description = + "Reset every setting of an index to its default value, which reindexes the documents."; + + static override 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", + ]; + + static override flags = { + ...BaseCommand.connectionFlags, + index: Flags.string({ + required: true, + description: + "The index to reset the settings of. `update settings -- null` resets one of them.", + helpValue: "", + }), + ...BaseCommand.waitFlags, + ...BaseCommand.confirmFlags, + }; + + async run(): Promise { + const { flags } = await this.parse(DeleteSettings); + await this.outputTask(await this.meilisearch.index(flags.index).resetSettings(), flags); + } +} diff --git a/src/commands/get/settings.ts b/src/commands/get/settings.ts new file mode 100644 index 0000000..4b97b58 --- /dev/null +++ b/src/commands/get/settings.ts @@ -0,0 +1,32 @@ +import { Flags } from "@oclif/core"; +import { type Api, BaseCommand } from "../../lib/base-command.js"; + +export default class GetSettings extends BaseCommand { + static api: Api = "meilisearch"; + + static routes = ["GET /indexes/{index_uid}/settings"]; + + static keyActions = ["settings.get"]; + + static override description = + "Get every setting of an index, each with its current value or its default."; + + static override examples = [ + "<%= config.bin %> <%= command.id %> --index movies", + "<%= config.bin %> <%= command.id %> --index movies --output json", + ]; + + static override flags = { + ...BaseCommand.connectionFlags, + index: Flags.string({ + required: true, + description: "The index to read the settings of.", + helpValue: "", + }), + }; + + async run(): Promise { + const { flags } = await this.parse(GetSettings); + await this.output(await this.meilisearch.index(flags.index).getSettings()); + } +} diff --git a/src/commands/search.ts b/src/commands/search.ts index a684ce9..3bb982d 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -2,6 +2,7 @@ import { Flags } from "@oclif/core"; import type { HybridSearch, SearchParams } from "meilisearch"; import { type Api, BaseCommand } from "../lib/base-command.js"; import { readJsonObjectBody } from "../lib/input.js"; +import { asJson, asNumber } from "../lib/values.js"; export default class Search extends BaseCommand { static api: Api = "meilisearch"; @@ -185,31 +186,3 @@ export default class Search extends BaseCommand { await this.output(await this.meilisearch.index(flags.index).search(undefined, params)); } } - -/** - * The two conversions a flag needs before it can be a body field (rule 7): the - * text of a number, and the JSON text of an object. Text that does not convert - * is sent as it is, so that the API names the value it refused, with its own - * code, rather than the CLI judging a value the API owns. The CLI only refuses - * what it cannot send at all, as `--data` and `--file`, which it has to parse - * before it can merge them. - */ -function asNumber(value: string | undefined): number | string | undefined { - if (value === undefined) return undefined; - const text = value.trim(); - // `Number` alone would not do: it reads `0x1` as 1 and `1e999` as Infinity, - // which JSON writes as null. Both turn what the agent typed into another - // value, silently. Only text that spells a decimal number becomes one. - return DECIMAL.test(text) && Number.isFinite(Number(text)) ? Number(text) : value; -} - -const DECIMAL = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/; - -function asJson(value: string | undefined): unknown { - if (value === undefined) return undefined; - try { - return JSON.parse(value); - } catch { - return value; - } -} diff --git a/src/commands/update/settings.ts b/src/commands/update/settings.ts new file mode 100644 index 0000000..2352a66 --- /dev/null +++ b/src/commands/update/settings.ts @@ -0,0 +1,174 @@ +import { Flags } from "@oclif/core"; +import type { Settings } from "meilisearch"; +import { type Api, BaseCommand } from "../../lib/base-command.js"; +import { readJsonObjectBody } from "../../lib/input.js"; +import { asJson, asList, asNumber, asText } from "../../lib/values.js"; + +export default class UpdateSettings extends BaseCommand { + static api: Api = "meilisearch"; + + static routes = ["PATCH /indexes/{index_uid}/settings"]; + + static keyActions = ["settings.update"]; + + static override description = + "Change the settings given, leave the others as they are, and reset one with the value null."; + + static override examples = [ + "<%= config.bin %> <%= command.id %> --index movies --searchable-attributes title,overview", + "<%= config.bin %> <%= command.id %> --index movies --filterable-attributes genres,release_date --output json", + "<%= config.bin %> <%= command.id %> --index movies --stop-words null", + "<%= config.bin %> <%= command.id %> --index movies --typo-tolerance '{\"enabled\":false}' --wait", + "<%= config.bin %> <%= command.id %> --index movies --file settings.json", + ]; + + static override flags = { + ...BaseCommand.connectionFlags, + index: Flags.string({ + required: true, + description: "The index to update the settings of.", + helpValue: "", + }), + "displayed-attributes": Flags.string({ + description: "Fields returned in search results, comma-separated.", + helpValue: "", + }), + "searchable-attributes": Flags.string({ + description: + "Fields searched for query words, comma-separated, in order of importance for the ranking.", + helpValue: "", + }), + "filterable-attributes": Flags.string({ + description: + "Attributes usable as filters and facets, comma-separated. Their rule form goes through --data.", + helpValue: "", + }), + "sortable-attributes": Flags.string({ + description: "Attributes usable to sort the search results, comma-separated.", + helpValue: "", + }), + "foreign-keys": Flags.string({ + description: + 'Foreign keys for cross-index filtering, as JSON: `[{"foreignIndexUid":"products","fieldName":"productId"}]`.', + helpValue: "", + }), + "ranking-rules": Flags.string({ + description: + "Ranking rules in order of importance, comma-separated, built-in or `attribute:asc` and `:desc`.", + helpValue: "", + }), + "stop-words": Flags.string({ + description: "Words ignored when they appear in a search query, comma-separated.", + helpValue: "", + }), + "non-separator-tokens": Flags.string({ + description: + "Characters removed from the default separators, comma-separated, so that they no longer split words.", + helpValue: "", + }), + "separator-tokens": Flags.string({ + description: "Characters added to the default separators, comma-separated.", + helpValue: "", + }), + dictionary: Flags.string({ + description: "Strings parsed as a single term, comma-separated, such as names.", + helpValue: "", + }), + synonyms: Flags.string({ + description: 'Words treated as equivalent, as JSON: `{"phone":["iPhone"]}`.', + helpValue: "", + }), + "distinct-attribute": Flags.string({ + description: "Field whose value must be unique in the returned documents.", + helpValue: "", + }), + "proximity-precision": Flags.string({ + description: + "Precision of the proximity ranking rule, `byWord` or `byAttribute`, which indexes faster.", + helpValue: "", + }), + "typo-tolerance": Flags.string({ + description: 'Typo tolerance as JSON, for example `{"enabled":false}`.', + helpValue: "", + }), + faceting: Flags.string({ + description: 'Faceting as JSON, for example `{"maxValuesPerFacet":200}`.', + helpValue: "", + }), + pagination: Flags.string({ + description: 'Pagination as JSON, for example `{"maxTotalHits":2000}`.', + helpValue: "", + }), + embedders: Flags.string({ + description: + 'Embedders as JSON, a map of name to configuration: `{"default":{"source":"openAi"}}`.', + helpValue: "", + }), + "search-cutoff-ms": Flags.string({ + description: "Maximum duration of a search in milliseconds, 1500 when it is null.", + helpValue: "", + }), + "localized-attributes": Flags.string({ + description: + 'Locales of the attributes, as JSON: `[{"locales":["jpn"],"attributePatterns":["*_ja"]}]`.', + helpValue: "", + }), + "facet-search": Flags.string({ + description: "Whether the facet-search route is enabled on this index, `true` or `false`.", + helpValue: "", + }), + "prefix-search": Flags.string({ + description: + "When prefix matches are computed, `indexingTime` or `disabled`, which indexes faster.", + helpValue: "", + }), + chat: Flags.string({ + description: + 'Chat settings as JSON, for example `{"description":"A movie database"}`, which the LLM reads.', + helpValue: "", + }), + ...BaseCommand.bodyInputFlags, + ...BaseCommand.waitFlags, + }; + + async run(): Promise { + const { flags } = await this.parse(UpdateSettings); + const fromFlags: Record = { + displayedAttributes: asList(flags["displayed-attributes"]), + searchableAttributes: asList(flags["searchable-attributes"]), + filterableAttributes: asList(flags["filterable-attributes"]), + sortableAttributes: asList(flags["sortable-attributes"]), + foreignKeys: asJson(flags["foreign-keys"]), + rankingRules: asList(flags["ranking-rules"]), + stopWords: asList(flags["stop-words"]), + nonSeparatorTokens: asList(flags["non-separator-tokens"]), + separatorTokens: asList(flags["separator-tokens"]), + dictionary: asList(flags.dictionary), + synonyms: asJson(flags.synonyms), + distinctAttribute: asText(flags["distinct-attribute"]), + proximityPrecision: asText(flags["proximity-precision"]), + typoTolerance: asJson(flags["typo-tolerance"]), + faceting: asJson(flags.faceting), + pagination: asJson(flags.pagination), + embedders: asJson(flags.embedders), + searchCutoffMs: asNumber(flags["search-cutoff-ms"]), + localizedAttributes: asJson(flags["localized-attributes"]), + facetSearch: asJson(flags["facet-search"]), + prefixSearch: asText(flags["prefix-search"]), + chat: asJson(flags.chat), + }; + + // The body of --file or --data first, then the flags, which override a field of it (rule 8). + // The undefined entries are dropped rather than spread: an absent flag would otherwise + // erase the field the body carries, since JSON.stringify leaves an undefined value out. + const settings = { + ...readJsonObjectBody(flags), + ...Object.fromEntries(Object.entries(fromFlags).filter(([, value]) => value !== undefined)), + } as Settings; + + await this.outputTask( + await this.meilisearch.index(flags.index).updateSettings(settings), + flags, + ); + } +} diff --git a/src/lib/values.ts b/src/lib/values.ts new file mode 100644 index 0000000..ad1e0c4 --- /dev/null +++ b/src/lib/values.ts @@ -0,0 +1,47 @@ +/** + * The conversions rule 7 of docs/contract/commands.md asks of a flag before it + * can become a body field: the text of a number, the JSON text of an object, + * the comma-separated text of an array of scalars, and `null`, which sends a + * JSON null. Text that does not convert is sent as it is, so that the API + * names the value it refused, with its own code, rather than the CLI judging a + * value the API owns. The CLI only refuses what it cannot send at all, as + * `--data` and `--file`, which it has to parse before it can merge them. + */ + +/** The text that sends a JSON null: `--stop-words null` resets that setting. */ +const NULL = "null"; + +/** An object or a nested array, as JSON text: `--typo-tolerance '{"enabled":false}'`. */ +export function asJson(value: string | undefined): unknown { + if (value === undefined) return undefined; + try { + return JSON.parse(value); + } catch { + return value; + } +} + +/** A number: `--search-cutoff-ms 1500`. */ +export function asNumber(value: string | undefined): number | string | null | undefined { + if (value === undefined) return undefined; + if (value === NULL) return null; + const text = value.trim(); + // `Number` alone would not do: it reads `0x1` as 1 and `1e999` as Infinity, + // which JSON writes as null. Both turn what the agent typed into another + // value, silently. Only text that spells a decimal number becomes one. + return DECIMAL.test(text) && Number.isFinite(Number(text)) ? Number(text) : value; +} + +const DECIMAL = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/; + +/** An array of scalars, comma-separated: `--stop-words the,a`. */ +export function asList(value: string | undefined): string[] | null | undefined { + if (value === undefined) return undefined; + return value === NULL ? null : value.split(","); +} + +/** A string, passed as it is: `--distinct-attribute sku`. */ +export function asText(value: string | undefined): string | null | undefined { + if (value === undefined) return undefined; + return value === NULL ? null : value; +} diff --git a/test/commands/delete/__snapshots__/settings.test.ts.snap b/test/commands/delete/__snapshots__/settings.test.ts.snap new file mode 100644 index 0000000..1e70f9f --- /dev/null +++ b/test/commands/delete/__snapshots__/settings.test.ts.snap @@ -0,0 +1,63 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`meili delete settings > 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 settings > prints its help without any network call 1`] = ` +"Reset every setting of an index to its default value, which reindexes the documents. + +USAGE + $ meili delete settings --index --yes [-o ] [--debug] + [--project-url ] [--project-api-key ] [--project ] + [--timeout --wait] + +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. + --index= (required) The index to reset the settings of. + \`update settings -- null\` resets one of + them. + --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. + --timeout= Maximum wait time, for example 30s, 2m. Default + 60s. Only with --wait. + --wait Poll the task until it reaches a final status, + then print the final task. + --yes (required) Confirm the deletion. Nothing is sent + to the API without it. + +DESCRIPTION + Reset every setting of an index to its default value, which reindexes the + documents. + +EXAMPLES + $ meili delete settings --index movies --yes + + $ meili delete settings --index movies --yes --output json + + $ meili delete settings --index movies --yes --wait + +" +`; + +exports[`meili delete settings > prints the enqueued task as the API returned it, without waiting 1`] = ` +{ + "exitCode": 0, + "stderr": "", + "stdout": "{"taskUid":15,"indexUid":"movies","status":"enqueued","type":"settingsUpdate","enqueuedAt":"2026-09-24T10:00:00Z"} +", +} +`; diff --git a/test/commands/delete/settings.test.ts b/test/commands/delete/settings.test.ts new file mode 100644 index 0000000..78792a7 --- /dev/null +++ b/test/commands/delete/settings.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import DeleteSettings from "../../../src/commands/delete/settings.js"; +import { + invalidApiKeyError, + MEILISEARCH_URL, + makeEnqueuedTask, + 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 reset = makeEnqueuedTask({ taskUid: 15, type: "settingsUpdate" }); + +describe("meili delete settings", () => { + it("prints its help without any network call", async () => { + const result = await runCli(["delete", "settings", "--help"]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toMatchSnapshot(); + }); + + it("prints the enqueued task as the API returned it, without waiting", async () => { + server.use(meilisearchHandlers.settingsDelete(reset)); + const result = await runCommand( + DeleteSettings, + ["--index", "movies", "--yes", "--output", "json"], + { env }, + ); + expect(result).toMatchSnapshot(); + }); + + it("names the index in the path", async () => { + server.use(meilisearchHandlers.settingsDelete(reset)); + const requests = await recordRequests(async () => { + await runCommand(DeleteSettings, ["--index", "movies", "--yes"], { env }); + }); + expect(requests).toHaveLength(1); + expect(requests[0]?.method).toBe("DELETE"); + expect(requests[0]?.url.pathname).toBe("/indexes/movies/settings"); + }); + + it("refuses to run without --yes, before any network call", async () => { + let result: Awaited> | undefined; + const requests = await countRequests(async () => { + result = await runCommand(DeleteSettings, ["--index", "movies"], { 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("/indexes/movies/settings", 403, invalidApiKeyError)); + const result = await runCommand( + DeleteSettings, + ["--index", "movies", "--yes", "--output", "json"], + { env }, + ); + expect(result).toMatchSnapshot(); + }); +}); diff --git a/test/commands/get/__snapshots__/settings.test.ts.snap b/test/commands/get/__snapshots__/settings.test.ts.snap new file mode 100644 index 0000000..fe009da --- /dev/null +++ b/test/commands/get/__snapshots__/settings.test.ts.snap @@ -0,0 +1,51 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`meili get settings > passes the API error of a missing index through and exits 4 1`] = ` +{ + "exitCode": 4, + "stderr": "{"error":{"code":"index_not_found","type":"invalid_request","message":"Index \`missing\` not found.","link":"https://docs.meilisearch.com/errors#index_not_found"}} +", + "stdout": "", +} +`; + +exports[`meili get settings > prints its help without any network call 1`] = ` +"Get every setting of an index, each with its current value or its default. + +USAGE + $ meili get settings --index [-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. + --index= (required) The index to read the settings of. + --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 + Get every setting of an index, each with its current value or its default. + +EXAMPLES + $ meili get settings --index movies + + $ meili get settings --index movies --output json + +" +`; + +exports[`meili get settings > prints the settings as the API returned them 1`] = ` +{ + "exitCode": 0, + "stderr": "", + "stdout": "{"displayedAttributes":["*"],"searchableAttributes":["title","overview"],"filterableAttributes":["genres"],"sortableAttributes":[],"rankingRules":["words","typo","proximity","attribute","sort","exactness"],"stopWords":["the"],"synonyms":{"phone":["iPhone"]},"distinctAttribute":null,"typoTolerance":{"enabled":true,"minWordSizeForTypos":{"oneTypo":5,"twoTypos":9}},"pagination":{"maxTotalHits":1000},"searchCutoffMs":null,"facetSearch":true,"prefixSearch":"indexingTime"} +", +} +`; diff --git a/test/commands/get/settings.test.ts b/test/commands/get/settings.test.ts new file mode 100644 index 0000000..5169c4b --- /dev/null +++ b/test/commands/get/settings.test.ts @@ -0,0 +1,69 @@ +import type { Settings } from "meilisearch"; +import { describe, expect, it } from "vitest"; +import GetSettings from "../../../src/commands/get/settings.js"; +import { MEILISEARCH_URL, 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 }; + +/** A settings body with one of each shape: lists, a map, a scalar and an object. */ +const settings: Settings = { + displayedAttributes: ["*"], + searchableAttributes: ["title", "overview"], + filterableAttributes: ["genres"], + sortableAttributes: [], + rankingRules: ["words", "typo", "proximity", "attribute", "sort", "exactness"], + stopWords: ["the"], + synonyms: { phone: ["iPhone"] }, + distinctAttribute: null, + typoTolerance: { enabled: true, minWordSizeForTypos: { oneTypo: 5, twoTypos: 9 } }, + pagination: { maxTotalHits: 1000 }, + searchCutoffMs: null, + facetSearch: true, + prefixSearch: "indexingTime", +}; + +describe("meili get settings", () => { + it("prints its help without any network call", async () => { + const result = await runCli(["get", "settings", "--help"]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toMatchSnapshot(); + }); + + it("prints the settings as the API returned them", async () => { + server.use(meilisearchHandlers.settings(settings)); + const result = await runCommand(GetSettings, ["--index", "movies", "--output", "json"], { + env, + }); + expect(result).toMatchSnapshot(); + }); + + it("names the index in the path", async () => { + server.use(meilisearchHandlers.settings(settings)); + const requests = await recordRequests(async () => { + await runCommand(GetSettings, ["--index", "movies"], { env }); + }); + expect(requests).toHaveLength(1); + expect(requests[0]?.method).toBe("GET"); + expect(requests[0]?.url.pathname).toBe("/indexes/movies/settings"); + }); + + it("passes the API error of a missing index through and exits 4", async () => { + server.use( + meilisearchHandlers.apiError("/indexes/missing/settings", 404, { + message: "Index `missing` not found.", + code: "index_not_found", + type: "invalid_request", + link: "https://docs.meilisearch.com/errors#index_not_found", + }), + ); + const result = await runCommand(GetSettings, ["--index", "missing", "--output", "json"], { + env, + }); + expect(result).toMatchSnapshot(); + }); +}); diff --git a/test/commands/update/__snapshots__/settings.test.ts.snap b/test/commands/update/__snapshots__/settings.test.ts.snap new file mode 100644 index 0000000..8df77d3 --- /dev/null +++ b/test/commands/update/__snapshots__/settings.test.ts.snap @@ -0,0 +1,134 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`meili update settings > 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 settings > prints its help without any network call 1`] = ` +"Change the settings given, leave the others as they are, and reset one with the value null. + +USAGE + $ meili update settings --index [-o ] [--debug] + [--project-url ] [--project-api-key ] [--project ] + [--displayed-attributes ] [--searchable-attributes ] + [--filterable-attributes ] [--sortable-attributes ] + [--foreign-keys ] [--ranking-rules ] [--stop-words ] + [--non-separator-tokens ] [--separator-tokens ] + [--dictionary ] [--synonyms ] [--distinct-attribute + ] [--proximity-precision ] [--typo-tolerance ] + [--faceting ] [--pagination ] [--embedders ] + [--search-cutoff-ms ] [--localized-attributes ] [--facet-search + ] [--prefix-search ] [--chat ] [--file | --data + ] [--timeout --wait] + +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. + --chat= Chat settings as JSON, for example + \`{"description":"A movie database"}\`, + which the LLM reads. + --data= Inline JSON body. + --debug Print every HTTP request and response + on stderr, secrets redacted. + --dictionary= Strings parsed as a single term, + comma-separated, such as names. + --displayed-attributes= Fields returned in search results, + comma-separated. + --distinct-attribute= Field whose value must be unique in the + returned documents. + --embedders= Embedders as JSON, a map of name to + configuration: + \`{"default":{"source":"openAi"}}\`. + --facet-search= Whether the facet-search route is + enabled on this index, \`true\` or + \`false\`. + --faceting= Faceting as JSON, for example + \`{"maxValuesPerFacet":200}\`. + --file= Read the body from a file. \`-\` reads + from stdin. + --filterable-attributes= Attributes usable as filters and + facets, comma-separated. Their rule + form goes through --data. + --foreign-keys= Foreign keys for cross-index filtering, + as JSON: \`[{"foreignIndexUid":"products + ","fieldName":"productId"}]\`. + --index= (required) The index to update the + settings of. + --localized-attributes= Locales of the attributes, as JSON: + \`[{"locales":["jpn"],"attributePatterns + ":["*_ja"]}]\`. + --non-separator-tokens= Characters removed from the default + separators, comma-separated, so that + they no longer split words. + --pagination= Pagination as JSON, for example + \`{"maxTotalHits":2000}\`. + --prefix-search= When prefix matches are computed, + \`indexingTime\` or \`disabled\`, which + indexes faster. + --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. + --proximity-precision= Precision of the proximity ranking + rule, \`byWord\` or \`byAttribute\`, which + indexes faster. + --ranking-rules= Ranking rules in order of importance, + comma-separated, built-in or + \`attribute:asc\` and \`:desc\`. + --search-cutoff-ms= Maximum duration of a search in + milliseconds, 1500 when it is null. + --searchable-attributes= Fields searched for query words, + comma-separated, in order of importance + for the ranking. + --separator-tokens= Characters added to the default + separators, comma-separated. + --sortable-attributes= Attributes usable to sort the search + results, comma-separated. + --stop-words= Words ignored when they appear in a + search query, comma-separated. + --synonyms= Words treated as equivalent, as JSON: + \`{"phone":["iPhone"]}\`. + --timeout= Maximum wait time, for example 30s, 2m. + Default 60s. Only with --wait. + --typo-tolerance= Typo tolerance as JSON, for example + \`{"enabled":false}\`. + --wait Poll the task until it reaches a final + status, then print the final task. + +DESCRIPTION + Change the settings given, leave the others as they are, and reset one with + the value null. + +EXAMPLES + $ meili update settings --index movies --searchable-attributes title,overview + + $ meili update settings --index movies --filterable-attributes genres,release_date --output json + + $ meili update settings --index movies --stop-words null + + $ meili update settings --index movies --typo-tolerance '{"enabled":false}' --wait + + $ meili update settings --index movies --file settings.json + +" +`; + +exports[`meili update settings > prints the enqueued task as the API returned it, without waiting 1`] = ` +{ + "exitCode": 0, + "stderr": "", + "stdout": "{"taskUid":14,"indexUid":"movies","status":"enqueued","type":"settingsUpdate","enqueuedAt":"2026-09-24T10:00:00Z"} +", +} +`; diff --git a/test/commands/update/settings.test.ts b/test/commands/update/settings.test.ts new file mode 100644 index 0000000..26f0f27 --- /dev/null +++ b/test/commands/update/settings.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; +import UpdateSettings from "../../../src/commands/update/settings.js"; +import { + invalidApiKeyError, + MEILISEARCH_URL, + makeEnqueuedTask, + 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 updated = makeEnqueuedTask({ taskUid: 14, type: "settingsUpdate" }); + +/** The path and the body the command sent to `PATCH /indexes/{uid}/settings`. */ +async function sent(argv: string[]): Promise<{ pathname: string; body: unknown }> { + server.use(meilisearchHandlers.settingsUpdate(updated)); + const requests = await recordRequests(async () => { + await runCommand(UpdateSettings, ["--index", "movies", ...argv], { env }); + }); + expect(requests).toHaveLength(1); + expect(requests[0]?.method).toBe("PATCH"); + return { + pathname: requests[0]?.url.pathname ?? "", + body: JSON.parse(requests[0]?.body ?? ""), + }; +} + +describe("meili update settings", () => { + it("prints its help without any network call", async () => { + const result = await runCli(["update", "settings", "--help"]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toMatchSnapshot(); + }); + + it("prints the enqueued task as the API returned it, without waiting", async () => { + server.use(meilisearchHandlers.settingsUpdate(updated)); + const result = await runCommand( + UpdateSettings, + ["--index", "movies", "--searchable-attributes", "title,overview", "--output", "json"], + { env }, + ); + expect(result).toMatchSnapshot(); + }); + + it("names the index in the path and sends only the settings given", async () => { + expect(await sent(["--searchable-attributes", "title,overview"])).toEqual({ + pathname: "/indexes/movies/settings", + body: { searchableAttributes: ["title", "overview"] }, + }); + }); + + it("splits a list on commas and sends a scalar, a number and a boolean as they are", async () => { + const { body } = await sent([ + "--stop-words", + "the,a", + "--distinct-attribute", + "sku", + "--search-cutoff-ms", + "150", + "--facet-search", + "false", + ]); + expect(body).toEqual({ + stopWords: ["the", "a"], + distinctAttribute: "sku", + searchCutoffMs: 150, + facetSearch: false, + }); + }); + + it("sends the JSON of an object setting, parsed", async () => { + const { body } = await sent([ + "--typo-tolerance", + '{"enabled":false}', + "--synonyms", + '{"phone":["iPhone"]}', + "--chat", + '{"description":"A movie database"}', + ]); + expect(body).toEqual({ + typoTolerance: { enabled: false }, + synonyms: { phone: ["iPhone"] }, + chat: { description: "A movie database" }, + }); + }); + + it("resets a setting with the value null, whatever its type", async () => { + const { body } = await sent([ + "--stop-words", + "null", + "--distinct-attribute", + "null", + "--search-cutoff-ms", + "null", + "--typo-tolerance", + "null", + ]); + expect(body).toEqual({ + stopWords: null, + distinctAttribute: null, + searchCutoffMs: null, + typoTolerance: null, + }); + }); + + it("sends the fields of --data, and lets the flags override the ones it carries", async () => { + const { body } = await sent([ + "--data", + '{"stopWords":["the"],"searchableAttributes":["title"]}', + "--searchable-attributes", + "overview", + ]); + expect(body).toEqual({ stopWords: ["the"], searchableAttributes: ["overview"] }); + }); + + it("sends a value that does not convert as the text it was given, for the API to refuse", async () => { + const { body } = await sent([ + "--search-cutoff-ms", + "soon", + "--faceting", + "{nope}", + "--proximity-precision", + "byWord", + ]); + expect(body).toEqual({ + searchCutoffMs: "soon", + faceting: "{nope}", + proximityPrecision: "byWord", + }); + }); + + it("passes an authentication error through with a hint and exits 3", async () => { + server.use(meilisearchHandlers.apiError("/indexes/movies/settings", 403, invalidApiKeyError)); + const result = await runCommand( + UpdateSettings, + ["--index", "movies", "--stop-words", "the", "--output", "json"], + { env }, + ); + expect(result).toMatchSnapshot(); + }); +}); diff --git a/test/contract/__snapshots__/commands.test.ts.snap b/test/contract/__snapshots__/commands.test.ts.snap index 81ff080..ac3eb4f 100644 --- a/test/contract/__snapshots__/commands.test.ts.snap +++ b/test/contract/__snapshots__/commands.test.ts.snap @@ -612,6 +612,70 @@ The file holds API keys and decides where every later command goes, for every pr "DELETE /indexes/{index_uid}", ], }, + { + "api": "meilisearch", + "async": true, + "description": "Reset every setting of an index to its default value, which reindexes the documents.", + "examples": [ + "meili delete settings --index movies --yes", + "meili delete settings --index movies --yes --output json", + "meili delete settings --index movies --yes --wait", + ], + "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 index to reset the settings of. \`update settings -- null\` resets one of them.", + "helpValue": "", + "name": "index", + "required": true, + "type": "option", + }, + { + "description": "Poll the task until it reaches a final status, then print the final task.", + "name": "wait", + "type": "boolean", + }, + { + "dependsOn": [ + "wait", + ], + "description": "Maximum wait time, for example 30s, 2m. Default 60s. Only with --wait.", + "helpValue": "", + "name": "timeout", + "type": "option", + }, + { + "description": "Confirm the deletion. Nothing is sent to the API without it.", + "name": "yes", + "required": true, + "type": "boolean", + }, + ], + "id": "delete settings", + "keyActions": [ + "settings.update", + ], + "routes": [ + "DELETE /indexes/{index_uid}/settings", + ], + }, { "api": "meilisearch", "async": true, @@ -943,6 +1007,48 @@ The file holds API keys and decides where every later command goes, for every pr "GET /indexes/{index_uid}", ], }, + { + "api": "meilisearch", + "description": "Get every setting of an index, each with its current value or its default.", + "examples": [ + "meili get settings --index movies", + "meili get settings --index movies --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 index to read the settings of.", + "helpValue": "", + "name": "index", + "required": true, + "type": "option", + }, + ], + "id": "get settings", + "keyActions": [ + "settings.get", + ], + "routes": [ + "GET /indexes/{index_uid}/settings", + ], + }, { "api": "meilisearch", "description": "List the tasks of the instance, narrowed by --ids, --statuses, --types and the other filters of the route.", @@ -1494,6 +1600,213 @@ The file holds API keys and decides where every later command goes, for every pr "PATCH /indexes/{index_uid}", ], }, + { + "api": "meilisearch", + "async": true, + "description": "Change the settings given, leave the others as they are, and reset one with the value null.", + "examples": [ + "meili update settings --index movies --searchable-attributes title,overview", + "meili update settings --index movies --filterable-attributes genres,release_date --output json", + "meili update settings --index movies --stop-words null", + "meili update settings --index movies --typo-tolerance '{"enabled":false}' --wait", + "meili update settings --index movies --file settings.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 index to update the settings of.", + "helpValue": "", + "name": "index", + "required": true, + "type": "option", + }, + { + "description": "Fields returned in search results, comma-separated.", + "helpValue": "", + "name": "displayed-attributes", + "type": "option", + }, + { + "description": "Fields searched for query words, comma-separated, in order of importance for the ranking.", + "helpValue": "", + "name": "searchable-attributes", + "type": "option", + }, + { + "description": "Attributes usable as filters and facets, comma-separated. Their rule form goes through --data.", + "helpValue": "", + "name": "filterable-attributes", + "type": "option", + }, + { + "description": "Attributes usable to sort the search results, comma-separated.", + "helpValue": "", + "name": "sortable-attributes", + "type": "option", + }, + { + "description": "Foreign keys for cross-index filtering, as JSON: \`[{"foreignIndexUid":"products","fieldName":"productId"}]\`.", + "helpValue": "", + "name": "foreign-keys", + "type": "option", + }, + { + "description": "Ranking rules in order of importance, comma-separated, built-in or \`attribute:asc\` and \`:desc\`.", + "helpValue": "", + "name": "ranking-rules", + "type": "option", + }, + { + "description": "Words ignored when they appear in a search query, comma-separated.", + "helpValue": "", + "name": "stop-words", + "type": "option", + }, + { + "description": "Characters removed from the default separators, comma-separated, so that they no longer split words.", + "helpValue": "", + "name": "non-separator-tokens", + "type": "option", + }, + { + "description": "Characters added to the default separators, comma-separated.", + "helpValue": "", + "name": "separator-tokens", + "type": "option", + }, + { + "description": "Strings parsed as a single term, comma-separated, such as names.", + "helpValue": "", + "name": "dictionary", + "type": "option", + }, + { + "description": "Words treated as equivalent, as JSON: \`{"phone":["iPhone"]}\`.", + "helpValue": "", + "name": "synonyms", + "type": "option", + }, + { + "description": "Field whose value must be unique in the returned documents.", + "helpValue": "", + "name": "distinct-attribute", + "type": "option", + }, + { + "description": "Precision of the proximity ranking rule, \`byWord\` or \`byAttribute\`, which indexes faster.", + "helpValue": "", + "name": "proximity-precision", + "type": "option", + }, + { + "description": "Typo tolerance as JSON, for example \`{"enabled":false}\`.", + "helpValue": "", + "name": "typo-tolerance", + "type": "option", + }, + { + "description": "Faceting as JSON, for example \`{"maxValuesPerFacet":200}\`.", + "helpValue": "", + "name": "faceting", + "type": "option", + }, + { + "description": "Pagination as JSON, for example \`{"maxTotalHits":2000}\`.", + "helpValue": "", + "name": "pagination", + "type": "option", + }, + { + "description": "Embedders as JSON, a map of name to configuration: \`{"default":{"source":"openAi"}}\`.", + "helpValue": "", + "name": "embedders", + "type": "option", + }, + { + "description": "Maximum duration of a search in milliseconds, 1500 when it is null.", + "helpValue": "", + "name": "search-cutoff-ms", + "type": "option", + }, + { + "description": "Locales of the attributes, as JSON: \`[{"locales":["jpn"],"attributePatterns":["*_ja"]}]\`.", + "helpValue": "", + "name": "localized-attributes", + "type": "option", + }, + { + "description": "Whether the facet-search route is enabled on this index, \`true\` or \`false\`.", + "helpValue": "", + "name": "facet-search", + "type": "option", + }, + { + "description": "When prefix matches are computed, \`indexingTime\` or \`disabled\`, which indexes faster.", + "helpValue": "", + "name": "prefix-search", + "type": "option", + }, + { + "description": "Chat settings as JSON, for example \`{"description":"A movie database"}\`, which the LLM reads.", + "helpValue": "", + "name": "chat", + "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", + }, + { + "description": "Poll the task until it reaches a final status, then print the final task.", + "name": "wait", + "type": "boolean", + }, + { + "dependsOn": [ + "wait", + ], + "description": "Maximum wait time, for example 30s, 2m. Default 60s. Only with --wait.", + "helpValue": "", + "name": "timeout", + "type": "option", + }, + ], + "id": "update settings", + "keyActions": [ + "settings.update", + ], + "routes": [ + "PATCH /indexes/{index_uid}/settings", + ], + }, ], "index": { "commands": [ @@ -1562,6 +1875,13 @@ The file holds API keys and decides where every later command goes, for every pr "DELETE /indexes/{index_uid}", ], }, + { + "description": "Reset every setting of an index to its default value, which reindexes the documents.", + "id": "delete settings", + "routes": [ + "DELETE /indexes/{index_uid}/settings", + ], + }, { "description": "Delete the finished tasks matched by --ids, --statuses, --types and the other filters of the route.", "id": "delete tasks", @@ -1591,6 +1911,13 @@ The file holds API keys and decides where every later command goes, for every pr "GET /indexes/{index_uid}", ], }, + { + "description": "Get every setting of an index, each with its current value or its default.", + "id": "get settings", + "routes": [ + "GET /indexes/{index_uid}/settings", + ], + }, { "description": "List the tasks of the instance, narrowed by --ids, --statuses, --types and the other filters of the route.", "id": "get tasks", @@ -1623,6 +1950,13 @@ The file holds API keys and decides where every later command goes, for every pr "PATCH /indexes/{index_uid}", ], }, + { + "description": "Change the settings given, leave the others as they are, and reset one with the value null.", + "id": "update settings", + "routes": [ + "PATCH /indexes/{index_uid}/settings", + ], + }, ], "globalFlags": [ { diff --git a/test/contract/__snapshots__/help.test.ts.snap b/test/contract/__snapshots__/help.test.ts.snap index a490e1e..ce0e8db 100644 --- a/test/contract/__snapshots__/help.test.ts.snap +++ b/test/contract/__snapshots__/help.test.ts.snap @@ -18,6 +18,8 @@ COMMANDS --filter. get health Check that the Meilisearch instance is available. get indexes List the indexes of the instance, or get one with --id. + get settings Get every setting of an index, each with its current value or + its default. get tasks List the tasks of the instance, narrowed by --ids, --statuses, --types and the other filters of the route. diff --git a/test/helpers/meilisearch-handlers.ts b/test/helpers/meilisearch-handlers.ts index 9ab6e61..2f30998 100644 --- a/test/helpers/meilisearch-handlers.ts +++ b/test/helpers/meilisearch-handlers.ts @@ -7,6 +7,7 @@ import type { IndexObject, MeilisearchErrorResponse, RecordAny, + Settings, Task, } from "meilisearch"; import { HttpResponse, http } from "msw"; @@ -99,6 +100,18 @@ export const meilisearchHandlers = { HttpResponse.json(task), ), + /** `GET /indexes/{uid}/settings` answering the settings of the index. */ + settings: (settings: Settings) => + http.get(`${MEILISEARCH_URL}/indexes/:uid/settings`, () => HttpResponse.json(settings)), + + /** `PATCH /indexes/{uid}/settings`, the update route. */ + settingsUpdate: (task: EnqueuedTask) => + http.patch(`${MEILISEARCH_URL}/indexes/:uid/settings`, () => HttpResponse.json(task)), + + /** `DELETE /indexes/{uid}/settings`, which resets every setting. */ + settingsDelete: (task: EnqueuedTask) => + http.delete(`${MEILISEARCH_URL}/indexes/:uid/settings`, () => HttpResponse.json(task)), + /** * `POST /indexes/{uid}/search` answering the envelope of the API at its * default pagination, with the `q` it was sent. `search` has no `--all`, and diff --git a/test/integration/meilisearch/delete-settings.test.ts b/test/integration/meilisearch/delete-settings.test.ts new file mode 100644 index 0000000..057dac8 --- /dev/null +++ b/test/integration/meilisearch/delete-settings.test.ts @@ -0,0 +1,55 @@ +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 = `meili-cli-test-delete-settings-${randomUUID().slice(0, 8)}`; + +describe.skipIf(!meilisearchUp)("meili delete settings against a real Meilisearch", () => { + const client = new Meilisearch({ host: MEILISEARCH_URL, apiKey: MASTER_KEY }); + + beforeAll(async () => { + await client.createIndex(uid).waitTask(); + await client + .index(uid) + .updateSettings({ stopWords: ["the"], searchableAttributes: ["title"] }) + .waitTask(); + }); + + afterAll(async () => { + await client.deleteIndex(uid).waitTask(); + }); + + it("resets every setting to its default value", async () => { + const result = await runBinary( + ["delete", "settings", "--index", uid, "--yes", "--wait", "--output", "json"], + env, + ); + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + indexUid: uid, + type: "settingsUpdate", + status: "succeeded", + }); + expect(await client.index(uid).getSettings()).toMatchObject({ + stopWords: [], + searchableAttributes: ["*"], + }); + }); + + it("refuses to run without --yes and leaves the settings alone", async () => { + await client + .index(uid) + .updateSettings({ stopWords: ["the"] }) + .waitTask(); + const result = await runBinary(["delete", "settings", "--index", uid, "--output", "json"], env); + expect(result.exitCode).toBe(2); + expect(result.stdout).toBe(""); + expect(JSON.parse(result.stderr).error.code).toBe("cli_usage"); + expect(await client.index(uid).getSettings()).toMatchObject({ stopWords: ["the"] }); + }); +}); diff --git a/test/integration/meilisearch/get-settings.test.ts b/test/integration/meilisearch/get-settings.test.ts new file mode 100644 index 0000000..dcaa17c --- /dev/null +++ b/test/integration/meilisearch/get-settings.test.ts @@ -0,0 +1,47 @@ +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 = `meili-cli-test-get-settings-${randomUUID().slice(0, 8)}`; + +describe.skipIf(!meilisearchUp)("meili get settings against a real Meilisearch", () => { + const client = new Meilisearch({ host: MEILISEARCH_URL, apiKey: MASTER_KEY }); + + beforeAll(async () => { + await client.createIndex(uid).waitTask(); + await client + .index(uid) + .updateSettings({ stopWords: ["the"] }) + .waitTask(); + }); + + afterAll(async () => { + await client.deleteIndex(uid).waitTask(); + }); + + it("returns every setting, the ones set and the defaults", async () => { + const result = await runBinary(["get", "settings", "--index", uid, "--output", "json"], env); + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(JSON.parse(result.stdout)).toMatchObject({ + stopWords: ["the"], + searchableAttributes: ["*"], + rankingRules: expect.arrayContaining(["words", "typo"]), + }); + }); + + it("exits 4 with the API error when the index does not exist", async () => { + const result = await runBinary( + ["get", "settings", "--index", `${uid}-missing`, "--output", "json"], + env, + ); + expect(result.exitCode).toBe(4); + expect(result.stdout).toBe(""); + expect(JSON.parse(result.stderr).error.code).toBe("index_not_found"); + }); +}); diff --git a/test/integration/meilisearch/update-settings.test.ts b/test/integration/meilisearch/update-settings.test.ts new file mode 100644 index 0000000..3670c87 --- /dev/null +++ b/test/integration/meilisearch/update-settings.test.ts @@ -0,0 +1,83 @@ +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 = `meili-cli-test-update-settings-${randomUUID().slice(0, 8)}`; + +describe.skipIf(!meilisearchUp)("meili update settings against a real Meilisearch", () => { + const client = new Meilisearch({ host: MEILISEARCH_URL, apiKey: MASTER_KEY }); + + beforeAll(async () => { + await client.createIndex(uid).waitTask(); + }); + + afterAll(async () => { + await client.deleteIndex(uid).waitTask(); + }); + + it("sets the settings given and leaves the others as they are", async () => { + const result = await runBinary( + [ + "update", + "settings", + "--index", + uid, + "--stop-words", + "the,a", + "--typo-tolerance", + '{"enabled":false}', + "--wait", + "--output", + "json", + ], + env, + ); + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + indexUid: uid, + type: "settingsUpdate", + status: "succeeded", + }); + expect(await client.index(uid).getSettings()).toMatchObject({ + stopWords: ["a", "the"], + typoTolerance: { enabled: false }, + searchableAttributes: ["*"], + }); + }); + + it("resets one setting with the value null", async () => { + const result = await runBinary( + ["update", "settings", "--index", uid, "--stop-words", "null", "--wait", "--output", "json"], + env, + ); + expect(result.exitCode).toBe(0); + const settings = await client.index(uid).getSettings(); + expect(settings.stopWords).toEqual([]); + expect(settings.typoTolerance).toMatchObject({ enabled: false }); + }); + + it("exits 1 with the API error when it refuses a value, before any task", async () => { + const result = await runBinary( + [ + "update", + "settings", + "--index", + uid, + "--proximity-precision", + "byNothing", + "--wait", + "--output", + "json", + ], + env, + ); + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + expect(JSON.parse(result.stderr).error.code).toBe("invalid_settings_proximity_precision"); + }); +});