diff --git a/.changeset/first-version.md b/.changeset/first-version.md index b948586..78f5768 100644 --- a/.changeset/first-version.md +++ b/.changeset/first-version.md @@ -7,6 +7,7 @@ First version of `meili`, the Meilisearch CLI built for coding agents. - Commands derive from the routes of the Meilisearch API and of the Management API, verb first and resource plural: `meili get health`, `meili get indexes`, `meili get indexes --id movies`. - 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. - 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 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. diff --git a/docs/contract/commands.md b/docs/contract/commands.md index 5e6f3c2..1fee422 100644 --- a/docs/contract/commands.md +++ b/docs/contract/commands.md @@ -49,6 +49,7 @@ Every API command declares its routes, `static routes = ["GET /indexes", "GET /i | `PATCH /indexes/{index_uid}/settings` | `meili update settings --index -- ` | | `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 ]` | | `POST /dumps` | `meili add dumps` | | `GET /api/v2/projects`, `GET /api/v2/projects/{id}` | `meili get projects [--id ] [--team-id ]` | diff --git a/docs/contract/output.md b/docs/contract/output.md index 81afbf1..6896882 100644 --- a/docs/contract/output.md +++ b/docs/contract/output.md @@ -110,7 +110,7 @@ Destructive commands are those of the verb `delete`, as `commands.md` derives th ## Pagination - `--limit` and `--offset` (or `--from` for tasks and batches) are passed to the API as they are. When `--id` selects a unit route, they are ignored and not sent. -- `--all` follows the API pagination until the end. With `ndjson` the items stream. With `json` the CLI returns the last page envelope with `results` replaced by the full list and `limit`, `offset` removed. +- `--all` follows the API pagination until the end. With `ndjson` the items stream. With `json` the CLI returns the last page envelope with `results` replaced by the full list and the fields that describe one page removed: `limit` and `offset`, or `limit` and `from` on the routes that page with a cursor. ## Help diff --git a/oclif.manifest.json b/oclif.manifest.json index 208e8b1..9d2c452 100644 --- a/oclif.manifest.json +++ b/oclif.manifest.json @@ -199,6 +199,27 @@ "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.", @@ -420,6 +441,27 @@ "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.", @@ -587,6 +629,27 @@ "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.", @@ -723,6 +786,27 @@ "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.", @@ -859,6 +943,27 @@ "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.", @@ -995,6 +1100,27 @@ "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.", @@ -1030,14 +1156,15 @@ "status.js" ] }, - "config:list": { + "cancel:tasks": { "aliases": [], "args": {}, - "description": "Print the config file path and its projects as JSON, keys masked.", + "description": "Cancel the enqueued and processing tasks matched by --ids, --statuses, --types and the other filters of the route.", "examples": [ - "<%= config.bin %> <%= command.id %>", - "<%= config.bin %> <%= command.id %> --output json", - "MEILI_CONFIG=./meili.json <%= config.bin %> <%= command.id %>" + "<%= config.bin %> <%= command.id %> --ids 42,1344", + "<%= config.bin %> <%= command.id %> --ids 42 --output json", + "<%= config.bin %> <%= command.id %> --statuses enqueued --index-ids movies", + "<%= config.bin %> <%= command.id %> --statuses processing --wait" ], "flags": { "output": { @@ -1058,19 +1185,170 @@ "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": "", + "multiple": false, + "type": "option" + }, + "index-ids": { + "aliases": [ + "index-uids" + ], + "description": "Match the tasks of these indexes, comma-separated. `*` matches every one.", + "name": "index-ids", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "canceled-by": { + "description": "Match the tasks canceled by these taskCancelation uids, comma-separated. `*` matches every one.", + "name": "canceled-by", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "types": { + "description": "Task types to match, comma-separated, as the `type` field of a task spells them, for example `documentAdditionOrUpdate`. `*` matches every one.", + "name": "types", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "statuses": { + "description": "Task statuses to match, comma-separated: enqueued, processing, succeeded, failed or canceled. `*` matches every one.", + "name": "statuses", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "after-enqueued-at": { + "description": "Match the tasks enqueued after this date, in RFC 3339 format.", + "name": "after-enqueued-at", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "before-enqueued-at": { + "description": "Match the tasks enqueued before this date, in RFC 3339 format.", + "name": "before-enqueued-at", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "after-started-at": { + "description": "Match the tasks started after this date, in RFC 3339 format.", + "name": "after-started-at", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "before-started-at": { + "description": "Match the tasks started before this date, in RFC 3339 format.", + "name": "before-started-at", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "after-finished-at": { + "description": "Match the tasks finished after this date, in RFC 3339 format.", + "name": "after-finished-at", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "before-finished-at": { + "description": "Match the tasks finished before this date, in RFC 3339 format.", + "name": "before-finished-at", + "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": "config:list", + "id": "cancel:tasks", "pluginAlias": "@meilisearch/cli", "pluginName": "@meilisearch/cli", "pluginType": "core", "strict": true, "enableJsonFlag": false, - "api": "none", - "routes": [], - "keyActions": [], + "api": "meilisearch", + "routes": [ + "POST /tasks/cancel" + ], + "keyActions": [ + "tasks.cancel" + ], "connectionFlags": { "project-url": { "description": "Project URL, the Meilisearch instance to talk to.", @@ -1132,6 +1410,27 @@ "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.", @@ -1163,18 +1462,18 @@ "relativePath": [ "dist", "commands", - "config", - "list.js" + "cancel", + "tasks.js" ] }, - "config:set": { + "config:list": { "aliases": [], "args": {}, - "description": "Set a project in the config file, creating the file owner-only; refuses a name that exists.\n\nThe file holds API keys and decides where every later command goes, for every process that reads it: an agent runs this only when the human asked for it explicitly.", + "description": "Print the config file path and its projects as JSON, keys masked.", "examples": [ - "<%= config.bin %> <%= command.id %> --project local --project-url http://localhost:7700", - "<%= config.bin %> <%= command.id %> --project prod --project-url https://ms-abc.sfo.meilisearch.io --project-api-key - < key.txt", - "<%= config.bin %> <%= command.id %> --project local --project-url http://localhost:7700 --output json" + "<%= config.bin %> <%= command.id %>", + "<%= config.bin %> <%= command.id %> --output json", + "MEILI_CONFIG=./meili.json <%= config.bin %> <%= command.id %>" ], "flags": { "output": { @@ -1195,12 +1494,170 @@ "name": "debug", "allowNo": false, "type": "boolean" - }, - "project": { - "description": "Name of the project to set.", - "name": "project", - "required": true, - "hasDynamicHelp": false, + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [], + "id": "config:list", + "pluginAlias": "@meilisearch/cli", + "pluginName": "@meilisearch/cli", + "pluginType": "core", + "strict": true, + "enableJsonFlag": false, + "api": "none", + "routes": [], + "keyActions": [], + "connectionFlags": { + "project-url": { + "description": "Project URL, the Meilisearch instance to talk to.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "project-api-key": { + "description": "Project API key. `-` reads it from stdin.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "project": { + "description": "A project of the config file. Cannot be combined with --project-url. Required when the file has several projects.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "waitFlags": { + "wait": { + "description": "Poll the task until it reaches a final status, then print the final task.", + "allowNo": false, + "type": "boolean" + }, + "timeout": { + "description": "Maximum wait time, for example 30s, 2m. Default 60s. Only with --wait.", + "helpValue": "", + "dependsOn": [ + "wait" + ], + "input": [], + "multiple": false, + "type": "option" + } + }, + "paginationFlags": { + "limit": { + "description": "Passed to the API.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "offset": { + "description": "Passed to the API.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "all": { + "description": "Fetch every page. Streams with ndjson, buffers into one array with json.", + "allowNo": false, + "type": "boolean" + } + }, + "fromPaginationFlags": { + "limit": { + "description": "Passed to the API.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "from": { + "description": "Passed to the API: the uid the page starts at, going down.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "all": { + "description": "Fetch every page. Streams with ndjson, buffers into one array with json.", + "allowNo": false, + "type": "boolean" + } + }, + "bodyInputFlags": { + "file": { + "description": "Read the body from a file. `-` reads from stdin.", + "helpValue": "", + "exclusive": [ + "data" + ], + "input": [], + "multiple": false, + "type": "option" + }, + "data": { + "description": "Inline JSON body.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "confirmFlags": { + "yes": { + "required": true, + "description": "Confirm the deletion. Nothing is sent to the API without it.", + "allowNo": false, + "type": "boolean" + } + }, + "isESM": true, + "relativePath": [ + "dist", + "commands", + "config", + "list.js" + ] + }, + "config:set": { + "aliases": [], + "args": {}, + "description": "Set a project in the config file, creating the file owner-only; refuses a name that exists.\n\nThe file holds API keys and decides where every later command goes, for every process that reads it: an agent runs this only when the human asked for it explicitly.", + "examples": [ + "<%= config.bin %> <%= command.id %> --project local --project-url http://localhost:7700", + "<%= config.bin %> <%= command.id %> --project prod --project-url https://ms-abc.sfo.meilisearch.io --project-api-key - < key.txt", + "<%= config.bin %> <%= command.id %> --project local --project-url http://localhost:7700 --output json" + ], + "flags": { + "output": { + "char": "o", + "description": "json (default): one document, the API body as it is, indented in a terminal, compact when piped. ndjson: one item per line, list commands only, streams with --all.", + "name": "output", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "options": [ + "json", + "ndjson" + ], + "type": "option" + }, + "debug": { + "description": "Print every HTTP request and response on stderr, secrets redacted.", + "name": "debug", + "allowNo": false, + "type": "boolean" + }, + "project": { + "description": "Name of the project to set.", + "name": "project", + "required": true, + "hasDynamicHelp": false, "helpValue": "", "multiple": false, "type": "option" @@ -1295,6 +1752,27 @@ "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.", @@ -1440,6 +1918,27 @@ "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.", @@ -1708,6 +2207,27 @@ "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.", @@ -1906,6 +2426,27 @@ "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.", @@ -1941,16 +2482,15 @@ "indexes.js" ] }, - "get:documents": { + "delete:tasks": { "aliases": [], "args": {}, - "description": "List the documents of an index, narrowed by --ids or by --filter.", + "description": "Delete the finished tasks matched by --ids, --statuses, --types and the other filters of the route.", "examples": [ - "<%= config.bin %> <%= command.id %> --index movies", - "<%= config.bin %> <%= command.id %> --index movies --output json", - "<%= config.bin %> <%= command.id %> --index movies --ids 42,1344 --fields id,title", - "<%= config.bin %> <%= command.id %> --index movies --filter 'rating > 4' --sort rating:desc", - "<%= config.bin %> <%= command.id %> --index movies --all --output ndjson" + "<%= config.bin %> <%= command.id %> --ids 42,1344 --yes", + "<%= config.bin %> <%= command.id %> --ids 42 --yes --output json", + "<%= config.bin %> <%= command.id %> --statuses succeeded --before-enqueued-at 2026-01-01T00:00:00Z --yes", + "<%= config.bin %> <%= command.id %> --index-ids movies --yes --wait" ], "flags": { "output": { @@ -1996,29 +2536,347 @@ "multiple": false, "type": "option" }, - "index": { - "description": "The index to read the documents from.", - "name": "index", - "required": true, - "hasDynamicHelp": false, - "helpValue": "", - "multiple": false, - "type": "option" - }, "ids": { "aliases": [ + "uids", "id" ], - "description": "Document identifiers to return, comma-separated.", + "description": "Task uids to match, comma-separated. `*` matches every one.", "name": "ids", "hasDynamicHelp": false, - "helpValue": "", + "helpValue": "", "multiple": false, "type": "option" }, - "filter": { - "description": "Filter expression, for example `rating > 4`. Its array-of-arrays form goes through --data.", - "name": "filter", + "batch-ids": { + "aliases": [ + "batch-uids" + ], + "description": "Match the tasks of these batch uids, comma-separated. `*` matches every one.", + "name": "batch-ids", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "index-ids": { + "aliases": [ + "index-uids" + ], + "description": "Match the tasks of these indexes, comma-separated. `*` matches every one.", + "name": "index-ids", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "canceled-by": { + "description": "Match the tasks canceled by these taskCancelation uids, comma-separated. `*` matches every one.", + "name": "canceled-by", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "types": { + "description": "Task types to match, comma-separated, as the `type` field of a task spells them, for example `documentAdditionOrUpdate`. `*` matches every one.", + "name": "types", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "statuses": { + "description": "Task statuses to match, comma-separated: enqueued, processing, succeeded, failed or canceled. `*` matches every one.", + "name": "statuses", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "after-enqueued-at": { + "description": "Match the tasks enqueued after this date, in RFC 3339 format.", + "name": "after-enqueued-at", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "before-enqueued-at": { + "description": "Match the tasks enqueued before this date, in RFC 3339 format.", + "name": "before-enqueued-at", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "after-started-at": { + "description": "Match the tasks started after this date, in RFC 3339 format.", + "name": "after-started-at", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "before-started-at": { + "description": "Match the tasks started before this date, in RFC 3339 format.", + "name": "before-started-at", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "after-finished-at": { + "description": "Match the tasks finished after this date, in RFC 3339 format.", + "name": "after-finished-at", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "before-finished-at": { + "description": "Match the tasks finished before this date, in RFC 3339 format.", + "name": "before-finished-at", + "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:tasks", + "pluginAlias": "@meilisearch/cli", + "pluginName": "@meilisearch/cli", + "pluginType": "core", + "strict": true, + "enableJsonFlag": false, + "api": "meilisearch", + "routes": [ + "DELETE /tasks" + ], + "keyActions": [ + "tasks.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", + "tasks.js" + ] + }, + "get:documents": { + "aliases": [], + "args": {}, + "description": "List the documents of an index, narrowed by --ids or by --filter.", + "examples": [ + "<%= config.bin %> <%= command.id %> --index movies", + "<%= config.bin %> <%= command.id %> --index movies --output json", + "<%= config.bin %> <%= command.id %> --index movies --ids 42,1344 --fields id,title", + "<%= config.bin %> <%= command.id %> --index movies --filter 'rating > 4' --sort rating:desc", + "<%= config.bin %> <%= command.id %> --index movies --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" + }, + "index": { + "description": "The index to read the documents from.", + "name": "index", + "required": true, + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "ids": { + "aliases": [ + "id" + ], + "description": "Document identifiers to return, comma-separated.", + "name": "ids", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "filter": { + "description": "Filter expression, for example `rating > 4`. Its array-of-arrays form goes through --data.", + "name": "filter", "hasDynamicHelp": false, "helpValue": "", "multiple": false, @@ -2164,6 +3022,211 @@ "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", + "documents.js" + ] + }, + "get:health": { + "aliases": [], + "args": {}, + "description": "Check that the Meilisearch instance is available.", + "examples": [ + "<%= config.bin %> <%= command.id %>", + "<%= config.bin %> <%= command.id %> --output json", + "MEILI_PROJECT_URL=http://localhost:7700 <%= config.bin %> <%= command.id %>" + ], + "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" + } + }, + "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.", @@ -2196,17 +3259,19 @@ "dist", "commands", "get", - "documents.js" + "health.js" ] }, - "get:health": { + "get:indexes": { "aliases": [], "args": {}, - "description": "Check that the Meilisearch instance is available.", + "description": "List the indexes of the instance, or get one with --id.", "examples": [ "<%= config.bin %> <%= command.id %>", "<%= config.bin %> <%= command.id %> --output json", - "MEILI_PROJECT_URL=http://localhost:7700 <%= config.bin %> <%= command.id %>" + "<%= config.bin %> <%= command.id %> --id movies", + "<%= config.bin %> <%= command.id %> --limit 5 --offset 10", + "<%= config.bin %> <%= command.id %> --all --output ndjson" ], "flags": { "output": { @@ -2251,11 +3316,41 @@ "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" + }, + "offset": { + "description": "Passed to the API.", + "name": "offset", + "hasDynamicHelp": false, + "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", @@ -2263,9 +3358,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.", @@ -2327,6 +3425,27 @@ "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.", @@ -2359,18 +3478,19 @@ "dist", "commands", "get", - "health.js" + "indexes.js" ] }, - "get:indexes": { + "get:tasks": { "aliases": [], "args": {}, - "description": "List the indexes of the instance, or get one with --id.", + "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 %> --id movies", - "<%= config.bin %> <%= command.id %> --limit 5 --offset 10", + "<%= 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": { @@ -2417,14 +3537,118 @@ "multiple": false, "type": "option" }, - "id": { - "description": "The index to get. Without it, the command lists the indexes.", - "name": "id", + "ids": { + "aliases": [ + "uids", + "id" + ], + "description": "Task uids to match, comma-separated. `*` matches every one.", + "name": "ids", "hasDynamicHelp": false, - "helpValue": "", + "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": "", + "multiple": false, + "type": "option" + }, + "index-ids": { + "aliases": [ + "index-uids" + ], + "description": "Match the tasks of these indexes, comma-separated. `*` matches every one.", + "name": "index-ids", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "canceled-by": { + "description": "Match the tasks canceled by these taskCancelation uids, comma-separated. `*` matches every one.", + "name": "canceled-by", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "types": { + "description": "Task types to match, comma-separated, as the `type` field of a task spells them, for example `documentAdditionOrUpdate`. `*` matches every one.", + "name": "types", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "statuses": { + "description": "Task statuses to match, comma-separated: enqueued, processing, succeeded, failed or canceled. `*` matches every one.", + "name": "statuses", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "after-enqueued-at": { + "description": "Match the tasks enqueued after this date, in RFC 3339 format.", + "name": "after-enqueued-at", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "before-enqueued-at": { + "description": "Match the tasks enqueued before this date, in RFC 3339 format.", + "name": "before-enqueued-at", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "after-started-at": { + "description": "Match the tasks started after this date, in RFC 3339 format.", + "name": "after-started-at", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "before-started-at": { + "description": "Match the tasks started before this date, in RFC 3339 format.", + "name": "before-started-at", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "after-finished-at": { + "description": "Match the tasks finished after this date, in RFC 3339 format.", + "name": "after-finished-at", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "before-finished-at": { + "description": "Match the tasks finished before this date, in RFC 3339 format.", + "name": "before-finished-at", + "hasDynamicHelp": false, + "helpValue": "", "multiple": false, "type": "option" }, + "reverse": { + "description": "Return the tasks from the oldest uid up, instead of the newest down.", + "name": "reverse", + "allowNo": false, + "type": "boolean" + }, "limit": { "description": "Passed to the API.", "name": "limit", @@ -2433,11 +3657,11 @@ "multiple": false, "type": "option" }, - "offset": { - "description": "Passed to the API.", - "name": "offset", + "from": { + "description": "Passed to the API: the uid the page starts at, going down.", + "name": "from", "hasDynamicHelp": false, - "helpValue": "", + "helpValue": "", "multiple": false, "type": "option" }, @@ -2450,7 +3674,7 @@ }, "hasDynamicHelp": false, "hiddenAliases": [], - "id": "get:indexes", + "id": "get:tasks", "pluginAlias": "@meilisearch/cli", "pluginName": "@meilisearch/cli", "pluginType": "core", @@ -2458,11 +3682,10 @@ "enableJsonFlag": false, "api": "meilisearch", "routes": [ - "GET /indexes", - "GET /indexes/{index_uid}" + "GET /tasks" ], "keyActions": [ - "indexes.get" + "tasks.get" ], "connectionFlags": { "project-url": { @@ -2525,6 +3748,27 @@ "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.", @@ -2557,7 +3801,7 @@ "dist", "commands", "get", - "indexes.js" + "tasks.js" ] }, "schema": { @@ -2671,6 +3915,27 @@ "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.", @@ -3075,6 +4340,27 @@ "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.", @@ -3308,6 +4594,27 @@ "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.", @@ -3538,6 +4845,27 @@ "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.", diff --git a/package.json b/package.json index 4d062e1..010fbd7 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,9 @@ }, "delete": { "description": "Remove a resource. Every command of this topic requires --yes on the command line: a flag the agent writes, never a question the CLI asks." + }, + "cancel": { + "description": "Stop a resource that is still running. The Meilisearch API answers with a task: the command prints it and returns at once, and --wait polls it to a final status instead." } } }, diff --git a/src/commands/cancel/tasks.ts b/src/commands/cancel/tasks.ts new file mode 100644 index 0000000..668c3f0 --- /dev/null +++ b/src/commands/cancel/tasks.ts @@ -0,0 +1,31 @@ +import { type Api, BaseCommand } from "../../lib/base-command.js"; +import { taskFilterFlags, taskFilterQuery } from "../../lib/task-filters.js"; + +export default class CancelTasks extends BaseCommand { + static api: Api = "meilisearch"; + + static routes = ["POST /tasks/cancel"]; + + static keyActions = ["tasks.cancel"]; + + static override description = + "Cancel the enqueued and processing tasks matched by --ids, --statuses, --types and the other filters of the route."; + + static override examples = [ + "<%= config.bin %> <%= command.id %> --ids 42,1344", + "<%= config.bin %> <%= command.id %> --ids 42 --output json", + "<%= config.bin %> <%= command.id %> --statuses enqueued --index-ids movies", + "<%= config.bin %> <%= command.id %> --statuses processing --wait", + ]; + + static override flags = { + ...BaseCommand.connectionFlags, + ...taskFilterFlags, + ...BaseCommand.waitFlags, + }; + + async run(): Promise { + const { flags } = await this.parse(CancelTasks); + await this.outputTask(await this.meilisearch.tasks.cancelTasks(taskFilterQuery(flags)), flags); + } +} diff --git a/src/commands/delete/tasks.ts b/src/commands/delete/tasks.ts new file mode 100644 index 0000000..ce3b322 --- /dev/null +++ b/src/commands/delete/tasks.ts @@ -0,0 +1,41 @@ +import { type Api, BaseCommand } from "../../lib/base-command.js"; +import { MeiliCliError } from "../../lib/errors.js"; +import { taskFilterFlags, taskFilterQuery } from "../../lib/task-filters.js"; + +export default class DeleteTasks extends BaseCommand { + static api: Api = "meilisearch"; + + static routes = ["DELETE /tasks"]; + + static keyActions = ["tasks.delete"]; + + static override description = + "Delete the finished tasks matched by --ids, --statuses, --types and the other filters of the route."; + + static override examples = [ + "<%= config.bin %> <%= command.id %> --ids 42,1344 --yes", + "<%= config.bin %> <%= command.id %> --ids 42 --yes --output json", + "<%= config.bin %> <%= command.id %> --statuses succeeded --before-enqueued-at 2026-01-01T00:00:00Z --yes", + "<%= config.bin %> <%= command.id %> --index-ids movies --yes --wait", + ]; + + static override flags = { + ...BaseCommand.connectionFlags, + ...taskFilterFlags, + ...BaseCommand.waitFlags, + ...BaseCommand.confirmFlags, + }; + + async run(): Promise { + const { flags } = await this.parse(DeleteTasks); + const query = taskFilterQuery(flags); + // docs/contract/output.md, Destructive commands. There is no --all here: + // the route has no form that deletes every task. + if (Object.values(query).every((filter) => filter === undefined)) { + throw new MeiliCliError("cli_usage", "The tasks to delete are missing.", { + hint: "Name them with `--ids `, or match them with `--statuses `, `--types `, `--index-ids ` or a date filter such as `--before-enqueued-at `.", + }); + } + await this.outputTask(await this.meilisearch.tasks.deleteTasks(query), flags); + } +} diff --git a/src/commands/get/documents.ts b/src/commands/get/documents.ts index ffdd3c8..6510fb8 100644 --- a/src/commands/get/documents.ts +++ b/src/commands/get/documents.ts @@ -4,6 +4,7 @@ import { type Api, BaseCommand } from "../../lib/base-command.js"; import { fetchDocuments } from "../../lib/clients/meilisearch.js"; import { MeiliCliError } from "../../lib/errors.js"; import { isJsonObject, readJsonBody } from "../../lib/input.js"; +import { nextOffsetQuery } from "../../lib/pagination.js"; export default class GetDocuments extends BaseCommand { static api: Api = "meilisearch"; @@ -79,6 +80,7 @@ export default class GetDocuments extends BaseCommand { (page) => fetchDocuments(this.meilisearch, flags.index, { ...rest, ...page }), { limit, offset }, flags.all, + nextOffsetQuery, ); } } diff --git a/src/commands/get/indexes.ts b/src/commands/get/indexes.ts index 67ac67c..779d901 100644 --- a/src/commands/get/indexes.ts +++ b/src/commands/get/indexes.ts @@ -1,5 +1,6 @@ import { Flags } from "@oclif/core"; import { type Api, BaseCommand } from "../../lib/base-command.js"; +import { nextOffsetQuery } from "../../lib/pagination.js"; export default class GetIndexes extends BaseCommand { static api: Api = "meilisearch"; @@ -37,6 +38,7 @@ export default class GetIndexes extends BaseCommand { (query) => this.meilisearch.getRawIndexes(query), { limit: flags.limit, offset: flags.offset }, flags.all, + nextOffsetQuery, ); } } diff --git a/src/commands/get/tasks.ts b/src/commands/get/tasks.ts new file mode 100644 index 0000000..3095e42 --- /dev/null +++ b/src/commands/get/tasks.ts @@ -0,0 +1,47 @@ +import { Flags } from "@oclif/core"; +import { type Api, BaseCommand } from "../../lib/base-command.js"; +import { nextFromQuery } from "../../lib/pagination.js"; +import { taskFilterFlags, taskFilterQuery } from "../../lib/task-filters.js"; + +export default class GetTasks extends BaseCommand { + static api: Api = "meilisearch"; + + static routes = ["GET /tasks"]; + + static keyActions = ["tasks.get"]; + + static override description = + "List the tasks of the instance, narrowed by --ids, --statuses, --types and the other filters of the route."; + + static override 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", + ]; + + static override flags = { + ...BaseCommand.connectionFlags, + ...taskFilterFlags, + reverse: Flags.boolean({ + description: "Return the tasks from the oldest uid up, instead of the newest down.", + }), + ...BaseCommand.fromPaginationFlags, + }; + + async run(): Promise { + const { flags } = await this.parse(GetTasks); + const filters = { + ...taskFilterQuery(flags), + reverse: flags.reverse ? true : undefined, + }; + await this.outputList( + (page) => this.meilisearch.tasks.getTasks({ ...filters, ...page }), + { limit: flags.limit, from: flags.from }, + flags.all, + nextFromQuery, + ); + } +} diff --git a/src/lib/base-command.ts b/src/lib/base-command.ts index 2a203d9..7b03115 100644 --- a/src/lib/base-command.ts +++ b/src/lib/base-command.ts @@ -19,7 +19,7 @@ import { import { MeiliCliError, toRenderedError, writeError, writeNotice, writeWarning } from "./errors.js"; import { readStdin } from "./input.js"; import { type OutputMode, resolveOutputMode, writeData } from "./output.js"; -import { iteratePages, type ListPage, mergePages, type OffsetQuery } from "./pagination.js"; +import { iteratePages, type ListPage, mergePages } from "./pagination.js"; import { credentialsFilePath, type ManagementCredentials, @@ -36,6 +36,12 @@ export type GlobalFlags = Interfaces.InferredFlags< typeof BaseCommand.baseFlags & typeof BaseCommand.connectionFlags >; +/** The two flags a list route declares whichever way it pages, shared by both groups below. */ +const LIMIT_FLAG = Flags.integer({ description: "Passed to the API.", helpValue: "" }); +const ALL_FLAG = Flags.boolean({ + description: "Fetch every page. Streams with ndjson, buffers into one array with json.", +}); + export interface WaitFlagValues { wait?: boolean | undefined; timeout?: string | undefined; @@ -114,11 +120,23 @@ export abstract class BaseCommand extends Command { /** Shared group for list commands. */ static paginationFlags = { - limit: Flags.integer({ description: "Passed to the API.", helpValue: "" }), + limit: LIMIT_FLAG, offset: Flags.integer({ description: "Passed to the API.", helpValue: "" }), - all: Flags.boolean({ - description: "Fetch every page. Streams with ndjson, buffers into one array with json.", + all: ALL_FLAG, + }; + + /** + * The same group for a route that pages with a cursor rather than an offset: + * tasks and batches take `from` and answer `next`. One group per cursor name, + * because the contract names the flag after the parameter of its route. + */ + static fromPaginationFlags = { + limit: LIMIT_FLAG, + from: Flags.integer({ + description: "Passed to the API: the uid the page starts at, going down.", + helpValue: "", }), + all: ALL_FLAG, }; /** Shared group for commands that send a JSON body. */ @@ -227,10 +245,11 @@ export abstract class BaseCommand extends Command { * `ndjson` one item per line, streamed with `--all`. List routes end with * this call. `fetchPage` is one API call, returning the API body as it is. */ - protected async outputList( - fetchPage: (query: OffsetQuery) => Promise, - query: OffsetQuery, + protected async outputList( + fetchPage: (query: Q) => Promise, + query: Q, all: boolean, + nextQuery: (query: Q, page: ListPage) => Q | undefined, ): Promise { if (!all) { const page = await fetchPage(query); @@ -240,13 +259,13 @@ export abstract class BaseCommand extends Command { return; } if (this.outputMode === "ndjson") { - for await (const page of iteratePages(fetchPage, query)) { + for await (const page of iteratePages(fetchPage, query, nextQuery)) { await this.output(page.results, { supportsNdjson: true }); } return; } const pages: ListPage[] = []; - for await (const page of iteratePages(fetchPage, query)) pages.push(page); + for await (const page of iteratePages(fetchPage, query, nextQuery)) pages.push(page); await this.output(mergePages(pages)); } diff --git a/src/lib/pagination.ts b/src/lib/pagination.ts index e8ffb41..bdf5965 100644 --- a/src/lib/pagination.ts +++ b/src/lib/pagination.ts @@ -1,5 +1,5 @@ /** - * Pagination for list routes: `--limit`, `--offset` and `--all`. + * Pagination for list routes: `--limit`, `--offset` or `--from`, and `--all`. * See docs/contract/output.md, section Pagination. */ @@ -11,6 +11,15 @@ export interface OffsetQuery { offset?: number | undefined; } +/** The query of a route that pages with a cursor: tasks and batches. */ +export interface FromQuery { + limit?: number | undefined; + from?: number | undefined; +} + +/** The envelope fields that describe one page rather than the whole list. */ +const PAGE_FIELDS = ["limit", "offset", "from"]; + function numberField(page: ListPage, name: string): number { const value = page[name]; return typeof value === "number" ? value : 0; @@ -23,24 +32,37 @@ export function nextOffsetQuery(query: OffsetQuery, page: ListPage): OffsetQuery return done ? undefined : { ...query, offset }; } -/** Fetches page after page until the end. */ -export async function* iteratePages

( - fetchPage: (query: OffsetQuery) => Promise

, - query: OffsetQuery, +/** + * The query of the page after `page` on a cursor route: the API answers the + * `from` of the next page in `next`, and a `next` that is not a number, null + * on the last page, ends the walk. + */ +export function nextFromQuery(query: FromQuery, page: ListPage): FromQuery | undefined { + const next = page.next; + return typeof next === "number" ? { ...query, from: next } : undefined; +} + +/** Fetches page after page until `nextQuery` says there is no page left. */ +export async function* iteratePages( + fetchPage: (query: Q) => Promise

, + query: Q, + nextQuery: (query: Q, page: ListPage) => Q | undefined, ): AsyncGenerator { - let current: OffsetQuery | undefined = query; + let current: Q | undefined = query; while (current !== undefined) { const page: P = await fetchPage(current); yield page; - current = nextOffsetQuery(current, page); + current = nextQuery(current, page); } } /** - * The envelope of the last page with `results` replaced by every item and - * `limit` and `offset` removed. Other fields, like `total`, stay. + * The envelope of the last page with `results` replaced by every item and the + * fields that describe one page removed. The others, like `total`, stay. A + * route carries one pair or the other, so dropping all three is dropping its. */ export function mergePages(pages: readonly ListPage[]): ListPage { - const { limit: _limit, offset: _offset, ...last } = pages.at(-1) ?? { results: [] }; + const last = { ...(pages.at(-1) ?? { results: [] }) }; + for (const field of PAGE_FIELDS) delete last[field]; return { ...last, results: pages.flatMap((page) => page.results) }; } diff --git a/src/lib/task-filters.ts b/src/lib/task-filters.ts new file mode 100644 index 0000000..6082267 --- /dev/null +++ b/src/lib/task-filters.ts @@ -0,0 +1,90 @@ +/** + * The selectors the three task routes share: `GET /tasks`, `POST /tasks/cancel` + * and `DELETE /tasks` take the same query parameters, so `get tasks`, + * `cancel tasks` and `delete tasks` spread the same flags and build the same + * query. Named by what they identify, with the API spelling as an alias + * (docs/contract/commands.md, rule 5). + */ +import { Flags, type Interfaces } from "@oclif/core"; +import type { DeleteOrCancelTasksQuery } from "meilisearch"; + +export const taskFilterFlags = { + ids: Flags.string({ + aliases: ["uids", "id"], + description: "Task uids to match, comma-separated. `*` matches every one.", + helpValue: "", + }), + "batch-ids": Flags.string({ + aliases: ["batch-uids"], + description: "Match the tasks of these batch uids, comma-separated. `*` matches every one.", + helpValue: "", + }), + "index-ids": Flags.string({ + aliases: ["index-uids"], + description: "Match the tasks of these indexes, comma-separated. `*` matches every one.", + helpValue: "", + }), + "canceled-by": Flags.string({ + description: + "Match the tasks canceled by these taskCancelation uids, comma-separated. `*` matches every one.", + helpValue: "", + }), + types: Flags.string({ + description: + "Task types to match, comma-separated, as the `type` field of a task spells them, for example `documentAdditionOrUpdate`. `*` matches every one.", + helpValue: "", + }), + statuses: Flags.string({ + description: + "Task statuses to match, comma-separated: enqueued, processing, succeeded, failed or canceled. `*` matches every one.", + helpValue: "", + }), + "after-enqueued-at": Flags.string({ + description: "Match the tasks enqueued after this date, in RFC 3339 format.", + helpValue: "", + }), + "before-enqueued-at": Flags.string({ + description: "Match the tasks enqueued before this date, in RFC 3339 format.", + helpValue: "", + }), + "after-started-at": Flags.string({ + description: "Match the tasks started after this date, in RFC 3339 format.", + helpValue: "", + }), + "before-started-at": Flags.string({ + description: "Match the tasks started before this date, in RFC 3339 format.", + helpValue: "", + }), + "after-finished-at": Flags.string({ + description: "Match the tasks finished after this date, in RFC 3339 format.", + helpValue: "", + }), + "before-finished-at": Flags.string({ + description: "Match the tasks finished before this date, in RFC 3339 format.", + helpValue: "", + }), +}; + +export type TaskFilterFlagValues = Interfaces.InferredFlags; + +/** The filters of a task route, read from the flags the three commands share. */ +export function taskFilterQuery(flags: TaskFilterFlagValues): DeleteOrCancelTasksQuery { + const query = { + uids: flags.ids, + batchUids: flags["batch-ids"], + indexUids: flags["index-ids"], + canceledBy: flags["canceled-by"], + types: flags.types, + statuses: flags.statuses, + afterEnqueuedAt: flags["after-enqueued-at"], + beforeEnqueuedAt: flags["before-enqueued-at"], + afterStartedAt: flags["after-started-at"], + beforeStartedAt: flags["before-started-at"], + afterFinishedAt: flags["after-finished-at"], + beforeFinishedAt: flags["before-finished-at"], + }; + // The comma-separated list a flag carries is already the API's own GET syntax, + // so the text travels as it is. meilisearch-js types the identifier lists as + // arrays of numbers, which it joins back into the same request line. + return query as DeleteOrCancelTasksQuery; +} diff --git a/src/lib/tasks.ts b/src/lib/tasks.ts index 5324fa3..d89f632 100644 --- a/src/lib/tasks.ts +++ b/src/lib/tasks.ts @@ -16,7 +16,7 @@ export function isTaskFinal(task: Pick): boolean { return FINAL_STATUSES.has(task.status); } -/** True when the task ended with `failed` or `canceled`: exit code 8. */ +/** True when the task ended with `failed` or `canceled`: exit code 7. */ export function isTaskFailure(task: Pick): boolean { return FAILURE_STATUSES.has(task.status); } @@ -75,7 +75,7 @@ export async function waitForTask( throw new MeiliCliError( "cli_timeout", `Task ${uid} did not reach a final status within ${formatDuration(options.timeoutMs)}. It keeps running on the server.`, - { hint: `Run \`meili task get ${uid}\` to check its status.` }, + { hint: `Run \`meili get tasks --ids ${uid}\` to check its status.` }, ); } await sleep(Math.min(interval, remaining)); diff --git a/test/commands/cancel/__snapshots__/tasks.test.ts.snap b/test/commands/cancel/__snapshots__/tasks.test.ts.snap new file mode 100644 index 0000000..cbed0fb --- /dev/null +++ b/test/commands/cancel/__snapshots__/tasks.test.ts.snap @@ -0,0 +1,93 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`meili cancel tasks > 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 cancel tasks > prints its help without any network call 1`] = ` +"Cancel the enqueued and processing tasks matched by --ids, --statuses, --types and the other filters of the route. + +USAGE + $ meili cancel tasks [-o ] [--debug] [--project-url ] + [--project-api-key ] [--project ] [--ids ] [--batch-ids + ] [--index-ids ] [--canceled-by ] [--types ] + [--statuses ] [--after-enqueued-at ] [--before-enqueued-at + ] [--after-started-at ] [--before-started-at ] + [--after-finished-at ] [--before-finished-at ] [--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. + --after-enqueued-at= Match the tasks enqueued after this date, in + RFC 3339 format. + --after-finished-at= Match the tasks finished after this date, in + RFC 3339 format. + --after-started-at= Match the tasks started after this date, in + RFC 3339 format. + --batch-ids= Match the tasks of these batch uids, + comma-separated. \`*\` matches every one. + --before-enqueued-at= Match the tasks enqueued before this date, in + RFC 3339 format. + --before-finished-at= Match the tasks finished before this date, in + RFC 3339 format. + --before-started-at= Match the tasks started before this date, in + RFC 3339 format. + --canceled-by= Match the tasks canceled by these + taskCancelation uids, comma-separated. \`*\` + matches every one. + --debug Print every HTTP request and response on + stderr, secrets redacted. + --ids= Task uids to match, comma-separated. \`*\` + matches every one. + --index-ids= Match the tasks of these indexes, + comma-separated. \`*\` matches every one. + --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. + --statuses= Task statuses to match, comma-separated: + enqueued, processing, succeeded, failed or + canceled. \`*\` matches every one. + --timeout= Maximum wait time, for example 30s, 2m. + Default 60s. Only with --wait. + --types= Task types to match, comma-separated, as the + \`type\` field of a task spells them, for + example \`documentAdditionOrUpdate\`. \`*\` + matches every one. + --wait Poll the task until it reaches a final + status, then print the final task. + +DESCRIPTION + Cancel the enqueued and processing tasks matched by --ids, --statuses, --types + and the other filters of the route. + +EXAMPLES + $ meili cancel tasks --ids 42,1344 + + $ meili cancel tasks --ids 42 --output json + + $ meili cancel tasks --statuses enqueued --index-ids movies + + $ meili cancel tasks --statuses processing --wait + +" +`; + +exports[`meili cancel tasks > prints the enqueued task as the API returned it, without waiting 1`] = ` +{ + "exitCode": 0, + "stderr": "", + "stdout": "{"taskUid":7,"indexUid":null,"status":"enqueued","type":"taskCancelation","enqueuedAt":"2026-09-24T10:00:00Z"} +", +} +`; diff --git a/test/commands/cancel/tasks.test.ts b/test/commands/cancel/tasks.test.ts new file mode 100644 index 0000000..10cc741 --- /dev/null +++ b/test/commands/cancel/tasks.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import CancelTasks from "../../../src/commands/cancel/tasks.js"; +import { + invalidApiKeyError, + MEILISEARCH_URL, + makeEnqueuedTask, + makeTask, + 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 canceling = makeEnqueuedTask({ taskUid: 7, indexUid: null, type: "taskCancelation" }); + +describe("meili cancel tasks", () => { + it("prints its help without any network call", async () => { + const result = await runCli(["cancel", "tasks", "--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.tasksCancel(canceling)); + const result = await runCommand(CancelTasks, ["--ids", "42,1344", "--output", "json"], { env }); + expect(result).toMatchSnapshot(); + }); + + it("sends the filters as the query parameters of the route", async () => { + server.use(meilisearchHandlers.tasksCancel(canceling)); + const requests = await recordRequests(async () => { + await runCommand( + CancelTasks, + ["--statuses", "enqueued,processing", "--index-ids", "movies"], + { env }, + ); + }); + expect(requests).toHaveLength(1); + expect(requests[0]?.method).toBe("POST"); + expect(requests[0]?.url.pathname).toBe("/tasks/cancel"); + expect(Object.fromEntries(requests[0]?.url.searchParams ?? [])).toEqual({ + statuses: "enqueued,processing", + indexUids: "movies", + }); + }); + + it("polls the task to its final status with --wait", async () => { + server.use( + meilisearchHandlers.tasksCancel(canceling), + meilisearchHandlers.taskSequence(7, [ + makeTask({ uid: 7, status: "processing", indexUid: null, type: "taskCancelation" }), + makeTask({ uid: 7, status: "succeeded", indexUid: null, type: "taskCancelation" }), + ]), + ); + const result = await runCommand( + CancelTasks, + ["--statuses", "enqueued", "--wait", "--output", "json"], + { env }, + ); + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ uid: 7, status: "succeeded" }); + }); + + it("lets the API refuse a call without any filter and exits 1", async () => { + server.use( + meilisearchHandlers.apiError("/tasks/cancel", 400, { + message: + "Query parameters to filter the tasks to cancel are missing. Available query parameters are: `uids`, `indexUids`, `statuses`, `types`, `canceledBy`, `beforeEnqueuedAt`, `afterEnqueuedAt`, `beforeStartedAt`, `afterStartedAt`, `beforeFinishedAt`, `afterFinishedAt`.", + code: "missing_task_filters", + type: "invalid_request", + link: "https://docs.meilisearch.com/errors#missing_task_filters", + }), + ); + const result = await runCommand(CancelTasks, ["--output", "json"], { env }); + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + expect(JSON.parse(result.stderr).error.code).toBe("missing_task_filters"); + }); + + it("passes an authentication error through with a hint and exits 3", async () => { + server.use(meilisearchHandlers.apiError("/tasks/cancel", 403, invalidApiKeyError)); + const result = await runCommand(CancelTasks, ["--ids", "42", "--output", "json"], { env }); + expect(result).toMatchSnapshot(); + }); +}); diff --git a/test/commands/delete/__snapshots__/tasks.test.ts.snap b/test/commands/delete/__snapshots__/tasks.test.ts.snap new file mode 100644 index 0000000..a16fa5f --- /dev/null +++ b/test/commands/delete/__snapshots__/tasks.test.ts.snap @@ -0,0 +1,104 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`meili delete tasks > 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 tasks > prints its help without any network call 1`] = ` +"Delete the finished tasks matched by --ids, --statuses, --types and the other filters of the route. + +USAGE + $ meili delete tasks --yes [-o ] [--debug] [--project-url ] + [--project-api-key ] [--project ] [--ids ] [--batch-ids + ] [--index-ids ] [--canceled-by ] [--types ] + [--statuses ] [--after-enqueued-at ] [--before-enqueued-at + ] [--after-started-at ] [--before-started-at ] + [--after-finished-at ] [--before-finished-at ] [--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. + --after-enqueued-at= Match the tasks enqueued after this date, in + RFC 3339 format. + --after-finished-at= Match the tasks finished after this date, in + RFC 3339 format. + --after-started-at= Match the tasks started after this date, in + RFC 3339 format. + --batch-ids= Match the tasks of these batch uids, + comma-separated. \`*\` matches every one. + --before-enqueued-at= Match the tasks enqueued before this date, in + RFC 3339 format. + --before-finished-at= Match the tasks finished before this date, in + RFC 3339 format. + --before-started-at= Match the tasks started before this date, in + RFC 3339 format. + --canceled-by= Match the tasks canceled by these + taskCancelation uids, comma-separated. \`*\` + matches every one. + --debug Print every HTTP request and response on + stderr, secrets redacted. + --ids= Task uids to match, comma-separated. \`*\` + matches every one. + --index-ids= Match the tasks of these indexes, + comma-separated. \`*\` matches every one. + --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. + --statuses= Task statuses to match, comma-separated: + enqueued, processing, succeeded, failed or + canceled. \`*\` matches every one. + --timeout= Maximum wait time, for example 30s, 2m. + Default 60s. Only with --wait. + --types= Task types to match, comma-separated, as the + \`type\` field of a task spells them, for + example \`documentAdditionOrUpdate\`. \`*\` + matches every one. + --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 + Delete the finished tasks matched by --ids, --statuses, --types and the other + filters of the route. + +EXAMPLES + $ meili delete tasks --ids 42,1344 --yes + + $ meili delete tasks --ids 42 --yes --output json + + $ meili delete tasks --statuses succeeded --before-enqueued-at 2026-01-01T00:00:00Z --yes + + $ meili delete tasks --index-ids movies --yes --wait + +" +`; + +exports[`meili delete tasks > prints the enqueued task as the API returned it, without waiting 1`] = ` +{ + "exitCode": 0, + "stderr": "", + "stdout": "{"taskUid":8,"indexUid":null,"status":"enqueued","type":"taskDeletion","enqueuedAt":"2026-09-24T10:00:00Z"} +", +} +`; + +exports[`meili delete tasks > refuses to run without a filter, before any network call 1`] = ` +{ + "exitCode": 2, + "stderr": "{"error":{"code":"cli_usage","type":"cli","message":"The tasks to delete are missing.","hint":"Name them with \`--ids \`, or match them with \`--statuses \`, \`--types \`, \`--index-ids \` or a date filter such as \`--before-enqueued-at \`."}} +", + "stdout": "", +} +`; diff --git a/test/commands/delete/tasks.test.ts b/test/commands/delete/tasks.test.ts new file mode 100644 index 0000000..ab5cd87 --- /dev/null +++ b/test/commands/delete/tasks.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import DeleteTasks from "../../../src/commands/delete/tasks.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 deleting = makeEnqueuedTask({ taskUid: 8, indexUid: null, type: "taskDeletion" }); + +describe("meili delete tasks", () => { + it("prints its help without any network call", async () => { + const result = await runCli(["delete", "tasks", "--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.tasksDelete(deleting)); + const result = await runCommand( + DeleteTasks, + ["--ids", "42,1344", "--yes", "--output", "json"], + { + env, + }, + ); + expect(result).toMatchSnapshot(); + }); + + it("sends the filters as the query parameters of the route", async () => { + server.use(meilisearchHandlers.tasksDelete(deleting)); + const requests = await recordRequests(async () => { + await runCommand( + DeleteTasks, + ["--statuses", "succeeded", "--before-enqueued-at", "2026-01-01T00:00:00Z", "--yes"], + { env }, + ); + }); + expect(requests).toHaveLength(1); + expect(requests[0]?.method).toBe("DELETE"); + expect(requests[0]?.url.pathname).toBe("/tasks"); + expect(Object.fromEntries(requests[0]?.url.searchParams ?? [])).toEqual({ + statuses: "succeeded", + beforeEnqueuedAt: "2026-01-01T00:00:00Z", + }); + }); + + it("refuses to run without --yes, before any network call", async () => { + let result: Awaited> | undefined; + const requests = await countRequests(async () => { + result = await runCommand(DeleteTasks, ["--ids", "42"], { 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("refuses to run without a filter, before any network call", async () => { + let result: Awaited> | undefined; + const requests = await countRequests(async () => { + result = await runCommand(DeleteTasks, ["--yes", "--output", "json"], { env }); + }); + expect(requests).toBe(0); + expect(result).toMatchSnapshot(); + }); + + it("passes an authentication error through with a hint and exits 3", async () => { + server.use(meilisearchHandlers.apiError("/tasks", 403, invalidApiKeyError)); + const result = await runCommand(DeleteTasks, ["--ids", "42", "--yes", "--output", "json"], { + env, + }); + expect(result).toMatchSnapshot(); + }); +}); diff --git a/test/commands/get/__snapshots__/tasks.test.ts.snap b/test/commands/get/__snapshots__/tasks.test.ts.snap new file mode 100644 index 0000000..07f3b78 --- /dev/null +++ b/test/commands/get/__snapshots__/tasks.test.ts.snap @@ -0,0 +1,109 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`meili get tasks > 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 tasks > prints its help without any network call 1`] = ` +"List the tasks of the instance, narrowed by --ids, --statuses, --types and the other filters of the route. + +USAGE + $ meili get tasks [-o ] [--debug] [--project-url ] + [--project-api-key ] [--project ] [--ids ] [--batch-ids + ] [--index-ids ] [--canceled-by ] [--types ] + [--statuses ] [--after-enqueued-at ] [--before-enqueued-at + ] [--after-started-at ] [--before-started-at ] + [--after-finished-at ] [--before-finished-at ] [--reverse] + [--limit ] [--from ] [--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. + --after-enqueued-at= Match the tasks enqueued after this date, in + RFC 3339 format. + --after-finished-at= Match the tasks finished after this date, in + RFC 3339 format. + --after-started-at= Match the tasks started after this date, in + RFC 3339 format. + --all Fetch every page. Streams with ndjson, + buffers into one array with json. + --batch-ids= Match the tasks of these batch uids, + comma-separated. \`*\` matches every one. + --before-enqueued-at= Match the tasks enqueued before this date, in + RFC 3339 format. + --before-finished-at= Match the tasks finished before this date, in + RFC 3339 format. + --before-started-at= Match the tasks started before this date, in + RFC 3339 format. + --canceled-by= Match the tasks canceled by these + taskCancelation uids, comma-separated. \`*\` + matches every one. + --debug Print every HTTP request and response on + stderr, secrets redacted. + --from= Passed to the API: the uid the page starts + at, going down. + --ids= Task uids to match, comma-separated. \`*\` + matches every one. + --index-ids= Match the tasks of these indexes, + comma-separated. \`*\` matches every one. + --limit= 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. + --reverse Return the tasks from the oldest uid up, + instead of the newest down. + --statuses= Task statuses to match, comma-separated: + enqueued, processing, succeeded, failed or + canceled. \`*\` matches every one. + --types= Task types to match, comma-separated, as the + \`type\` field of a task spells them, for + example \`documentAdditionOrUpdate\`. \`*\` + matches every one. + +DESCRIPTION + List the tasks of the instance, narrowed by --ids, --statuses, --types and the + other filters of the route. + +EXAMPLES + $ meili get tasks + + $ meili get tasks --output json + + $ meili get tasks --ids 42 + + $ meili get tasks --statuses failed,canceled --limit 5 + + $ meili get tasks --index-ids movies --types documentAdditionOrUpdate + + $ meili get tasks --all --output ndjson + +" +`; + +exports[`meili get tasks > prints the API envelope as it is in JSON 1`] = ` +{ + "exitCode": 0, + "stderr": "", + "stdout": "{"results":[{"indexUid":"movies","type":"indexCreation","enqueuedAt":"2026-09-24T10:00:00Z","startedAt":null,"finishedAt":null,"duration":null,"batchUid":null,"canceledBy":null,"uid":5,"status":"enqueued"},{"indexUid":"movies","type":"indexCreation","enqueuedAt":"2026-09-24T10:00:00Z","startedAt":null,"finishedAt":null,"duration":null,"batchUid":null,"canceledBy":null,"uid":4,"status":"enqueued"},{"indexUid":"movies","type":"indexCreation","enqueuedAt":"2026-09-24T10:00:00Z","startedAt":null,"finishedAt":null,"duration":null,"batchUid":null,"canceledBy":null,"uid":3,"status":"succeeded"},{"indexUid":"movies","type":"indexCreation","enqueuedAt":"2026-09-24T10:00:00Z","startedAt":null,"finishedAt":null,"duration":null,"batchUid":null,"canceledBy":null,"uid":2,"status":"succeeded"},{"indexUid":"movies","type":"indexCreation","enqueuedAt":"2026-09-24T10:00:00Z","startedAt":null,"finishedAt":null,"duration":null,"batchUid":null,"canceledBy":null,"uid":1,"status":"succeeded"}],"total":5,"limit":20,"from":5,"next":null} +", +} +`; + +exports[`meili get tasks > returns the list envelope when --ids narrows it to one task 1`] = ` +{ + "exitCode": 0, + "stderr": "", + "stdout": "{"results":[{"indexUid":"movies","type":"indexCreation","enqueuedAt":"2026-09-24T10:00:00Z","startedAt":null,"finishedAt":null,"duration":null,"batchUid":null,"canceledBy":null,"uid":3,"status":"succeeded"}],"total":1,"limit":20,"from":3,"next":null} +", +} +`; diff --git a/test/commands/get/tasks.test.ts b/test/commands/get/tasks.test.ts new file mode 100644 index 0000000..7e848c7 --- /dev/null +++ b/test/commands/get/tasks.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; +import GetTasks from "../../../src/commands/get/tasks.js"; +import { + invalidApiKeyError, + MEILISEARCH_URL, + makeTask, + 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 five = [5, 4, 3, 2, 1].map((uid) => + makeTask({ uid, status: uid > 3 ? "enqueued" : "succeeded" }), +); + +describe("meili get tasks", () => { + it("prints its help without any network call", async () => { + const result = await runCli(["get", "tasks", "--help"]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toMatchSnapshot(); + }); + + it("prints the API envelope as it is in JSON", async () => { + server.use(meilisearchHandlers.tasks(five)); + const result = await runCommand(GetTasks, ["--output", "json"], { env }); + expect(result).toMatchSnapshot(); + }); + + it("returns the list envelope when --ids narrows it to one task", async () => { + server.use(meilisearchHandlers.tasks(five)); + const result = await runCommand(GetTasks, ["--ids", "3", "--output", "json"], { env }); + expect(result).toMatchSnapshot(); + }); + + it("sends every filter as the query parameter of the route, the text as it is", async () => { + server.use(meilisearchHandlers.tasks(five)); + const requests = await recordRequests(async () => { + await runCommand( + GetTasks, + [ + "--ids", + "1,2", + "--batch-ids", + "7", + "--index-ids", + "movies", + "--canceled-by", + "9", + "--types", + "documentAdditionOrUpdate,indexDeletion", + "--statuses", + "failed,canceled", + "--before-enqueued-at", + "2026-01-01T00:00:00Z", + "--reverse", + "--limit", + "2", + "--from", + "4", + ], + { env }, + ); + }); + expect(requests).toHaveLength(1); + expect(Object.fromEntries(requests[0]?.url.searchParams ?? [])).toEqual({ + uids: "1,2", + batchUids: "7", + indexUids: "movies", + canceledBy: "9", + types: "documentAdditionOrUpdate,indexDeletion", + statuses: "failed,canceled", + beforeEnqueuedAt: "2026-01-01T00:00:00Z", + reverse: "true", + limit: "2", + from: "4", + }); + }); + + it("accepts the API spelling of the identifier flags as an alias", async () => { + server.use(meilisearchHandlers.tasks(five)); + const requests = await recordRequests(async () => { + await runCommand(GetTasks, ["--uids", "3", "--index-uids", "movies"], { env }); + }); + expect(Object.fromEntries(requests[0]?.url.searchParams ?? [])).toEqual({ + uids: "3", + indexUids: "movies", + }); + }); + + it("follows the next cursor with --all and merges every page into one envelope", async () => { + server.use(meilisearchHandlers.tasks(five)); + let result: Awaited> | undefined; + const requests = await countRequests(async () => { + result = await runCommand(GetTasks, ["--all", "--limit", "2", "--output", "json"], { env }); + }); + expect(requests).toBe(3); + expect(result?.exitCode).toBe(0); + expect(JSON.parse(result?.stdout ?? "")).toEqual({ results: five, total: 5, next: null }); + }); + + it("streams every task with --all in ndjson", async () => { + server.use(meilisearchHandlers.tasks(five)); + const result = await runCommand(GetTasks, ["--all", "--limit", "2", "--output", "ndjson"], { + env, + }); + expect(result.exitCode).toBe(0); + const lines = result.stdout.trimEnd().split("\n"); + expect(lines.map((line) => (JSON.parse(line) as { uid: number }).uid)).toEqual([5, 4, 3, 2, 1]); + }); + + it("passes an authentication error through with a hint and exits 3", async () => { + server.use(meilisearchHandlers.apiError("/tasks", 403, invalidApiKeyError)); + const result = await runCommand(GetTasks, ["--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 9a69ec5..81ff080 100644 --- a/test/contract/__snapshots__/commands.test.ts.snap +++ b/test/contract/__snapshots__/commands.test.ts.snap @@ -239,6 +239,130 @@ exports[`oclif.manifest.json > \`meili schema\` is a snapshot, the index and eve "flags": [], "id": "auth status", }, + { + "api": "meilisearch", + "async": true, + "description": "Cancel the enqueued and processing tasks matched by --ids, --statuses, --types and the other filters of the route.", + "examples": [ + "meili cancel tasks --ids 42,1344", + "meili cancel tasks --ids 42 --output json", + "meili cancel tasks --statuses enqueued --index-ids movies", + "meili cancel tasks --statuses processing --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": "Task uids to match, comma-separated. \`*\` matches every one.", + "helpValue": "", + "name": "ids", + "type": "option", + }, + { + "description": "Match the tasks of these batch uids, comma-separated. \`*\` matches every one.", + "helpValue": "", + "name": "batch-ids", + "type": "option", + }, + { + "description": "Match the tasks of these indexes, comma-separated. \`*\` matches every one.", + "helpValue": "", + "name": "index-ids", + "type": "option", + }, + { + "description": "Match the tasks canceled by these taskCancelation uids, comma-separated. \`*\` matches every one.", + "helpValue": "", + "name": "canceled-by", + "type": "option", + }, + { + "description": "Task types to match, comma-separated, as the \`type\` field of a task spells them, for example \`documentAdditionOrUpdate\`. \`*\` matches every one.", + "helpValue": "", + "name": "types", + "type": "option", + }, + { + "description": "Task statuses to match, comma-separated: enqueued, processing, succeeded, failed or canceled. \`*\` matches every one.", + "helpValue": "", + "name": "statuses", + "type": "option", + }, + { + "description": "Match the tasks enqueued after this date, in RFC 3339 format.", + "helpValue": "", + "name": "after-enqueued-at", + "type": "option", + }, + { + "description": "Match the tasks enqueued before this date, in RFC 3339 format.", + "helpValue": "", + "name": "before-enqueued-at", + "type": "option", + }, + { + "description": "Match the tasks started after this date, in RFC 3339 format.", + "helpValue": "", + "name": "after-started-at", + "type": "option", + }, + { + "description": "Match the tasks started before this date, in RFC 3339 format.", + "helpValue": "", + "name": "before-started-at", + "type": "option", + }, + { + "description": "Match the tasks finished after this date, in RFC 3339 format.", + "helpValue": "", + "name": "after-finished-at", + "type": "option", + }, + { + "description": "Match the tasks finished before this date, in RFC 3339 format.", + "helpValue": "", + "name": "before-finished-at", + "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": "cancel tasks", + "keyActions": [ + "tasks.cancel", + ], + "routes": [ + "POST /tasks/cancel", + ], + }, { "api": "none", "description": "Print the config file path and its projects as JSON, keys masked.", @@ -488,6 +612,136 @@ The file holds API keys and decides where every later command goes, for every pr "DELETE /indexes/{index_uid}", ], }, + { + "api": "meilisearch", + "async": true, + "description": "Delete the finished tasks matched by --ids, --statuses, --types and the other filters of the route.", + "examples": [ + "meili delete tasks --ids 42,1344 --yes", + "meili delete tasks --ids 42 --yes --output json", + "meili delete tasks --statuses succeeded --before-enqueued-at 2026-01-01T00:00:00Z --yes", + "meili delete tasks --index-ids 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": "Task uids to match, comma-separated. \`*\` matches every one.", + "helpValue": "", + "name": "ids", + "type": "option", + }, + { + "description": "Match the tasks of these batch uids, comma-separated. \`*\` matches every one.", + "helpValue": "", + "name": "batch-ids", + "type": "option", + }, + { + "description": "Match the tasks of these indexes, comma-separated. \`*\` matches every one.", + "helpValue": "", + "name": "index-ids", + "type": "option", + }, + { + "description": "Match the tasks canceled by these taskCancelation uids, comma-separated. \`*\` matches every one.", + "helpValue": "", + "name": "canceled-by", + "type": "option", + }, + { + "description": "Task types to match, comma-separated, as the \`type\` field of a task spells them, for example \`documentAdditionOrUpdate\`. \`*\` matches every one.", + "helpValue": "", + "name": "types", + "type": "option", + }, + { + "description": "Task statuses to match, comma-separated: enqueued, processing, succeeded, failed or canceled. \`*\` matches every one.", + "helpValue": "", + "name": "statuses", + "type": "option", + }, + { + "description": "Match the tasks enqueued after this date, in RFC 3339 format.", + "helpValue": "", + "name": "after-enqueued-at", + "type": "option", + }, + { + "description": "Match the tasks enqueued before this date, in RFC 3339 format.", + "helpValue": "", + "name": "before-enqueued-at", + "type": "option", + }, + { + "description": "Match the tasks started after this date, in RFC 3339 format.", + "helpValue": "", + "name": "after-started-at", + "type": "option", + }, + { + "description": "Match the tasks started before this date, in RFC 3339 format.", + "helpValue": "", + "name": "before-started-at", + "type": "option", + }, + { + "description": "Match the tasks finished after this date, in RFC 3339 format.", + "helpValue": "", + "name": "after-finished-at", + "type": "option", + }, + { + "description": "Match the tasks finished before this date, in RFC 3339 format.", + "helpValue": "", + "name": "before-finished-at", + "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 tasks", + "keyActions": [ + "tasks.delete", + ], + "routes": [ + "DELETE /tasks", + ], + }, { "api": "meilisearch", "description": "List the documents of an index, narrowed by --ids or by --filter.", @@ -689,6 +943,139 @@ The file holds API keys and decides where every later command goes, for every pr "GET /indexes/{index_uid}", ], }, + { + "api": "meilisearch", + "description": "List the tasks of the instance, narrowed by --ids, --statuses, --types and the other filters of the route.", + "examples": [ + "meili get tasks", + "meili get tasks --output json", + "meili get tasks --ids 42", + "meili get tasks --statuses failed,canceled --limit 5", + "meili get tasks --index-ids movies --types documentAdditionOrUpdate", + "meili get tasks --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": "Task uids to match, comma-separated. \`*\` matches every one.", + "helpValue": "", + "name": "ids", + "type": "option", + }, + { + "description": "Match the tasks of these batch uids, comma-separated. \`*\` matches every one.", + "helpValue": "", + "name": "batch-ids", + "type": "option", + }, + { + "description": "Match the tasks of these indexes, comma-separated. \`*\` matches every one.", + "helpValue": "", + "name": "index-ids", + "type": "option", + }, + { + "description": "Match the tasks canceled by these taskCancelation uids, comma-separated. \`*\` matches every one.", + "helpValue": "", + "name": "canceled-by", + "type": "option", + }, + { + "description": "Task types to match, comma-separated, as the \`type\` field of a task spells them, for example \`documentAdditionOrUpdate\`. \`*\` matches every one.", + "helpValue": "", + "name": "types", + "type": "option", + }, + { + "description": "Task statuses to match, comma-separated: enqueued, processing, succeeded, failed or canceled. \`*\` matches every one.", + "helpValue": "", + "name": "statuses", + "type": "option", + }, + { + "description": "Match the tasks enqueued after this date, in RFC 3339 format.", + "helpValue": "", + "name": "after-enqueued-at", + "type": "option", + }, + { + "description": "Match the tasks enqueued before this date, in RFC 3339 format.", + "helpValue": "", + "name": "before-enqueued-at", + "type": "option", + }, + { + "description": "Match the tasks started after this date, in RFC 3339 format.", + "helpValue": "", + "name": "after-started-at", + "type": "option", + }, + { + "description": "Match the tasks started before this date, in RFC 3339 format.", + "helpValue": "", + "name": "before-started-at", + "type": "option", + }, + { + "description": "Match the tasks finished after this date, in RFC 3339 format.", + "helpValue": "", + "name": "after-finished-at", + "type": "option", + }, + { + "description": "Match the tasks finished before this date, in RFC 3339 format.", + "helpValue": "", + "name": "before-finished-at", + "type": "option", + }, + { + "description": "Return the tasks from the oldest uid up, instead of the newest down.", + "name": "reverse", + "type": "boolean", + }, + { + "description": "Passed to the API.", + "helpValue": "", + "name": "limit", + "type": "option", + }, + { + "description": "Passed to the API: the uid the page starts at, going down.", + "helpValue": "", + "name": "from", + "type": "option", + }, + { + "description": "Fetch every page. Streams with ndjson, buffers into one array with json.", + "name": "all", + "type": "boolean", + }, + ], + "id": "get tasks", + "keyActions": [ + "tasks.get", + ], + "routes": [ + "GET /tasks", + ], + }, { "api": "none", "description": "Print one line per command, or one command in full with --command, from the oclif manifest.", @@ -1140,6 +1527,13 @@ The file holds API keys and decides where every later command goes, for every pr "description": "Say whether a session of \`auth login\` exists and whether the authorization server still accepts it.", "id": "auth status", }, + { + "description": "Cancel the enqueued and processing tasks matched by --ids, --statuses, --types and the other filters of the route.", + "id": "cancel tasks", + "routes": [ + "POST /tasks/cancel", + ], + }, { "description": "Print the config file path and its projects as JSON, keys masked.", "id": "config list", @@ -1168,6 +1562,13 @@ The file holds API keys and decides where every later command goes, for every pr "DELETE /indexes/{index_uid}", ], }, + { + "description": "Delete the finished tasks matched by --ids, --statuses, --types and the other filters of the route.", + "id": "delete tasks", + "routes": [ + "DELETE /tasks", + ], + }, { "description": "List the documents of an index, narrowed by --ids or by --filter.", "id": "get documents", @@ -1190,6 +1591,13 @@ The file holds API keys and decides where every later command goes, for every pr "GET /indexes/{index_uid}", ], }, + { + "description": "List the tasks of the instance, narrowed by --ids, --statuses, --types and the other filters of the route.", + "id": "get tasks", + "routes": [ + "GET /tasks", + ], + }, { "description": "Print one line per command, or one command in full with --command, from the oclif manifest.", "id": "schema", diff --git a/test/contract/__snapshots__/help.test.ts.snap b/test/contract/__snapshots__/help.test.ts.snap index 37cbb24..a490e1e 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 tasks List the tasks of the instance, narrowed by --ids, --statuses, + --types and the other filters of the route. " `; diff --git a/test/contract/env.test.ts b/test/contract/env.test.ts index 3d7d1b3..1441b3e 100644 --- a/test/contract/env.test.ts +++ b/test/contract/env.test.ts @@ -40,6 +40,7 @@ describe("flags and environment variables share their name (CLAUDE.md, rule 14)" BaseCommand.connectionFlags, BaseCommand.waitFlags, BaseCommand.paginationFlags, + BaseCommand.fromPaginationFlags, BaseCommand.bodyInputFlags, BaseCommand.confirmFlags, ]; diff --git a/test/helpers/meilisearch-handlers.ts b/test/helpers/meilisearch-handlers.ts index 6c62e94..9ab6e61 100644 --- a/test/helpers/meilisearch-handlers.ts +++ b/test/helpers/meilisearch-handlers.ts @@ -117,6 +117,40 @@ export const meilisearchHandlers = { }); }), + /** + * `GET /tasks` paging over `all` like the API does: `limit` defaults to 20, + * the newest uid first, `from` names the uid the page starts at, and the + * envelope carries `from`, `limit`, `total` and the `next` cursor. + */ + tasks: (all: Task[]) => + http.get(`${MEILISEARCH_URL}/tasks`, ({ request }) => { + const params = new URL(request.url).searchParams; + const limit = Number(params.get("limit") ?? 20); + const from = params.get("from"); + const uids = params.get("uids")?.split(","); + const matching = all + .filter((task) => uids === undefined || uids.includes(String(task.uid))) + .sort((a, b) => b.uid - a.uid); + const start = + from === null ? 0 : matching.findIndex((task) => task.uid === Number.parseInt(from, 10)); + const results = matching.slice(start, start + limit); + return HttpResponse.json({ + results, + total: matching.length, + limit, + from: results[0]?.uid ?? null, + next: matching[start + limit]?.uid ?? null, + }); + }), + + /** `POST /tasks/cancel`, the cancelation route. */ + tasksCancel: (task: EnqueuedTask) => + http.post(`${MEILISEARCH_URL}/tasks/cancel`, () => HttpResponse.json(task)), + + /** `DELETE /tasks`, the deletion route. */ + tasksDelete: (task: EnqueuedTask) => + http.delete(`${MEILISEARCH_URL}/tasks`, () => HttpResponse.json(task)), + /** Any method on `path` answering a Meilisearch error body with `status`. */ apiError: (path: string, status: number, body: MeilisearchErrorResponse) => http.all(`${MEILISEARCH_URL}${path}`, () => HttpResponse.json(body, { status })), diff --git a/test/integration/meilisearch/cancel-tasks.test.ts b/test/integration/meilisearch/cancel-tasks.test.ts new file mode 100644 index 0000000..5153797 --- /dev/null +++ b/test/integration/meilisearch/cancel-tasks.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 = `meili-cli-test-cancel-tasks-${randomUUID().slice(0, 8)}`; +let created = 0; + +// Every filter names this suite's own task uid: the suites run in parallel, and +// a cancelation by status would reach the tasks of the others. +describe.skipIf(!meilisearchUp)("meili cancel tasks against a real Meilisearch", () => { + const client = new Meilisearch({ host: MEILISEARCH_URL, apiKey: MASTER_KEY }); + + beforeAll(async () => { + const task = await client.createIndex(uid).waitTask(); + created = task.uid; + }); + + afterAll(async () => { + await client.deleteIndex(uid).waitTask(); + }); + + it("enqueues a taskCancelation and waits for it", async () => { + const result = await runBinary( + ["cancel", "tasks", "--ids", String(created), "--wait", "--output", "json"], + env, + ); + expect(result.exitCode).toBe(0); + // The task has already succeeded, so the cancelation matches nothing: what + // the command owns is the taskCancelation it enqueued and polled. + expect(JSON.parse(result.stdout)).toMatchObject({ + type: "taskCancelation", + status: "succeeded", + }); + }); + + it("prints the enqueued task and returns at once without --wait", async () => { + const result = await runBinary( + ["cancel", "tasks", "--ids", String(created), "--output", "json"], + env, + ); + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + type: "taskCancelation", + status: "enqueued", + }); + }); + + it("exits 1 with the API error when no filter is given", async () => { + const result = await runBinary(["cancel", "tasks", "--output", "json"], env); + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + expect(JSON.parse(result.stderr).error.code).toBe("missing_task_filters"); + }); +}); diff --git a/test/integration/meilisearch/delete-tasks.test.ts b/test/integration/meilisearch/delete-tasks.test.ts new file mode 100644 index 0000000..ea40b1d --- /dev/null +++ b/test/integration/meilisearch/delete-tasks.test.ts @@ -0,0 +1,54 @@ +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 = `meili-cli-test-delete-tasks-${randomUUID().slice(0, 8)}`; +let created = 0; + +// The index is created and dropped in beforeAll, so the only task left to the +// suite is its own: the filters never name anything another suite owns. +describe.skipIf(!meilisearchUp)("meili delete tasks against a real Meilisearch", () => { + const client = new Meilisearch({ host: MEILISEARCH_URL, apiKey: MASTER_KEY }); + + beforeAll(async () => { + const task = await client.createIndex(uid).waitTask(); + created = task.uid; + await client.deleteIndex(uid).waitTask(); + }); + + it("exits 2 and deletes nothing without --yes", async () => { + const result = await runBinary( + ["delete", "tasks", "--ids", String(created), "--output", "json"], + env, + ); + expect(result.exitCode).toBe(2); + expect(result.stdout).toBe(""); + expect(JSON.parse(result.stderr).error.code).toBe("cli_usage"); + await expect(client.tasks.getTask(created)).resolves.toMatchObject({ uid: created }); + }); + + it("exits 2 without a filter, before any network call", async () => { + const result = await runBinary(["delete", "tasks", "--yes", "--output", "json"], env); + expect(result.exitCode).toBe(2); + expect(result.stdout).toBe(""); + expect(JSON.parse(result.stderr).error.code).toBe("cli_usage"); + }); + + it("deletes the task named by --ids", async () => { + const result = await runBinary( + ["delete", "tasks", "--ids", String(created), "--yes", "--wait", "--output", "json"], + env, + ); + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + type: "taskDeletion", + status: "succeeded", + }); + await expect(client.tasks.getTask(created)).rejects.toThrow(); + }); +}); diff --git a/test/integration/meilisearch/get-tasks.test.ts b/test/integration/meilisearch/get-tasks.test.ts new file mode 100644 index 0000000..9f66d80 --- /dev/null +++ b/test/integration/meilisearch/get-tasks.test.ts @@ -0,0 +1,70 @@ +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-tasks-${randomUUID().slice(0, 8)}`; +let created = 0; + +describe.skipIf(!meilisearchUp)("meili get tasks against a real Meilisearch", () => { + const client = new Meilisearch({ host: MEILISEARCH_URL, apiKey: MASTER_KEY }); + + beforeAll(async () => { + const task = await client.createIndex(uid, { primaryKey: "id" }).waitTask(); + created = task.uid; + }); + + afterAll(async () => { + await client.deleteIndex(uid).waitTask(); + }); + + it("returns the list envelope filtered by --index-ids", async () => { + const result = await runBinary( + ["get", "tasks", "--index-ids", uid, "--all", "--output", "json"], + env, + ); + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + const page = JSON.parse(result.stdout) as { results: Array<{ uid: number; type: string }> }; + expect(page.results.map((task) => task.type)).toContain("indexCreation"); + expect(page.results.map((task) => task.uid)).toContain(created); + }); + + it("filters by --ids and still answers the list envelope", async () => { + const result = await runBinary( + ["get", "tasks", "--ids", String(created), "--output", "json"], + env, + ); + expect(result.exitCode).toBe(0); + const page = JSON.parse(result.stdout) as { + results: Array<{ uid: number; indexUid: string; status: string }>; + total: number; + }; + expect(page.total).toBe(1); + expect(page.results[0]).toMatchObject({ uid: created, indexUid: uid, status: "succeeded" }); + }); + + it("streams the tasks of the index as ndjson", async () => { + const result = await runBinary( + ["get", "tasks", "--index-ids", uid, "--all", "--limit", "1", "--output", "ndjson"], + env, + ); + expect(result.exitCode).toBe(0); + const uids = result.stdout + .trimEnd() + .split("\n") + .map((line) => (JSON.parse(line) as { uid: number }).uid); + expect(uids).toContain(created); + }); + + it("exits 1 with the API error when a filter value is not one the API knows", async () => { + const result = await runBinary(["get", "tasks", "--statuses", "nope", "--output", "json"], env); + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + expect(JSON.parse(result.stderr).error.code).toBe("invalid_task_statuses"); + }); +}); diff --git a/test/lib/pagination.test.ts b/test/lib/pagination.test.ts index ba66405..8b0ec7f 100644 --- a/test/lib/pagination.test.ts +++ b/test/lib/pagination.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from "vitest"; -import { iteratePages, mergePages, nextOffsetQuery } from "../../src/lib/pagination.js"; +import { + iteratePages, + mergePages, + nextFromQuery, + nextOffsetQuery, + type OffsetQuery, +} from "../../src/lib/pagination.js"; describe("nextOffsetQuery", () => { it("advances by the number of results until the total is reached", () => { @@ -19,29 +25,75 @@ describe("nextOffsetQuery", () => { }); }); +describe("nextFromQuery", () => { + it("follows the next cursor the API answers", () => { + expect(nextFromQuery({ limit: 2 }, { results: [5, 4], from: 5, limit: 2, next: 3 })).toEqual({ + limit: 2, + from: 3, + }); + }); + + it("stops on the last page, which answers a null next", () => { + expect( + nextFromQuery({ limit: 2, from: 3 }, { results: [3], from: 3, limit: 2, next: null }), + ).toBeUndefined(); + }); +}); + describe("iteratePages", () => { it("fetches page after page and yields each one", async () => { const calls: number[] = []; const pages: unknown[][] = []; - for await (const page of iteratePages(async (query) => { - const offset = query.offset ?? 0; - calls.push(offset); - return { results: [1, 2, 3, 4, 5].slice(offset, offset + 2), offset, limit: 2, total: 5 }; - }, {})) { + for await (const page of iteratePages( + async (query: OffsetQuery) => { + const offset = query.offset ?? 0; + calls.push(offset); + return { results: [1, 2, 3, 4, 5].slice(offset, offset + 2), offset, limit: 2, total: 5 }; + }, + {}, + nextOffsetQuery, + )) { pages.push([...page.results]); } expect(calls).toEqual([0, 2, 4]); expect(pages).toEqual([[1, 2], [3, 4], [5]]); }); + + it("walks a cursor route down from one uid to the next", async () => { + const uids = [5, 4, 3, 2, 1]; + const calls: Array = []; + const pages: unknown[][] = []; + for await (const page of iteratePages( + async (query: { from?: number | undefined }) => { + calls.push(query.from); + const start = uids.indexOf(query.from ?? 5); + const results = uids.slice(start, start + 2); + return { results, from: results[0], limit: 2, next: uids[start + 2] ?? null }; + }, + {}, + nextFromQuery, + )) { + pages.push([...page.results]); + } + expect(calls).toEqual([undefined, 3, 1]); + expect(pages).toEqual([[5, 4], [3, 2], [1]]); + }); }); describe("mergePages", () => { - it("keeps the last envelope, concatenates results and drops limit and offset", () => { + it("keeps the last envelope, concatenates results and drops the fields of one page", () => { expect( mergePages([ { results: [1, 2], offset: 0, limit: 2, total: 3 }, { results: [3], offset: 2, limit: 2, total: 3 }, ]), ).toEqual({ results: [1, 2, 3], total: 3 }); + + expect( + mergePages([ + { results: [5, 4], from: 5, limit: 2, next: 3 }, + { results: [3], from: 3, limit: 2, next: null }, + ]), + ).toEqual({ results: [5, 4, 3], next: null }); }); }); diff --git a/test/lib/tasks.test.ts b/test/lib/tasks.test.ts index 3b72e70..30d7037 100644 --- a/test/lib/tasks.test.ts +++ b/test/lib/tasks.test.ts @@ -82,6 +82,8 @@ describe("waitForTask", () => { expect((error as MeiliCliError).code).toBe("cli_timeout"); expect((error as MeiliCliError).exitCode).toBe(5); expect((error as MeiliCliError).message).toContain("Task 9"); - expect((error as MeiliCliError).hint).toBe("Run `meili task get 9` to check its status."); + expect((error as MeiliCliError).hint).toBe( + "Run `meili get tasks --ids 9` to check its status.", + ); }); });