From 3b3679b43cec1c1819ff1c76b0a725b5ea72718f Mon Sep 17 00:00:00 2001 From: curquiza Date: Fri, 23 Oct 2026 17:51:35 +0200 Subject: [PATCH] `config set`, `config list`, `config unset` The vocabulary of a config file everywhere else: `git config`, `npm config set`, `aws configure set`. An agent arrives with that prior and spends it on the first try. The behaviour does not move. `config set` still refuses a name that already exists, `cli_config`, and points at `config unset`: changing an entry stays an `unset` then a `set`, which ADR 0005 decided on purpose and which isolation through MEILI_CONFIG, not this refusal, really protects. A `set` that overwrites was considered and dropped for that one reason. The ADRs are rewritten as if the decision had been taken on day one, rather than superseded: nothing is released, and ADR 0005, 0008, 0009 and 0010 only ever mention these commands in passing. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/first-version.md | 2 +- README.md | 2 +- docs/adr/0005-named-projects-config-file.md | 4 +- docs/adr/0008-schema-has-two-zoom-levels.md | 4 +- ...0009-oauth-login-for-the-management-api.md | 2 +- ...ags-belong-to-the-commands-that-connect.md | 8 +- docs/contract/commands.md | 8 +- docs/contract/config.md | 4 +- docs/contract/output.md | 2 +- oclif.manifest.json | 86 +++++++++---------- src/commands/config/{add.ts => set.ts} | 10 +-- src/commands/config/{remove.ts => unset.ts} | 4 +- .../{add.test.ts.snap => set.test.ts.snap} | 18 ++-- ...remove.test.ts.snap => unset.test.ts.snap} | 8 +- .../config/{add.test.ts => set.test.ts} | 20 ++--- .../config/{remove.test.ts => unset.test.ts} | 12 +-- test/commands/schema.test.ts | 6 +- .../__snapshots__/commands.test.ts.snap | 50 +++++------ test/contract/commands.test.ts | 6 +- 19 files changed, 128 insertions(+), 128 deletions(-) rename src/commands/config/{add.ts => set.ts} (86%) rename src/commands/config/{remove.ts => unset.ts} (90%) rename test/commands/config/__snapshots__/{add.test.ts.snap => set.test.ts.snap} (68%) rename test/commands/config/__snapshots__/{remove.test.ts.snap => unset.test.ts.snap} (76%) rename test/commands/config/{add.test.ts => set.test.ts} (83%) rename test/commands/config/{remove.test.ts => unset.test.ts} (80%) diff --git a/.changeset/first-version.md b/.changeset/first-version.md index 94bfe06..5f6afab 100644 --- a/.changeset/first-version.md +++ b/.changeset/first-version.md @@ -8,7 +8,7 @@ First version of `meili`, the Meilisearch CLI built for coding agents. - Documents: `meili get documents --index `, narrowed by `--ids` or `--filter`; `meili create 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`. - `meili schema` prints the index of the command tree as JSON, one entry per command with its routes, and `meili schema --command "get indexes"` prints one command in full, with its key actions and whether it returns a task. - Connection through `--project-url` and `--project-api-key`, or `MEILI_PROJECT_URL` and `MEILI_PROJECT_API_KEY`; `MEILI_MANAGEMENT_TOKEN` and `MEILI_MANAGEMENT_URL` for the Management API. -- Several projects in a config file, `~/.config/meili/config.json` or `MEILI_CONFIG`, chosen with `--project` on the command line and never deduced; `meili config list`, `config add` and `config remove` manage the file. +- Several projects in a config file, `~/.config/meili/config.json` or `MEILI_CONFIG`, chosen with `--project` on the command line and never deduced; `meili config list`, `config set` and `config unset` manage the file. - `meili auth login` logs in to Meilisearch Cloud with a browser, through the OAuth2 authorization code flow with PKCE on a loopback redirect, and stores the session in `credentials.json` next to the config file, mode `600`; the management commands refresh it on their own, `meili auth status` says whether that session is still accepted by the server, `meili auth refresh` rotates it on demand, and `meili auth logout` revokes it and forgets it. - A command of the verb `delete` requires `--yes` on the command line: without it the command refuses to run, `cli_usage` and exit 2, before any network call. The CLI still never asks a question. - `json` output by default, `ndjson` for lists, `--all` to follow the pagination, `--wait` and `--timeout` for asynchronous tasks. diff --git a/README.md b/README.md index 3db8831..30908d8 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ The URL, the key and the token also exist as flags with the same name: `--projec meili get indexes --project prod ``` -With several projects in the file, `--project` is required: the CLI never guesses, and it refuses to run when the file and the `MEILI_PROJECT_URL` variable both define a project. `MEILI_CONFIG` names another file. For a coding agent, give it its own file through `MEILI_CONFIG`, read-only: the file is shared state, and an agent must not be able to change where another one's commands go. The file holds your keys: keep it readable by you alone, the CLI warns on stderr otherwise, and an agent changes nothing in it, neither a project nor its permissions, unless you asked for it explicitly. `meili config list`, `meili config add` and `meili config remove` manage the file. +With several projects in the file, `--project` is required: the CLI never guesses, and it refuses to run when the file and the `MEILI_PROJECT_URL` variable both define a project. `MEILI_CONFIG` names another file. For a coding agent, give it its own file through `MEILI_CONFIG`, read-only: the file is shared state, and an agent must not be able to change where another one's commands go. The file holds your keys: keep it readable by you alone, the CLI warns on stderr otherwise, and an agent changes nothing in it, neither a project nor its permissions, unless you asked for it explicitly. `meili config list`, `meili config set` and `meili config unset` manage the file. ## Documentation diff --git a/docs/adr/0005-named-projects-config-file.md b/docs/adr/0005-named-projects-config-file.md index 44c9137..9722600 100644 --- a/docs/adr/0005-named-projects-config-file.md +++ b/docs/adr/0005-named-projects-config-file.md @@ -36,7 +36,7 @@ Alternatives considered: - **A URL and its key travel together.** With `--project-url`, the key comes from `--project-api-key` or from nowhere, never from the environment or the file: a configured key is never sent to a URL it was not configured with. `--project-api-key` alone, or with `--project`, replaces the key of the configured project: an explicit key for a configured URL is the way to hand an agent a restricted key. -- **Three commands without an API manage the file**, for humans: `config list` prints the path and the projects with keys masked; `config add` creates the file in mode `600`, validates the URL, reads the key from stdin with `--project-api-key -` to keep it out of the shell history, and refuses a name that already exists; `config remove` deletes an entry. They are a convenience, not a protection. +- **Three commands without an API manage the file**, for humans: `config list` prints the path and the projects with keys masked; `config set` creates the file in mode `600`, validates the URL, reads the key from stdin with `--project-api-key -` to keep it out of the shell history, and refuses a name that already exists; `config unset` deletes an entry. They are a convenience, not a protection. - **The file is shared state.** An agent gets its own file through `MEILI_CONFIG`, prepared by a human and mounted read-only in its sandbox, never the human's file. An agent changes nothing in the file, neither a project nor its permissions, unless the human asked for it explicitly. `--help`, `meili schema` and the README say so, because those are what an agent reads. @@ -46,7 +46,7 @@ Alternatives considered: - An agent works on two projects in one session, `--project staging` then `--project prod`, with no key on any command line and no state that another process can switch under it. - A forgotten variable cannot choose the target silently: the environment pair next to a file is an error, not a precedence. The price is that a script which exports the pair on a machine that has a file must point `MEILI_CONFIG` at a file that does not exist, or unset the pair. -- What the CLI cannot protect against: another writer of the same file. Removing `staging` and adding it back with other credentials redirects every later `--project staging` of whoever shares the file, and a process with a file tool does not need `config add` to do it. Isolation, one file per agent, is the protection; `config add` refusing an existing name only prevents a replacement by mistake. +- What the CLI cannot protect against: another writer of the same file. Unsetting `staging` and setting it again with other credentials redirects every later `--project staging` of whoever shares the file, and a process with a file tool does not need `config set` to do it. Isolation, one file per agent, is the protection; `config set` refusing an existing name only prevents a replacement by mistake. - `config.md` changes one promise: the CLI reads keys from the config file, and from nowhere else on disk, and warns when the file is readable by others. `output.md` gains the warning format. - Unit and integration tests point `MEILI_CONFIG` at a file that does not exist, so a developer's own file never leaks into a test. - `config.md` gains the file format and the resolution rules, `commands.md` the `--project` global flag and the three `config` commands. diff --git a/docs/adr/0008-schema-has-two-zoom-levels.md b/docs/adr/0008-schema-has-two-zoom-levels.md index 721ef6b..2efeec2 100644 --- a/docs/adr/0008-schema-has-two-zoom-levels.md +++ b/docs/adr/0008-schema-has-two-zoom-levels.md @@ -16,12 +16,12 @@ The output is also missing what an agent asks first. The routes a command covers `meili schema` keeps its name and gains a second zoom level, selected by a flag. There is no third level and no full dump: the two levels cover every need, and `oclif.manifest.json` is still there for whoever wants everything. -- `meili schema` prints the index: the global flags once, then one entry per command with its `id`, its `description` and its `routes`. The description is its first paragraph: `config add` and `config remove` carry a warning paragraph that is 17 % of today's index and belongs in the detail. About 12 kB at sixty-three commands. +- `meili schema` prints the index: the global flags once, then one entry per command with its `id`, its `description` and its `routes`. The description is its first paragraph: `config set` and `config unset` carry a warning paragraph that is 17 % of today's index and belongs in the detail. About 12 kB at sixty-three commands. - `meili schema --command "get indexes"` prints one command in full: `id`, `description`, `api`, `routes`, `async`, `keyActions`, the `flags` the command declares itself and its `examples`. - An unknown `--command` is a usage error, `cli_usage`, exit 2, with a hint that names the closest command, the suggestion `errors.ts` already computes for an unknown command, or `meili schema` when nothing is close. - A field that would say nothing is absent rather than empty or false, the convention `routes` and `keyActions` already follow: no `async` on a command that returns no task, no `required` on an optional flag. That is 7.6 % of a detail call. -"The flags the command declares itself" is read from the command class, where oclif keeps `flags` and `baseFlags` apart, and not inferred from the manifest, which merges them. The distinction matters because `meili config add` redefines `--project` and `--project-url` as required with its own descriptions: dropping global flags by name would hide exactly the two flags that command needs. Comparing the rendered flag against `BaseCommand.baseFlags` would work today but compares two separately built artifacts, the live `src` object against the manifest built from `dist`, so a stale manifest would make every untouched global flag look redefined. The class answers the question directly. +"The flags the command declares itself" is read from the command class, where oclif keeps `flags` and `baseFlags` apart, and not inferred from the manifest, which merges them. The distinction matters because `meili config set` redefines `--project` and `--project-url` as required with its own descriptions: dropping global flags by name would hide exactly the two flags that command needs. Comparing the rendered flag against `BaseCommand.baseFlags` would work today but compares two separately built artifacts, the live `src` object against the manifest built from `dist`, so a stale manifest would make every untouched global flag look redefined. The class answers the question directly. Three fields are new, and none of them is metadata to maintain by hand twice: diff --git a/docs/adr/0009-oauth-login-for-the-management-api.md b/docs/adr/0009-oauth-login-for-the-management-api.md index ec61d45..414bba9 100644 --- a/docs/adr/0009-oauth-login-for-the-management-api.md +++ b/docs/adr/0009-oauth-login-for-the-management-api.md @@ -26,7 +26,7 @@ Alternatives considered: - **Four commands without an API, `auth login`, `auth status`, `auth refresh` and `auth logout`**, in the table of `commands.md`. They call neither API of the contract: the authorization server is described by no OpenAPI document here, so no route derives their name. They sit next to the `config` commands, and like them they are for humans. -- **`auth login` runs the authorization code flow with PKCE**, as a native application does (RFC 8252). It starts a server bound to `127.0.0.1` on a free port, prints the consent URL on stderr, opens it in a browser unless `--no-browser`, waits for the redirect, checks the `state` it sent, and exchanges the code with its verifier. It writes nothing on stdout: what it produced is a file, as for `config add`. `auth status` is where the data is. The browser is a convenience: the URL is always printed, so a session over SSH works by opening it elsewhere. +- **`auth login` runs the authorization code flow with PKCE**, as a native application does (RFC 8252). It starts a server bound to `127.0.0.1` on a free port, prints the consent URL on stderr, opens it in a browser unless `--no-browser`, waits for the redirect, checks the `state` it sent, and exchanges the code with its verifier. It writes nothing on stdout: what it produced is a file, as for `config set`. `auth status` is where the data is. The browser is a convenience: the URL is always printed, so a session over SSH works by opening it elsewhere. - **It is the one command that waits for a human**, and it says so in `commands.md`. It never prompts and never reads stdin: it waits for an HTTP request that the human causes, for five minutes, then `cli_timeout` and exit 5. Everything else the CLI does stays non-interactive, and an agent that must not wait uses `MEILI_MANAGEMENT_TOKEN`. diff --git a/docs/adr/0010-connection-flags-belong-to-the-commands-that-connect.md b/docs/adr/0010-connection-flags-belong-to-the-commands-that-connect.md index a34d7dd..403f742 100644 --- a/docs/adr/0010-connection-flags-belong-to-the-commands-that-connect.md +++ b/docs/adr/0010-connection-flags-belong-to-the-commands-that-connect.md @@ -6,7 +6,7 @@ Status: accepted. Date: 2026-10-21. `--project-url`, `--project-api-key` and `--project` were global: oclif put them on every command through `BaseCommand.baseFlags`, and `commands.md` listed them in a table that said "these flags work on every command". That was true of the commands written at the time, which all talked to a Meilisearch project. -It stopped being true with the `auth` commands of ADR 0009, which talk to the authorization server of Meilisearch Cloud and resolve no project at all. `meili auth login --help` advertised three flags it ignores entirely, and so did `schema`, `config list` and `config remove`. An agent learns this CLI by reading `meili schema`, and it was being told that four commands take a project they do nothing with. A flag that does nothing is worse than a missing one: nothing fails, the agent just believes something false. +It stopped being true with the `auth` commands of ADR 0009, which talk to the authorization server of Meilisearch Cloud and resolve no project at all. `meili auth login --help` advertised three flags it ignores entirely, and so did `schema`, `config list` and `config unset`. An agent learns this CLI by reading `meili schema`, and it was being told that four commands take a project they do nothing with. A flag that does nothing is worse than a missing one: nothing fails, the agent just believes something false. ADR 0005 decided how a project is chosen and ADR 0008 how `meili schema` prints a command. Neither decided that the three flags belong to every command: 0005 names `--project` a "global flag" once, in a consequence that records where the documentation went, and 0008 counts "five global flags" in a context that describes the CLI as it was. Those two sentences are the only trace of the old arrangement, and they are history, not decisions. @@ -24,13 +24,13 @@ Alternatives considered: - **The group sits in a command's own `flags`, not in `baseFlags`.** ADR 0008 prints the detail of a command from the flags its class declares, so a connection in `baseFlags` would be invisible to `meili schema --command "get indexes"`, which is where an agent looks to learn how to reach a project. The index keeps printing the global flags once, now two of them. -- **`meili config add` declares its own three.** It writes a project into the config file rather than connecting to one: `--project` is the name to add, `--project-url` the URL to store, `--project-api-key` the key, with `-` reading stdin out of the shell history. That also makes the rule testable without a list of exceptions: the connection group is on the commands whose `static api` is `meilisearch`, and on `config add`, which says so in its own flags. +- **`meili config set` declares its own three.** It writes a project into the config file rather than connecting to one: `--project` is the name to set, `--project-url` the URL to store, `--project-api-key` the key, with `-` reading stdin out of the shell history. That also makes the rule testable without a list of exceptions: the connection group is on the commands whose `static api` is `meilisearch`, and on `config set`, which says so in its own flags. -- **A contract test holds it**: a command declares the connection flags if and only if it reaches a project. It keys on `--project-url`, because `config remove --project` names an entry of the file, not a connection, and is allowed to keep that name. +- **A contract test holds it**: a command declares the connection flags if and only if it reaches a project. It keys on `--project-url`, because `config unset --project` names an entry of the file, not a connection, and is allowed to keep that name. ## Consequences -- `meili auth`, `meili schema`, `config list` and `config remove` stop advertising three flags they ignore. `meili get indexes --help` is unchanged: oclif renders a leaf command's base flags and own flags in the same list. +- `meili auth`, `meili schema`, `config list` and `config unset` stop advertising three flags they ignore. `meili get indexes --help` is unchanged: oclif renders a leaf command's base flags and own flags in the same list. - `meili schema` prints two global flags instead of five, and the connection appears in the detail of the commands that take it, which is where it is usable. - Nothing changes in how a project is resolved: ADR 0005 holds entirely, and `config.md` is untouched. - Two sentences of accepted ADRs now describe a world where the three flags were global: the consequence line of ADR 0005 and the context and one example of ADR 0008. They are not edited, and neither ADR is superseded, since this one changes no decision of theirs: it decides which commands carry the flags, which neither had decided. diff --git a/docs/contract/commands.md b/docs/contract/commands.md index 35e729e..d488585 100644 --- a/docs/contract/commands.md +++ b/docs/contract/commands.md @@ -119,7 +119,7 @@ Commands reuse these groups. A command that matches a group uses the whole group | `--project-api-key ` | Project API key. `-` reads it from stdin. | | `--project ` | A project of the config file. Cannot be combined with `--project-url`. Required when the file has several projects. See `config.md`. | -`meili config add` writes these three values rather than connecting with them, so it declares its own, with its own descriptions. See ADR 0010. +`meili config set` writes these three values rather than connecting with them, so it declares its own, with its own descriptions. See ADR 0010. **Write commands** (any command whose API response is a task): @@ -157,9 +157,9 @@ These commands call neither of the two APIs of this contract, so no route derive |---|---| | `meili schema` | Print the index of the command tree as JSON: the global flags once, then one entry per command with its description and its routes. `meili schema --command "get indexes"` prints that one command in full: its API, its routes, its key actions, whether it returns a task, the flags it declares itself and its examples. Generated from the oclif manifest. This is how an agent learns the CLI: the index in one call, a command when it needs it. See ADR 0008. | | `meili config list` | Print the config file path and its projects as JSON, keys masked. | -| `meili config add --project --project-url [--project-api-key ]` | Add a project to the config file, creating it in mode `600`. `--project-api-key -` reads the key from stdin. Refuses a URL that is not HTTP(S) with `cli_usage`, and a name that already exists with `cli_config`. Nothing on stdout. | -| `meili config remove --project ` | Remove a project from the config file. Nothing on stdout. | -| `meili auth login` | Run the OAuth2 flow with PKCE against Meilisearch Cloud, as the OAuth application of this CLI, which `--management-client-id` replaces on another Cloud: print the consent URL on stderr, open it in a browser unless `--no-browser`, receive the code on a loopback server, and write the session to the credentials file in mode `600`. Nothing on stdout: the session is the data and it goes to the file, like `config add`. One line on stderr when stderr is a TTY. The one command that waits for a human: five minutes, then `cli_timeout` and exit 5. See `config.md`. | +| `meili config set --project --project-url [--project-api-key ]` | Set a project in the config file, creating the file in mode `600`. `--project-api-key -` reads the key from stdin. Refuses a URL that is not HTTP(S) with `cli_usage`, and a name that already exists with `cli_config`: changing an entry is an `unset` then a `set`, on purpose. Nothing on stdout. | +| `meili config unset --project ` | Remove a project from the config file. Nothing on stdout. | +| `meili auth login` | Run the OAuth2 flow with PKCE against Meilisearch Cloud, as the OAuth application of this CLI, which `--management-client-id` replaces on another Cloud: print the consent URL on stderr, open it in a browser unless `--no-browser`, receive the code on a loopback server, and write the session to the credentials file in mode `600`. Nothing on stdout: the session is the data and it goes to the file, like `config set`. One line on stderr when stderr is a TTY. The one command that waits for a human: five minutes, then `cli_timeout` and exit 5. See `config.md`. | | `meili auth status` | Say whether a session exists and whether the authorization server still accepts it, through `GET /oauth/token/info`. On a live session, exit 0 and the Cloud it was issued by, the `scope`, the `expiresAt` the server reports and the path of the credentials file on stdout. Otherwise one error and nothing on stdout, as everywhere else: `cli_auth_missing` when nobody is logged in, `cli_auth_failed` when the server no longer accepts the session, both exit 3. A spent access token is refreshed first, like any management command would. | | `meili auth refresh` | Trade the refresh token of the stored session for a new access token and write it back, without waiting for the old one to expire. Nothing on stdout, one line on stderr when stderr is a TTY. `cli_auth_missing` when nobody is logged in, `cli_auth_failed` when the session carries no refresh token or the server refuses it, both exit 3. | | `meili auth logout` | Revoke both tokens of the session at the authorization server and remove the credentials file. Nothing on stdout, one line on stderr when stderr is a TTY; exit 0 when nobody is logged in, and the code of what failed when the revocation did not go through, see `config.md`. | diff --git a/docs/contract/config.md b/docs/contract/config.md index 2aea356..0de4d4b 100644 --- a/docs/contract/config.md +++ b/docs/contract/config.md @@ -16,7 +16,7 @@ A project is a Meilisearch instance: the one you run, or a project on Meilisearc ## Config file -The path is `$MEILI_CONFIG` if set, else `$XDG_CONFIG_HOME/meili/config.json`, else `~/.config/meili/config.json`. The file is JSON, written by hand or by `meili config add`: +The path is `$MEILI_CONFIG` if set, else `$XDG_CONFIG_HOME/meili/config.json`, else `~/.config/meili/config.json`. The file is JSON, written by hand or by `meili config set`: ```json { @@ -31,7 +31,7 @@ The path is `$MEILI_CONFIG` if set, else `$XDG_CONFIG_HOME/meili/config.json`, e - The file holds API keys, so it belongs to one user and is readable by that user alone: mode `600`, or `400` for a file an agent must not change. When the file is readable by others, the CLI warns on stderr with the `chmod` to run (`output.md`, "Warnings") and goes on. On Windows, where file modes do not exist, there is no check. A file that does not exist is not an error: the CLI behaves as if there were no file. - Invalid JSON, or a shape other than the one above, fails with `cli_config` and the path of the file. - The file is shared state: every process that reads it is affected by whoever writes it. Give an agent its own file through `MEILI_CONFIG`, prepared by a human and mounted read-only in its sandbox, never the human's file. An agent adds, removes or changes a project, or the permissions of the file, only when the human asked for it explicitly: the file decides where every later command goes, for every process that reads it. `meili --help`, the help of the `config` commands and `meili schema` say so. -- `meili config list`, `meili config add` and `meili config remove` manage the file, see `commands.md`. `config add` refuses a name that already exists: changing an entry is a `remove` then an `add`, on purpose. +- `meili config list`, `meili config set` and `meili config unset` manage the file, see `commands.md`. `config set` refuses a name that already exists: changing an entry is an `unset` then a `set`, on purpose. ## Resolution: nothing is deduced diff --git a/docs/contract/output.md b/docs/contract/output.md index bc2519c..3c21822 100644 --- a/docs/contract/output.md +++ b/docs/contract/output.md @@ -115,7 +115,7 @@ Destructive commands are those of the verb `delete`, as `commands.md` derives th - Every command has a `description` of one sentence and at least two `examples`. Every flag has a description of one sentence, and a flag that takes a value documents it with a `helpValue` or a list of options. - `--help` never makes a network call. - `meili schema` prints the index: the global flags once, then one entry per command with its `id`, its `description` and its `routes`. The description is its first paragraph; what follows is a warning the detail carries. It is the cheapest way for an agent to see everything that exists. -- `meili schema --command "get indexes"` prints that one command in full: `id`, `description`, `api`, `routes`, `async`, `keyActions`, the `flags` the command declares itself and its `examples`. The global flags are not repeated there: they belong to the index. `meili config add --project` is listed, required and with its own description, because that command declares it; `--debug` is not, because no command declares it. An unknown `--command` is `cli_usage`, exit 2, with a hint that names the closest command, or `meili schema` when nothing is close. +- `meili schema --command "get indexes"` prints that one command in full: `id`, `description`, `api`, `routes`, `async`, `keyActions`, the `flags` the command declares itself and its `examples`. The global flags are not repeated there: they belong to the index. `meili config set --project` is listed, required and with its own description, because that command declares it; `--debug` is not, because no command declares it. An unknown `--command` is `cli_usage`, exit 2, with a hint that names the closest command, or `meili schema` when nothing is close. - A field that would say nothing is absent rather than empty or false: no `routes` on a command without an API, no `keyActions` when the routes need none, no `async` on a command that returns no task, no `required` on an optional flag. - The index reads the oclif manifest; `--command` reads it too and loads the command class for its own flags. Both are always in sync with the code. There is no third level: `oclif.manifest.json` is there for whoever wants everything. See ADR 0008. diff --git a/oclif.manifest.json b/oclif.manifest.json index 817a7c8..56e4c1d 100644 --- a/oclif.manifest.json +++ b/oclif.manifest.json @@ -575,14 +575,14 @@ "status.js" ] }, - "config:add": { + "config:list": { "aliases": [], "args": {}, - "description": "Add a project to the config file, creating it 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": { @@ -603,37 +603,11 @@ "name": "debug", "allowNo": false, "type": "boolean" - }, - "project": { - "description": "Name of the project to add.", - "name": "project", - "required": true, - "hasDynamicHelp": false, - "helpValue": "", - "multiple": false, - "type": "option" - }, - "project-url": { - "description": "URL of the project.", - "name": "project-url", - "required": true, - "hasDynamicHelp": false, - "helpValue": "", - "multiple": false, - "type": "option" - }, - "project-api-key": { - "description": "API key of the project. `-` reads it from stdin, out of the shell history.", - "name": "project-api-key", - "hasDynamicHelp": false, - "helpValue": "", - "multiple": false, - "type": "option" } }, "hasDynamicHelp": false, "hiddenAliases": [], - "id": "config:add", + "id": "config:list", "pluginAlias": "@meilisearch/cli", "pluginName": "@meilisearch/cli", "pluginType": "core", @@ -735,17 +709,17 @@ "dist", "commands", "config", - "add.js" + "list.js" ] }, - "config:list": { + "config:set": { "aliases": [], "args": {}, - "description": "Print the config file path and its projects as JSON, keys masked.", + "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 %>", - "<%= config.bin %> <%= command.id %> --output json", - "MEILI_CONFIG=./meili.json <%= config.bin %> <%= command.id %>" + "<%= 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": { @@ -766,11 +740,37 @@ "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" + }, + "project-url": { + "description": "URL of the project.", + "name": "project-url", + "required": true, + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "project-api-key": { + "description": "API key of the project. `-` reads it from stdin, out of the shell history.", + "name": "project-api-key", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" } }, "hasDynamicHelp": false, "hiddenAliases": [], - "id": "config:list", + "id": "config:set", "pluginAlias": "@meilisearch/cli", "pluginName": "@meilisearch/cli", "pluginType": "core", @@ -872,10 +872,10 @@ "dist", "commands", "config", - "list.js" + "set.js" ] }, - "config:remove": { + "config:unset": { "aliases": [], "args": {}, "description": "Remove a project from the config file.\n\nThe file holds API keys and decides where every later command goes, for every process that reads it: an agent runs this only when the human asked for it explicitly.", @@ -915,7 +915,7 @@ }, "hasDynamicHelp": false, "hiddenAliases": [], - "id": "config:remove", + "id": "config:unset", "pluginAlias": "@meilisearch/cli", "pluginName": "@meilisearch/cli", "pluginType": "core", @@ -1017,7 +1017,7 @@ "dist", "commands", "config", - "remove.js" + "unset.js" ] }, "create:documents": { diff --git a/src/commands/config/add.ts b/src/commands/config/set.ts similarity index 86% rename from src/commands/config/add.ts rename to src/commands/config/set.ts index 8a3f2e8..538af55 100644 --- a/src/commands/config/add.ts +++ b/src/commands/config/set.ts @@ -4,11 +4,11 @@ import { writeConfigFile } from "../../lib/config.js"; import { MeiliCliError } from "../../lib/errors.js"; import { readStdin } from "../../lib/input.js"; -export default class ConfigAdd extends BaseCommand { +export default class ConfigSet extends BaseCommand { static api: Api = "none"; static override description = - `Add a project to the config file, creating it owner-only; refuses a name that exists. + `Set a project in the config file, creating the file owner-only; refuses a name that exists. The file holds API keys and decides where every later command goes, for every process that reads it: an agent runs this only when the human asked for it explicitly.`; @@ -21,7 +21,7 @@ The file holds API keys and decides where every later command goes, for every pr static override flags = { project: Flags.string({ required: true, - description: "Name of the project to add.", + description: "Name of the project to set.", helpValue: "", }), "project-url": Flags.string({ @@ -37,7 +37,7 @@ The file holds API keys and decides where every later command goes, for every pr }; async run(): Promise { - const { flags } = await this.parse(ConfigAdd); + const { flags } = await this.parse(ConfigSet); // A Meilisearch instance answers over HTTP(S) only; the SDK mangles any other scheme. const url = URL.parse(flags["project-url"]); if (url === null || (url.protocol !== "http:" && url.protocol !== "https:")) { @@ -48,7 +48,7 @@ The file holds API keys and decides where every later command goes, for every pr throw new MeiliCliError( "cli_config", `Project \`${flags.project}\` already exists in ${file.path}.`, - { hint: `Run \`meili config remove --project ${flags.project}\` first.` }, + { hint: `Run \`meili config unset --project ${flags.project}\` first.` }, ); } writeConfigFile({ diff --git a/src/commands/config/remove.ts b/src/commands/config/unset.ts similarity index 90% rename from src/commands/config/remove.ts rename to src/commands/config/unset.ts index 125cb9d..563acc3 100644 --- a/src/commands/config/remove.ts +++ b/src/commands/config/unset.ts @@ -2,7 +2,7 @@ import { Flags } from "@oclif/core"; import { type Api, BaseCommand } from "../../lib/base-command.js"; import { projectOf, writeConfigFile } from "../../lib/config.js"; -export default class ConfigRemove extends BaseCommand { +export default class ConfigUnset extends BaseCommand { static api: Api = "none"; static override description = `Remove a project from the config file. @@ -23,7 +23,7 @@ The file holds API keys and decides where every later command goes, for every pr }; async run(): Promise { - const { flags } = await this.parse(ConfigRemove); + const { flags } = await this.parse(ConfigUnset); const file = this.configFile(); projectOf(file, flags.project); const { [flags.project]: _removed, ...projects } = file.projects; diff --git a/test/commands/config/__snapshots__/add.test.ts.snap b/test/commands/config/__snapshots__/set.test.ts.snap similarity index 68% rename from test/commands/config/__snapshots__/add.test.ts.snap rename to test/commands/config/__snapshots__/set.test.ts.snap index 85903f2..f53a6f9 100644 --- a/test/commands/config/__snapshots__/add.test.ts.snap +++ b/test/commands/config/__snapshots__/set.test.ts.snap @@ -1,10 +1,10 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`meili config add > prints its help without any network call 1`] = ` -"Add a project to the config file, creating it owner-only; refuses a name that exists. +exports[`meili config set > prints its help without any network call 1`] = ` +"Set a project in the config file, creating the file owner-only; refuses a name that exists. USAGE - $ meili config add --project --project-url [-o ] + $ meili config set --project --project-url [-o ] [--debug] [--project-api-key ] FLAGS @@ -14,25 +14,25 @@ FLAGS streams with --all. --debug Print every HTTP request and response on stderr, secrets redacted. - --project= (required) Name of the project to add. + --project= (required) Name of the project to set. --project-api-key= API key of the project. \`-\` reads it from stdin, out of the shell history. --project-url= (required) URL of the project. DESCRIPTION - Add a project to the config file, creating it owner-only; refuses a name that - exists. + Set a project in the config file, creating the file owner-only; refuses a name + that exists. The file holds API keys and decides where every later command goes, for every process that reads it: an agent runs this only when the human asked for it explicitly. EXAMPLES - $ meili config add --project local --project-url http://localhost:7700 + $ meili config set --project local --project-url http://localhost:7700 - $ meili config add --project prod --project-url https://ms-abc.sfo.meilisearch.io --project-api-key - < key.txt + $ meili config set --project prod --project-url https://ms-abc.sfo.meilisearch.io --project-api-key - < key.txt - $ meili config add --project local --project-url http://localhost:7700 --output json + $ meili config set --project local --project-url http://localhost:7700 --output json " `; diff --git a/test/commands/config/__snapshots__/remove.test.ts.snap b/test/commands/config/__snapshots__/unset.test.ts.snap similarity index 76% rename from test/commands/config/__snapshots__/remove.test.ts.snap rename to test/commands/config/__snapshots__/unset.test.ts.snap index a6f9eb4..98b7a2a 100644 --- a/test/commands/config/__snapshots__/remove.test.ts.snap +++ b/test/commands/config/__snapshots__/unset.test.ts.snap @@ -1,10 +1,10 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`meili config remove > prints its help without any network call 1`] = ` +exports[`meili config unset > prints its help without any network call 1`] = ` "Remove a project from the config file. USAGE - $ meili config remove --project [-o ] [--debug] + $ meili config unset --project [-o ] [--debug] FLAGS -o, --output= json (default): one document, the API body as it is, @@ -22,9 +22,9 @@ DESCRIPTION explicitly. EXAMPLES - $ meili config remove --project prod + $ meili config unset --project prod - $ meili config remove --project prod --output json + $ meili config unset --project prod --output json " `; diff --git a/test/commands/config/add.test.ts b/test/commands/config/set.test.ts similarity index 83% rename from test/commands/config/add.test.ts rename to test/commands/config/set.test.ts index 0b3ca86..a2f9754 100644 --- a/test/commands/config/add.test.ts +++ b/test/commands/config/set.test.ts @@ -1,7 +1,7 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; -import ConfigAdd from "../../../src/commands/config/add.js"; +import ConfigSet from "../../../src/commands/config/set.js"; import { useMockServer } from "../../helpers/msw.js"; import { runCli, runCommand, scrubbedHome } from "../../helpers/run-command.js"; @@ -11,9 +11,9 @@ useMockServer(); const read = (path: string) => JSON.parse(readFileSync(path, "utf8")) as unknown; -describe("meili config add", () => { +describe("meili config set", () => { it("prints its help without any network call", async () => { - const result = await runCli(["config", "add", "--help"]); + const result = await runCli(["config", "set", "--help"]); expect(result.exitCode).toBe(0); expect(result.stderr).toBe(""); expect(result.stdout).toMatchSnapshot(); @@ -22,7 +22,7 @@ describe("meili config add", () => { it("creates the file with the project, and prints nothing", async () => { const path = join(scrubbedHome, "new", "config.json"); const result = await runCommand( - ConfigAdd, + ConfigSet, ["--project", "local", "--project-url", "http://localhost:7700", "--output", "json"], { env: { MEILI_CONFIG: path } }, ); @@ -33,7 +33,7 @@ describe("meili config add", () => { it("reads the key from stdin with -", async () => { const path = join(scrubbedHome, "stdin.json"); const result = await runCommand( - ConfigAdd, + ConfigSet, ["--project", "prod", "--project-url", "https://prod.example.com", "--project-api-key", "-"], { env: { MEILI_CONFIG: path } }, ); @@ -46,23 +46,23 @@ describe("meili config add", () => { it("adds to an existing file and refuses a name that exists", async () => { const path = join(scrubbedHome, "existing.json"); const env = { MEILI_CONFIG: path }; - await runCommand(ConfigAdd, ["--project", "a", "--project-url", "http://a"], { env }); - await runCommand(ConfigAdd, ["--project", "b", "--project-url", "http://b"], { env }); + await runCommand(ConfigSet, ["--project", "a", "--project-url", "http://a"], { env }); + await runCommand(ConfigSet, ["--project", "b", "--project-url", "http://b"], { env }); expect(read(path)).toEqual({ projects: { a: { url: "http://a" }, b: { url: "http://b" } } }); - const result = await runCommand(ConfigAdd, ["--project", "a", "--project-url", "http://c"], { + const result = await runCommand(ConfigSet, ["--project", "a", "--project-url", "http://c"], { env, }); expect(result.exitCode).toBe(2); const error = JSON.parse(result.stderr).error as { code: string; hint: string }; expect(error.code).toBe("cli_config"); - expect(error.hint).toBe("Run `meili config remove --project a` first."); + expect(error.hint).toBe("Run `meili config unset --project a` first."); expect(read(path)).toEqual({ projects: { a: { url: "http://a" }, b: { url: "http://b" } } }); }); it("rejects a URL that is invalid or not HTTP(S) with cli_usage", async () => { for (const url of ["not a url", "ftp://example.com", "localhost:7700"]) { - const result = await runCommand(ConfigAdd, ["--project", "ok", "--project-url", url], { + const result = await runCommand(ConfigSet, ["--project", "ok", "--project-url", url], { env: { MEILI_CONFIG: join(scrubbedHome, "invalid.json") }, }); expect(result.exitCode, url).toBe(2); diff --git a/test/commands/config/remove.test.ts b/test/commands/config/unset.test.ts similarity index 80% rename from test/commands/config/remove.test.ts rename to test/commands/config/unset.test.ts index 37773c5..58fa2a2 100644 --- a/test/commands/config/remove.test.ts +++ b/test/commands/config/unset.test.ts @@ -1,16 +1,16 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import ConfigRemove from "../../../src/commands/config/remove.js"; +import ConfigUnset from "../../../src/commands/config/unset.js"; import { writeConfigFile } from "../../../src/lib/config.js"; import { useMockServer } from "../../helpers/msw.js"; import { runCli, runCommand, scrubbedHome } from "../../helpers/run-command.js"; useMockServer(); -describe("meili config remove", () => { +describe("meili config unset", () => { it("prints its help without any network call", async () => { - const result = await runCli(["config", "remove", "--help"]); + const result = await runCli(["config", "unset", "--help"]); expect(result.exitCode).toBe(0); expect(result.stderr).toBe(""); expect(result.stdout).toMatchSnapshot(); @@ -19,7 +19,7 @@ describe("meili config remove", () => { it("removes the project and prints nothing", async () => { const path = join(scrubbedHome, "projects.json"); writeConfigFile({ path, projects: { a: { url: "http://a" }, b: { url: "http://b" } } }); - const result = await runCommand(ConfigRemove, ["--project", "a", "--output", "json"], { + const result = await runCommand(ConfigUnset, ["--project", "a", "--output", "json"], { env: { MEILI_CONFIG: path }, }); expect(result).toEqual({ stdout: "", stderr: "", exitCode: 0 }); @@ -31,7 +31,7 @@ describe("meili config remove", () => { it("fails with cli_config when the project or the file does not exist", async () => { const path = join(scrubbedHome, "unknown.json"); writeConfigFile({ path, projects: { b: { url: "http://b" } } }); - const unknown = await runCommand(ConfigRemove, ["--project", "nope"], { + const unknown = await runCommand(ConfigUnset, ["--project", "nope"], { env: { MEILI_CONFIG: path }, }); expect(unknown.exitCode).toBe(2); @@ -40,7 +40,7 @@ describe("meili config remove", () => { ); const missing = join(scrubbedHome, "missing.json"); - const noFile = await runCommand(ConfigRemove, ["--project", "b"], { + const noFile = await runCommand(ConfigUnset, ["--project", "b"], { env: { MEILI_CONFIG: missing }, }); expect(noFile.exitCode).toBe(2); diff --git a/test/commands/schema.test.ts b/test/commands/schema.test.ts index b6da838..00b48f7 100644 --- a/test/commands/schema.test.ts +++ b/test/commands/schema.test.ts @@ -45,20 +45,20 @@ describe("meili schema", () => { }); it("prints the flags a command declares itself, not the global ones it inherits", async () => { - const result = await runCommand(Schema, ["--command", "config add", "--output", "json"]); + const result = await runCommand(Schema, ["--command", "config set", "--output", "json"]); expect(result.exitCode).toBe(0); expect(result.stderr).toBe(""); const command = JSON.parse(result.stdout) as { description: string; flags: Array<{ name: string }>; }; - // `config add` declares the three values it writes, with its own descriptions. + // `config set` declares the three values it writes, with its own descriptions. expect(command.flags).toEqual([ { name: "project", type: "option", required: true, - description: "Name of the project to add.", + description: "Name of the project to set.", helpValue: "", }, { diff --git a/test/contract/__snapshots__/commands.test.ts.snap b/test/contract/__snapshots__/commands.test.ts.snap index fd8540a..381af4f 100644 --- a/test/contract/__snapshots__/commands.test.ts.snap +++ b/test/contract/__snapshots__/commands.test.ts.snap @@ -70,17 +70,28 @@ exports[`oclif.manifest.json > \`meili schema\` is a snapshot, the index and eve }, { "api": "none", - "description": "Add a project to the config file, creating it owner-only; refuses a name that exists. + "description": "Print the config file path and its projects as JSON, keys masked.", + "examples": [ + "meili config list", + "meili config list --output json", + "MEILI_CONFIG=./meili.json meili config list", + ], + "flags": [], + "id": "config list", + }, + { + "api": "none", + "description": "Set a project in the config file, creating the file owner-only; refuses a name that exists. The file holds API keys and decides where every later command goes, for every process that reads it: an agent runs this only when the human asked for it explicitly.", "examples": [ - "meili config add --project local --project-url http://localhost:7700", - "meili config add --project prod --project-url https://ms-abc.sfo.meilisearch.io --project-api-key - < key.txt", - "meili config add --project local --project-url http://localhost:7700 --output json", + "meili config set --project local --project-url http://localhost:7700", + "meili config set --project prod --project-url https://ms-abc.sfo.meilisearch.io --project-api-key - < key.txt", + "meili config set --project local --project-url http://localhost:7700 --output json", ], "flags": [ { - "description": "Name of the project to add.", + "description": "Name of the project to set.", "helpValue": "", "name": "project", "required": true, @@ -100,18 +111,7 @@ The file holds API keys and decides where every later command goes, for every pr "type": "option", }, ], - "id": "config add", - }, - { - "api": "none", - "description": "Print the config file path and its projects as JSON, keys masked.", - "examples": [ - "meili config list", - "meili config list --output json", - "MEILI_CONFIG=./meili.json meili config list", - ], - "flags": [], - "id": "config list", + "id": "config set", }, { "api": "none", @@ -119,8 +119,8 @@ The file holds API keys and decides where every later command goes, for every pr The file holds API keys and decides where every later command goes, for every process that reads it: an agent runs this only when the human asked for it explicitly.", "examples": [ - "meili config remove --project prod", - "meili config remove --project prod --output json", + "meili config unset --project prod", + "meili config unset --project prod --output json", ], "flags": [ { @@ -131,7 +131,7 @@ The file holds API keys and decides where every later command goes, for every pr "type": "option", }, ], - "id": "config remove", + "id": "config unset", }, { "api": "meilisearch", @@ -676,17 +676,17 @@ 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": "Add a project to the config file, creating it owner-only; refuses a name that exists.", - "id": "config add", - }, { "description": "Print the config file path and its projects as JSON, keys masked.", "id": "config list", }, + { + "description": "Set a project in the config file, creating the file owner-only; refuses a name that exists.", + "id": "config set", + }, { "description": "Remove a project from the config file.", - "id": "config remove", + "id": "config unset", }, { "description": "Add documents to an index, replacing the ones that already have the same primary key.", diff --git a/test/contract/commands.test.ts b/test/contract/commands.test.ts index 8b3224a..7f94499 100644 --- a/test/contract/commands.test.ts +++ b/test/contract/commands.test.ts @@ -161,9 +161,9 @@ describe("global flags", () => { default: { api?: unknown }; }; const flags = Object.keys(manifest.commands[id]?.flags ?? {}); - // `config add` writes a project rather than connecting to one: it declares its own. - const connects = command.api === "meilisearch" || id === "config:add"; - // `--project-url` is the unambiguous one: `config remove --project` names an + // `config set` writes a project rather than connecting to one: it declares its own. + const connects = command.api === "meilisearch" || id === "config:set"; + // `--project-url` is the unambiguous one: `config unset --project` names an // entry of the file, not a connection. expect(flags.includes("project-url"), `${id} and --project-url`).toBe(connects); if (connects) {