diff --git a/.claude/skills/scalar-galaxy-cli-sdk/SKILL.md b/.claude/skills/scalar-galaxy-cli-sdk/SKILL.md index db0d6aa..b76a972 100644 --- a/.claude/skills/scalar-galaxy-cli-sdk/SKILL.md +++ b/.claude/skills/scalar-galaxy-cli-sdk/SKILL.md @@ -14,10 +14,11 @@ Generated CLI client for Scalar Galaxy API, published as `@scalar/galaxy-cli`. U npm install -g @scalar/galaxy-cli # Homebrew — standalone binary, no Node.js required -brew install scalar/galaxy-cli-tap/galaxy +brew install --cask scalar/galaxy-cli-tap/galaxy +# Had the `galaxy` formula installed? Run `brew uninstall --formula galaxy` before the install above. # Direct download — standalone binary, no Node.js required -curl -fsSL https://github.com/scalar/galaxy-cli/releases/latest/download/galaxy-$(uname -s | tr '[:upper:]' '[:lower:]')-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tar.gz | tar xz +curl -fsSL "https://github.com/scalar/galaxy-cli/releases/latest/download/galaxy-$(uname -s | tr '[:upper:]' '[:lower:]')-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tar.gz" | tar xz galaxy sudo mv galaxy /usr/local/bin/ # Windows — download and extract galaxy-windows-x64.zip, then add it to PATH @@ -55,7 +56,7 @@ Failed requests print a structured error to standard error and exit with a statu - Pass `--format toon` for token-efficient structured output: a uniform list collapses into one header plus a row per item, with a definitive `[N]` count. Use `--format json` when the output is fed to a JSON parser. - Use `--max-items ` to bound paginated, streaming, and WebSocket commands before they fill the context, and `--transform ` to keep only the field you need. -- Commands never prompt, so they are safe to run non-interactively. Credentials come from the documented environment variables or their flags. +- Endpoint commands never prompt, so they are safe to run non-interactively. Credentials come from the documented environment variables or their flags. `login` is meant for a human at a terminal, so do not drive it: a flow that reads a credential refuses a non-terminal stdin outright, and a browser or device flow waits for a person to finish signing in elsewhere before it gives up. Set the credential through its environment variable or flag instead. - Branch on the exit status rather than on stderr text: `0` success, `1` `error`, `2` `usage`, `10` `auth-failed`, `11` `not-found`, `12` `rate-limited`, `13` `client-error`, `14` `server-error`, `15` `connection-error`. A failed request repeats its class on stderr as a stable `code`, with a `hint` when there is a concrete next step; exit `2` is a plain message with no structured body, because the command never ran. - Run `galaxy --help` or `galaxy --help` to discover commands and flags, and `man galaxy` for the full reference. diff --git a/.gitignore b/.gitignore index b4fb308..fd0d28f 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ node_modules/ .env .env.* /*.tar.gz +/apple-codesign-*/ /compile-out/ /release-binaries/ /tap/ diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 0ee8c01..816df2d 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.3.0" + ".": "0.3.1" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 298211e..be509cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [0.3.1](https://github.com/scalar/galaxy-cli/compare/v0.3.0...v0.3.1) (2026-10-02) + + +### Chores + +* **api:** regenerate SDK ([f2b5b18](https://github.com/scalar/galaxy-cli/commit/f2b5b188396318b95ff6cb3d1aeb4b875f798d6f)) +* **api:** update generated SDK content ([c861bdb](https://github.com/scalar/galaxy-cli/commit/c861bdb763144c3557048022762814b04bf306b1)) + ## [0.3.0](https://github.com/scalar/galaxy-cli/compare/v0.2.4...v0.3.0) (2026-09-15) diff --git a/README.md b/README.md index 2be3c0a..48134af 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ The full API of this library can be found in [api.md](./api.md). - [Installation](#installation) - [Usage](#usage) - [API Reference](./api.md) +- [Signing In](#signing-in) +- [Environments](#environments) - [File Arguments](#file-arguments) - [Shell Completion](#shell-completion) - [Manual Pages](#manual-pages) @@ -31,10 +33,11 @@ The full API of this library can be found in [api.md](./api.md). npm install -g @scalar/galaxy-cli # Homebrew — standalone binary, no Node.js required -brew install scalar/galaxy-cli-tap/galaxy +brew install --cask scalar/galaxy-cli-tap/galaxy +# Had the `galaxy` formula installed? Run `brew uninstall --formula galaxy` before the install above. # Direct download — standalone binary, no Node.js required -curl -fsSL https://github.com/scalar/galaxy-cli/releases/latest/download/galaxy-$(uname -s | tr '[:upper:]' '[:lower:]')-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tar.gz | tar xz +curl -fsSL "https://github.com/scalar/galaxy-cli/releases/latest/download/galaxy-$(uname -s | tr '[:upper:]' '[:lower:]')-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tar.gz" | tar xz galaxy sudo mv galaxy /usr/local/bin/ # Windows — download and extract galaxy-windows-x64.zip, then add it to PATH @@ -51,12 +54,42 @@ galaxy [resource] [command] [flags] galaxy planets list-all-data --bearer-auth "$BEARER_AUTH" --limit '10' --offset '0' ``` -The examples in the following sections assume a `client` configured as shown above. +Every command accepts the global flags below, so the examples that follow show only what is specific to them. See the [API reference](./api.md) for every available operation.
+## Signing In + +`galaxy login` signs you in and saves the credential for later commands, so it does not have to be passed every time. It goes into your operating system's credential store — the system keyring on Linux, Credential Manager on Windows — and falls back to a file in your state directory, readable only by you, when no such store is available. On macOS it is always that file, because the system's own tool accepts a password only on its command line, where other processes could read it. Either way it is filed under the base URL it was captured for, so a credential saved for one host is never sent to another. `galaxy logout` forgets it. A credential passed with a flag, or set in the environment, still takes precedence over a saved one. Both act on the environment selected with `--environment ` or `SCALAR_ENVIRONMENT`, so sign in to each environment you call. Sign-in methods: bearer-auth, basic, api-key-header, api-key-query, api-key-cookie, oauth-client-credentials, oauth-password, open-id-connect. Pass `--flow ` to pick one without being asked. + +```sh +galaxy login +galaxy login --flow bearer-auth +galaxy logout +galaxy logout --all +``` + +
+ +## Environments + +This API declares more than one environment. Pass `--environment ` to any command to choose one, or leave it off for `production`. `--base-url` sets a URL directly and cannot be combined with it, since each names where the request goes. `galaxy environments` prints the list for reading and takes `--format json` for parsing. Set `SCALAR_ENVIRONMENT` to choose one for every command in a shell; `--environment` and `--base-url` both take precedence over it, and it cannot be set together with the base URL environment variable. + +- `production` — `https://galaxy.scalar.com` (default) +- `void` — `https://void.scalar.com/` + +```sh +# read them; --format json when a script is parsing +galaxy environments + +# call an endpoint against one +galaxy --environment production COMMAND +``` + +
+ ## File Arguments Any command flag or credential reads its value from a file when the value begins with `@`, so a body field holding a whole document does not have to survive shell quoting. `@file://` always sends the file as text and `@data://` always sends it base64-encoded; a bare `@` lets the file decide. A flag that uploads a file takes its path with or without the `@`. Escape a literal value that begins with `@` as `\@`. The global options (`--base-url`, `--timeout`, `--format` and the rest) are read exactly as written. @@ -143,6 +176,7 @@ Configure the generated client by setting any of these options when you create i | `--timeout` | `` | - | Request timeout in milliseconds. | | `--max-retries` | `` | - | Number of retries for retryable failures. | | `--debug` | `flag` | - | Enable SDK debug logging. | +| `--environment` | `` | `production` | Named environment to target: production, void (can also be set with SCALAR_ENVIRONMENT env var). Cannot be combined with `--base-url`. |
diff --git a/SKILL.md b/SKILL.md index 0ebffc4..5ec5c63 100644 --- a/SKILL.md +++ b/SKILL.md @@ -14,10 +14,11 @@ Generated CLI client for Scalar Galaxy API, published as `@scalar/galaxy-cli`. U npm install -g @scalar/galaxy-cli # Homebrew — standalone binary, no Node.js required -brew install scalar/galaxy-cli-tap/galaxy +brew install --cask scalar/galaxy-cli-tap/galaxy +# Had the `galaxy` formula installed? Run `brew uninstall --formula galaxy` before the install above. # Direct download — standalone binary, no Node.js required -curl -fsSL https://github.com/scalar/galaxy-cli/releases/latest/download/galaxy-$(uname -s | tr '[:upper:]' '[:lower:]')-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tar.gz | tar xz +curl -fsSL "https://github.com/scalar/galaxy-cli/releases/latest/download/galaxy-$(uname -s | tr '[:upper:]' '[:lower:]')-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tar.gz" | tar xz galaxy sudo mv galaxy /usr/local/bin/ # Windows — download and extract galaxy-windows-x64.zip, then add it to PATH @@ -55,7 +56,7 @@ Failed requests print a structured error to standard error and exit with a statu - Pass `--format toon` for token-efficient structured output: a uniform list collapses into one header plus a row per item, with a definitive `[N]` count. Use `--format json` when the output is fed to a JSON parser. - Use `--max-items ` to bound paginated, streaming, and WebSocket commands before they fill the context, and `--transform ` to keep only the field you need. -- Commands never prompt, so they are safe to run non-interactively. Credentials come from the documented environment variables or their flags. +- Endpoint commands never prompt, so they are safe to run non-interactively. Credentials come from the documented environment variables or their flags. `login` is meant for a human at a terminal, so do not drive it: a flow that reads a credential refuses a non-terminal stdin outright, and a browser or device flow waits for a person to finish signing in elsewhere before it gives up. Set the credential through its environment variable or flag instead. - Branch on the exit status rather than on stderr text: `0` success, `1` `error`, `2` `usage`, `10` `auth-failed`, `11` `not-found`, `12` `rate-limited`, `13` `client-error`, `14` `server-error`, `15` `connection-error`. A failed request repeats its class on stderr as a stable `code`, with a `hint` when there is a concrete next step; exit `2` is a plain message with no structured body, because the command never ran. - Run `galaxy --help` or `galaxy --help` to discover commands and flags, and `man galaxy` for the full reference. diff --git a/VERSIONING.md b/VERSIONING.md index 662f2b6..f9cf7dd 100644 --- a/VERSIONING.md +++ b/VERSIONING.md @@ -73,6 +73,10 @@ different fixes: platform and the `github-actions` bot to push (or be left unprotected); the default branch only ever advances by merging release PRs, and `scalar-next` receives each released state back from the release workflow. +- **`scalar-next` must not enable "Require linear history."** The Scalar platform merges each + regeneration into it, and merges a resolved conflict back the same way; the release sync + adds another merge whenever a release PR is squash- or rebase-merged. That setting rejects + all of them. - No Actions settings changes are required: the generated workflows declare their own permissions and never create pull requests. - If this package publishes through OIDC trusted publishing (for example PyPI or npm), @@ -84,3 +88,13 @@ different fixes: manual re-publishes at an existing tag; register it as an additional trusted publisher only if you use it. If the publish job is configured with a deployment environment, include that environment in the registration too. + +### Recommended, not required + +Releases work without this; it closes a window where one can go out at the wrong version. + +- **If this repository uses auto-merge, make the `Release PR version` check a required status + check.** Auto-merge merges as soon as the checks that are *required* go green, with no + human present — so a check that is not required but still red at that moment stops + nothing, which is exactly the window between retitling a release PR and the platform + re-rendering it. diff --git a/biome.json b/biome.json index 80f9a92..b2ce844 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.5.7/schema.json", + "$schema": "https://biomejs.dev/schemas/2.5.14/schema.json", "root": true, "files": { "includes": [ diff --git a/man/galaxy-authentication-create-token.1 b/man/galaxy-authentication-create-token.1 index 9753423..522c15e 100644 --- a/man/galaxy-authentication-create-token.1 +++ b/man/galaxy-authentication-create-token.1 @@ -42,6 +42,9 @@ Print transformed string values without JSON quotes. \fB\-\-debug\fR Enable SDK debug logging on standard error. .TP +\fB\-\-environment\fR \fI\fR +Named environment to target: production, void (can also be set with SCALAR_ENVIRONMENT env var) (default: production). Cannot be combined with \-\-base\-url. +.TP \fB\-\-bearer\-auth\fR \fI\fR JWT Bearer token authentication. Can also be set with the BEARER_AUTH environment variable. .TP @@ -77,6 +80,9 @@ galaxy authentication create\-token \e .RE .SH "ENVIRONMENT" .TP +\fBSCALAR_ENVIRONMENT\fR +Fallback for \-\-environment when the flag is not passed. Ignored when \-\-base\-url is passed, and refused beside the base URL environment variable. +.TP \fBBEARER_AUTH\fR Fallback for \-\-bearer\-auth when the flag is not passed. .TP diff --git a/man/galaxy-authentication-create-user.1 b/man/galaxy-authentication-create-user.1 index df26154..4affc28 100644 --- a/man/galaxy-authentication-create-user.1 +++ b/man/galaxy-authentication-create-user.1 @@ -45,6 +45,9 @@ Print transformed string values without JSON quotes. \fB\-\-debug\fR Enable SDK debug logging on standard error. .TP +\fB\-\-environment\fR \fI\fR +Named environment to target: production, void (can also be set with SCALAR_ENVIRONMENT env var) (default: production). Cannot be combined with \-\-base\-url. +.TP \fB\-\-bearer\-auth\fR \fI\fR JWT Bearer token authentication. Can also be set with the BEARER_AUTH environment variable. .TP @@ -81,6 +84,9 @@ galaxy authentication create\-user \e .RE .SH "ENVIRONMENT" .TP +\fBSCALAR_ENVIRONMENT\fR +Fallback for \-\-environment when the flag is not passed. Ignored when \-\-base\-url is passed, and refused beside the base URL environment variable. +.TP \fBBEARER_AUTH\fR Fallback for \-\-bearer\-auth when the flag is not passed. .TP diff --git a/man/galaxy-authentication-list-me.1 b/man/galaxy-authentication-list-me.1 index e144242..8d45c9d 100644 --- a/man/galaxy-authentication-list-me.1 +++ b/man/galaxy-authentication-list-me.1 @@ -36,6 +36,9 @@ Print transformed string values without JSON quotes. \fB\-\-debug\fR Enable SDK debug logging on standard error. .TP +\fB\-\-environment\fR \fI\fR +Named environment to target: production, void (can also be set with SCALAR_ENVIRONMENT env var) (default: production). Cannot be combined with \-\-base\-url. +.TP \fB\-\-bearer\-auth\fR \fI\fR JWT Bearer token authentication. Can also be set with the BEARER_AUTH environment variable. .TP @@ -68,6 +71,9 @@ galaxy authentication list\-me \-\-bearer\-auth "$BEARER_AUTH" .RE .SH "ENVIRONMENT" .TP +\fBSCALAR_ENVIRONMENT\fR +Fallback for \-\-environment when the flag is not passed. Ignored when \-\-base\-url is passed, and refused beside the base URL environment variable. +.TP \fBBEARER_AUTH\fR Fallback for \-\-bearer\-auth when the flag is not passed. .TP diff --git a/man/galaxy-celestial-bodies-create.1 b/man/galaxy-celestial-bodies-create.1 index 1fa0ded..33b7214 100644 --- a/man/galaxy-celestial-bodies-create.1 +++ b/man/galaxy-celestial-bodies-create.1 @@ -36,6 +36,9 @@ Print transformed string values without JSON quotes. \fB\-\-debug\fR Enable SDK debug logging on standard error. .TP +\fB\-\-environment\fR \fI\fR +Named environment to target: production, void (can also be set with SCALAR_ENVIRONMENT env var) (default: production). Cannot be combined with \-\-base\-url. +.TP \fB\-\-bearer\-auth\fR \fI\fR JWT Bearer token authentication. Can also be set with the BEARER_AUTH environment variable. .TP @@ -68,6 +71,9 @@ galaxy celestial\-bodies create \-\-bearer\-auth "$BEARER_AUTH" .RE .SH "ENVIRONMENT" .TP +\fBSCALAR_ENVIRONMENT\fR +Fallback for \-\-environment when the flag is not passed. Ignored when \-\-base\-url is passed, and refused beside the base URL environment variable. +.TP \fBBEARER_AUTH\fR Fallback for \-\-bearer\-auth when the flag is not passed. .TP diff --git a/man/galaxy-environments.1 b/man/galaxy-environments.1 new file mode 100644 index 0000000..7ee4b4f --- /dev/null +++ b/man/galaxy-environments.1 @@ -0,0 +1,61 @@ +.\" File generated from our OpenAPI spec by Scalar. Do not edit. +.TH "GALAXY\-ENVIRONMENTS" "1" "" "galaxy" "Scalar Galaxy Manual" +.SH "NAME" +galaxy\-environments \- list the named environments this API declares +.SH "SYNOPSIS" +.B galaxy environments +[\fB\-\-format\fR \fI\fR] +.SH "DESCRIPTION" +Prints every environment \-\-environment accepts, with the base URL it selects and which one is used by default. The list comes from the same table galaxy resolves the flag against, so it always describes this version's environments. +.SH "OPTIONS" +.TP +\fB\-\-format\fR \fI\fR +Output format: auto, json, jsonl, pretty, raw, toon, yaml. Without one, or with auto or pretty, the list is drawn for a reader: the default marked, each base URL behind its name, and colour only when standard output is a terminal. Name a machine format to parse it. +.SH "ENVIRONMENTS" +\-\-environment selects one of these base URLs, or SCALAR_ENVIRONMENT when the flag is not passed, and production is used when neither is. Run galaxy environments to print the same list. +.TP +\fBproduction\fR +https://galaxy.scalar.com (default) +.TP +\fBvoid\fR +https://void.scalar.com/ +.SH "EXAMPLES" +Print the environments, then call an endpoint against one of them: +.PP +.RS 4 +.nf +galaxy environments +galaxy environments \-\-format json +galaxy \-\-environment production COMMAND +.fi +.RE +.SH "EXIT STATUS" +.TP +\fB0\fR +The command completed successfully. +.TP +\fB1\fR +The request failed for an unclassified reason. Reported as error. +.TP +\fB2\fR +The command was invoked incorrectly (missing or invalid arguments). +.TP +\fB10\fR +Authentication or authorization failed (HTTP 401 or 403). Reported as auth\-failed. +.TP +\fB11\fR +The requested resource does not exist (HTTP 404). Reported as not\-found. +.TP +\fB12\fR +The request was rate limited (HTTP 429). Reported as rate\-limited. +.TP +\fB13\fR +The request was rejected (any other HTTP 4xx). Reported as client\-error. +.TP +\fB14\fR +The API failed to handle the request (HTTP 5xx). Reported as server\-error. +.TP +\fB15\fR +The API could not be reached, or the request timed out. Reported as connection\-error. +.SH "SEE ALSO" +.BR galaxy (1) diff --git a/man/galaxy-login.1 b/man/galaxy-login.1 new file mode 100644 index 0000000..bb296f3 --- /dev/null +++ b/man/galaxy-login.1 @@ -0,0 +1,65 @@ +.\" File generated from our OpenAPI spec by Scalar. Do not edit. +.TH "GALAXY\-LOGIN" "1" "" "galaxy" "Scalar Galaxy Manual" +.SH "NAME" +galaxy\-login \- sign in and save credentials +.SH "SYNOPSIS" +.B galaxy login +[\fB\-\-flow\fR \fIname\fR] [\fB\-\-base\-url\fR \fIurl\fR] [\fB\-\-environment\fR \fIname\fR] +.SH "DESCRIPTION" +Obtains a credential and saves it so later commands do not have to carry one. It goes into your operating system's credential store \- the system keyring on Linux, Credential Manager on Windows \- and falls back to a file in your state directory, readable only by you, when no such store is available. On macOS it is always that file, because the system's own tool accepts a password only on its command line, where other processes could read it. Either way it is filed under the base URL it was captured for, so a credential saved for one host is never sent to another. A credential passed with a flag, or set in the environment, still takes precedence over a saved one. +.SH "OPTIONS" +.TP +\fB\-\-flow\fR \fIname\fR +Sign\-in flow to use, skipping the prompt. One of: bearer\-auth, basic, api\-key\-header, api\-key\-query, api\-key\-cookie, oauth\-client\-credentials, oauth\-password, open\-id\-connect. +.TP +\fB\-\-base\-url\fR \fIurl\fR +Sign in against this base URL instead of the default. +.TP +\fB\-\-environment\fR \fIname\fR +Sign in to the named environment instead of the default. One of: production, void. Falls back to SCALAR_ENVIRONMENT. Cannot be combined with \-\-base\-url. +.SH "SIGN\-IN FLOWS" +.TP +\fBbearer\-auth\fR +Enter your access token. +.TP +\fBbasic\fR +Username and password for basic auth. +.TP +\fBapi\-key\-header\fR +Enter your API key. +.TP +\fBapi\-key\-query\fR +Enter your API key. +.TP +\fBapi\-key\-cookie\fR +Enter your API key. +.TP +\fBoauth\-client\-credentials\fR +Sign in with a client id and secret (OAuth client credentials). +.TP +\fBoauth\-password\fR +Sign in with a username and password (OAuth password grant). +.TP +\fBopen\-id\-connect\fR +Paste an access token you already have. +.SH "EXAMPLES" +.PP +.RS 4 +.nf +galaxy login +galaxy login \-\-flow bearer\-auth +.fi +.RE +.SH "EXIT STATUS" +.TP +\fB0\fR +The command completed successfully. +.TP +\fB2\fR +The command was invoked incorrectly (missing or invalid arguments). +.TP +\fB10\fR +Sign\-in or sign\-out did not complete. The message on standard error says why. +.SH "SEE ALSO" +.BR galaxy (1), +.BR galaxy\-logout (1) diff --git a/man/galaxy-logout.1 b/man/galaxy-logout.1 new file mode 100644 index 0000000..03238ff --- /dev/null +++ b/man/galaxy-logout.1 @@ -0,0 +1,31 @@ +.\" File generated from our OpenAPI spec by Scalar. Do not edit. +.TH "GALAXY\-LOGOUT" "1" "" "galaxy" "Scalar Galaxy Manual" +.SH "NAME" +galaxy\-logout \- forget saved credentials +.SH "SYNOPSIS" +.B galaxy logout +[\fB\-\-all\fR] [\fB\-\-base\-url\fR \fIurl\fR] [\fB\-\-environment\fR \fIname\fR] +.SH "DESCRIPTION" +Removes the credentials saved for one base URL, or for every base URL. Nothing else is affected: a credential supplied through a flag or an environment variable is not stored here and keeps working. +.SH "OPTIONS" +.TP +\fB\-\-all\fR +Forget every saved credential, for every base URL. Cannot be combined with \-\-base\-url, which names one. +.TP +\fB\-\-base\-url\fR \fIurl\fR +Forget the credentials saved for this base URL. Cannot be combined with \-\-all. +.TP +\fB\-\-environment\fR \fIname\fR +Forget the credentials saved for the named environment instead of the default. One of: production, void. Falls back to SCALAR_ENVIRONMENT. Cannot be combined with \-\-base\-url or \-\-all. +.SH "EXIT STATUS" +.TP +\fB0\fR +The command completed successfully. +.TP +\fB2\fR +The command was invoked incorrectly (missing or invalid arguments). +.TP +\fB10\fR +Sign\-in or sign\-out did not complete. The message on standard error says why. +.SH "SEE ALSO" +.BR galaxy (1) diff --git a/man/galaxy-planets-create.1 b/man/galaxy-planets-create.1 index ff8e371..121f9eb 100644 --- a/man/galaxy-planets-create.1 +++ b/man/galaxy-planets-create.1 @@ -102,6 +102,9 @@ Print transformed string values without JSON quotes. \fB\-\-debug\fR Enable SDK debug logging on standard error. .TP +\fB\-\-environment\fR \fI\fR +Named environment to target: production, void (can also be set with SCALAR_ENVIRONMENT env var) (default: production). Cannot be combined with \-\-base\-url. +.TP \fB\-\-bearer\-auth\fR \fI\fR JWT Bearer token authentication. Can also be set with the BEARER_AUTH environment variable. .TP @@ -134,6 +137,9 @@ galaxy planets create \-\-bearer\-auth "$BEARER_AUTH" \-\-name 'Mars' .RE .SH "ENVIRONMENT" .TP +\fBSCALAR_ENVIRONMENT\fR +Fallback for \-\-environment when the flag is not passed. Ignored when \-\-base\-url is passed, and refused beside the base URL environment variable. +.TP \fBBEARER_AUTH\fR Fallback for \-\-bearer\-auth when the flag is not passed. .TP diff --git a/man/galaxy-planets-delete.1 b/man/galaxy-planets-delete.1 index 8c5a87d..89869be 100644 --- a/man/galaxy-planets-delete.1 +++ b/man/galaxy-planets-delete.1 @@ -40,6 +40,9 @@ Print transformed string values without JSON quotes. \fB\-\-debug\fR Enable SDK debug logging on standard error. .TP +\fB\-\-environment\fR \fI\fR +Named environment to target: production, void (can also be set with SCALAR_ENVIRONMENT env var) (default: production). Cannot be combined with \-\-base\-url. +.TP \fB\-\-bearer\-auth\fR \fI\fR JWT Bearer token authentication. Can also be set with the BEARER_AUTH environment variable. .TP @@ -72,6 +75,9 @@ galaxy planets delete '1' \-\-bearer\-auth "$BEARER_AUTH" .RE .SH "ENVIRONMENT" .TP +\fBSCALAR_ENVIRONMENT\fR +Fallback for \-\-environment when the flag is not passed. Ignored when \-\-base\-url is passed, and refused beside the base URL environment variable. +.TP \fBBEARER_AUTH\fR Fallback for \-\-bearer\-auth when the flag is not passed. .TP diff --git a/man/galaxy-planets-delte-image.1 b/man/galaxy-planets-delte-image.1 index 8e2ed58..e33ca76 100644 --- a/man/galaxy-planets-delte-image.1 +++ b/man/galaxy-planets-delte-image.1 @@ -43,6 +43,9 @@ Print transformed string values without JSON quotes. \fB\-\-debug\fR Enable SDK debug logging on standard error. .TP +\fB\-\-environment\fR \fI\fR +Named environment to target: production, void (can also be set with SCALAR_ENVIRONMENT env var) (default: production). Cannot be combined with \-\-base\-url. +.TP \fB\-\-bearer\-auth\fR \fI\fR JWT Bearer token authentication. Can also be set with the BEARER_AUTH environment variable. .TP @@ -75,6 +78,9 @@ galaxy planets delte\-image '1' \-\-bearer\-auth "$BEARER_AUTH" .RE .SH "ENVIRONMENT" .TP +\fBSCALAR_ENVIRONMENT\fR +Fallback for \-\-environment when the flag is not passed. Ignored when \-\-base\-url is passed, and refused beside the base URL environment variable. +.TP \fBBEARER_AUTH\fR Fallback for \-\-bearer\-auth when the flag is not passed. .TP diff --git a/man/galaxy-planets-list-all-data.1 b/man/galaxy-planets-list-all-data.1 index 1d6c075..f9462bc 100644 --- a/man/galaxy-planets-list-all-data.1 +++ b/man/galaxy-planets-list-all-data.1 @@ -42,6 +42,9 @@ Print transformed string values without JSON quotes. \fB\-\-debug\fR Enable SDK debug logging on standard error. .TP +\fB\-\-environment\fR \fI\fR +Named environment to target: production, void (can also be set with SCALAR_ENVIRONMENT env var) (default: production). Cannot be combined with \-\-base\-url. +.TP \fB\-\-bearer\-auth\fR \fI\fR JWT Bearer token authentication. Can also be set with the BEARER_AUTH environment variable. .TP @@ -74,6 +77,9 @@ galaxy planets list\-all\-data \-\-bearer\-auth "$BEARER_AUTH" \-\-limit '10' \- .RE .SH "ENVIRONMENT" .TP +\fBSCALAR_ENVIRONMENT\fR +Fallback for \-\-environment when the flag is not passed. Ignored when \-\-base\-url is passed, and refused beside the base URL environment variable. +.TP \fBBEARER_AUTH\fR Fallback for \-\-bearer\-auth when the flag is not passed. .TP diff --git a/man/galaxy-planets-retrieve.1 b/man/galaxy-planets-retrieve.1 index 80619be..b3aadaf 100644 --- a/man/galaxy-planets-retrieve.1 +++ b/man/galaxy-planets-retrieve.1 @@ -40,6 +40,9 @@ Print transformed string values without JSON quotes. \fB\-\-debug\fR Enable SDK debug logging on standard error. .TP +\fB\-\-environment\fR \fI\fR +Named environment to target: production, void (can also be set with SCALAR_ENVIRONMENT env var) (default: production). Cannot be combined with \-\-base\-url. +.TP \fB\-\-bearer\-auth\fR \fI\fR JWT Bearer token authentication. Can also be set with the BEARER_AUTH environment variable. .TP @@ -72,6 +75,9 @@ galaxy planets retrieve '1' \-\-bearer\-auth "$BEARER_AUTH" .RE .SH "ENVIRONMENT" .TP +\fBSCALAR_ENVIRONMENT\fR +Fallback for \-\-environment when the flag is not passed. Ignored when \-\-base\-url is passed, and refused beside the base URL environment variable. +.TP \fBBEARER_AUTH\fR Fallback for \-\-bearer\-auth when the flag is not passed. .TP diff --git a/man/galaxy-planets-update.1 b/man/galaxy-planets-update.1 index 63f45ec..8adfca2 100644 --- a/man/galaxy-planets-update.1 +++ b/man/galaxy-planets-update.1 @@ -106,6 +106,9 @@ Print transformed string values without JSON quotes. \fB\-\-debug\fR Enable SDK debug logging on standard error. .TP +\fB\-\-environment\fR \fI\fR +Named environment to target: production, void (can also be set with SCALAR_ENVIRONMENT env var) (default: production). Cannot be combined with \-\-base\-url. +.TP \fB\-\-bearer\-auth\fR \fI\fR JWT Bearer token authentication. Can also be set with the BEARER_AUTH environment variable. .TP @@ -138,6 +141,9 @@ galaxy planets update '1' \-\-bearer\-auth "$BEARER_AUTH" \-\-name 'Mars' .RE .SH "ENVIRONMENT" .TP +\fBSCALAR_ENVIRONMENT\fR +Fallback for \-\-environment when the flag is not passed. Ignored when \-\-base\-url is passed, and refused beside the base URL environment variable. +.TP \fBBEARER_AUTH\fR Fallback for \-\-bearer\-auth when the flag is not passed. .TP diff --git a/man/galaxy.1 b/man/galaxy.1 index 35773f3..ab4c484 100644 --- a/man/galaxy.1 +++ b/man/galaxy.1 @@ -38,6 +38,9 @@ Print transformed string values without JSON quotes. \fB\-\-debug\fR Enable SDK debug logging on standard error. .TP +\fB\-\-environment\fR \fI\fR +Named environment to target: production, void (can also be set with SCALAR_ENVIRONMENT env var) (default: production). Cannot be combined with \-\-base\-url. +.TP \fB\-\-bearer\-auth\fR \fI\fR JWT Bearer token authentication. Can also be set with the BEARER_AUTH environment variable. .TP @@ -93,8 +96,25 @@ Get a token. See galaxy\-authentication\-create\-token(1). \fBgalaxy authentication list\-me\fR Get authenticated user. See galaxy\-authentication\-list\-me(1). .TP +\fBgalaxy environments\fR +List the named environments \-\-environment can select. See galaxy\-environments(1). +.TP \fBgalaxy completion\fR Print a shell completion script. See galaxy\-completion(1). +.TP +\fBgalaxy login\fR +Sign in and save credentials for later commands. See galaxy\-login(1). +.TP +\fBgalaxy logout\fR +Forget saved credentials. See galaxy\-logout(1). +.SH "ENVIRONMENTS" +\-\-environment selects one of these base URLs, or SCALAR_ENVIRONMENT when the flag is not passed, and production is used when neither is. Run galaxy environments to print the same list. +.TP +\fBproduction\fR +https://galaxy.scalar.com (default) +.TP +\fBvoid\fR +https://void.scalar.com/ .SH "FILE ARGUMENTS" Any command flag or credential reads its value from a file when the value begins with @. A flag shown with a value uploads a file and takes its path with or without the @. The global options above are read exactly as written. .PP @@ -118,6 +138,9 @@ galaxy completion fish | source .RE .SH "ENVIRONMENT" .TP +\fBSCALAR_ENVIRONMENT\fR +Fallback for \-\-environment when the flag is not passed. Ignored when \-\-base\-url is passed, and refused beside the base URL environment variable. +.TP \fBBEARER_AUTH\fR Fallback for \-\-bearer\-auth when the flag is not passed. .TP diff --git a/package.json b/package.json index 6e3aca4..31fa254 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@scalar/galaxy-cli", - "version": "0.3.0", + "version": "0.3.1", "description": "The Scalar Galaxy is an example OpenAPI document to test OpenAPI tools and libraries. It's a fictional universe with fictional planets and fictional data.", "author": "Scalar Galaxy", "repository": { @@ -31,6 +31,8 @@ ], "man": [ "./man/galaxy.1", + "./man/galaxy-login.1", + "./man/galaxy-logout.1", "./man/galaxy-planets-list-all-data.1", "./man/galaxy-planets-create.1", "./man/galaxy-planets-retrieve.1", @@ -41,6 +43,7 @@ "./man/galaxy-authentication-create-user.1", "./man/galaxy-authentication-create-token.1", "./man/galaxy-authentication-list-me.1", + "./man/galaxy-environments.1", "./man/galaxy-completion.1" ], "scripts": { diff --git a/scalar-sdk.manifest.json b/scalar-sdk.manifest.json index bcf6b4f..e4e4080 100644 --- a/scalar-sdk.manifest.json +++ b/scalar-sdk.manifest.json @@ -1,7 +1,7 @@ { "name": "ScalarGalaxy", "slug": "scalarGalaxy", - "generatorVersion": "0.32.14", + "generatorVersion": "0.36.0", "servers": [ "https://galaxy.scalar.com", "{protocol}://void.scalar.com/{path}" @@ -58,7 +58,6 @@ "kind": "authorizationCode", "authorizationUrl": "https://galaxy.scalar.com/oauth/authorize", "tokenUrl": "https://galaxy.scalar.com/oauth/token", - "refreshUrl": "", "scopes": [ "read:account", "write:planets", @@ -68,7 +67,6 @@ { "kind": "clientCredentials", "tokenUrl": "https://galaxy.scalar.com/oauth/token", - "refreshUrl": "", "scopes": [ "read:account", "write:planets", @@ -78,7 +76,6 @@ { "kind": "password", "tokenUrl": "https://galaxy.scalar.com/oauth/token", - "refreshUrl": "", "scopes": [ "read:account", "write:planets", @@ -88,7 +85,6 @@ { "kind": "implicit", "authorizationUrl": "https://galaxy.scalar.com/oauth/authorize", - "refreshUrl": "", "scopes": [ "read:account", "write:planets", diff --git a/src/cli/completions.ts b/src/cli/completions.ts index 3dd7969..46cb91a 100644 --- a/src/cli/completions.ts +++ b/src/cli/completions.ts @@ -4,12 +4,12 @@ // built from. `galaxy completion ` prints one of them. const bash = - "# File generated from our OpenAPI spec by Scalar. Do not edit.\n\n__galaxy_known_command() {\n case \"$1\" in\n 'completion'|'planets'|'planets list-all-data'|'planets create'|'planets retrieve'|'planets update'|'planets delete'|'planets delte-image'|'celestial-bodies'|'celestial-bodies create'|'authentication'|'authentication create-user'|'authentication create-token'|'authentication list-me') return 0 ;;\n esac\n return 1\n}\n\n__galaxy_path_flag() {\n case \"$1\" in\n 'planets delte-image --image') return 0 ;;\n esac\n return 1\n}\n\n__galaxy_completion() {\n local cur prev node candidate words line index\n COMPREPLY=()\n cur=\"${COMP_WORDS[COMP_CWORD]}\"\n prev=''\n if (( COMP_CWORD > 0 )); then prev=\"${COMP_WORDS[COMP_CWORD-1]}\"; fi\n node=''\n for (( index = 1; index < COMP_CWORD; index++ )); do\n case \"${COMP_WORDS[index]}\" in\n -*) continue ;;\n esac\n if [ -z \"$node\" ]; then\n candidate=\"${COMP_WORDS[index]}\"\n else\n candidate=\"$node ${COMP_WORDS[index]}\"\n fi\n if __galaxy_known_command \"$candidate\"; then node=\"$candidate\"; fi\n done\n\n case \"$node\" in\n '') words='planets celestial-bodies authentication completion help --base-url --timeout --max-retries --format --format-error --transform --transform-error --raw-output --debug --bearer-auth --basic-auth-username --basic-auth-password --api-key-header --api-key-query --api-key-cookie --o-auth2 --open-id-connect --version --help' ;;\n 'completion') words='bash zsh fish --help' ;;\n 'planets') words='list-all-data create retrieve update delete delte-image --help' ;;\n 'planets list-all-data') words='--limit --offset --base-url --timeout --max-retries --format --format-error --transform --transform-error --raw-output --debug --bearer-auth --basic-auth-username --basic-auth-password --api-key-header --api-key-query --api-key-cookie --o-auth2 --open-id-connect --help' ;;\n 'planets create') words='--name --description --type --habitability-index --physical-properties --physical-properties.mass --physical-properties.radius --physical-properties.gravity --physical-properties.temperature --physical-properties.temperature.min --physical-properties.temperature.max --physical-properties.temperature.average --atmosphere --discovered-at --image --satellite --creator --creator.id --creator.name --tag --success-callback-url --failure-callback-url --base-url --timeout --max-retries --format --format-error --transform --transform-error --raw-output --debug --bearer-auth --basic-auth-username --basic-auth-password --api-key-header --api-key-query --api-key-cookie --o-auth2 --open-id-connect --help' ;;\n 'planets retrieve') words='--planet-id --base-url --timeout --max-retries --format --format-error --transform --transform-error --raw-output --debug --bearer-auth --basic-auth-username --basic-auth-password --api-key-header --api-key-query --api-key-cookie --o-auth2 --open-id-connect --help' ;;\n 'planets update') words='--name --description --type --habitability-index --physical-properties --physical-properties.mass --physical-properties.radius --physical-properties.gravity --physical-properties.temperature --physical-properties.temperature.min --physical-properties.temperature.max --physical-properties.temperature.average --atmosphere --discovered-at --image --satellite --creator --creator.id --creator.name --tag --success-callback-url --failure-callback-url --planet-id --base-url --timeout --max-retries --format --format-error --transform --transform-error --raw-output --debug --bearer-auth --basic-auth-username --basic-auth-password --api-key-header --api-key-query --api-key-cookie --o-auth2 --open-id-connect --help' ;;\n 'planets delete') words='--planet-id --base-url --timeout --max-retries --format --format-error --transform --transform-error --raw-output --debug --bearer-auth --basic-auth-username --basic-auth-password --api-key-header --api-key-query --api-key-cookie --o-auth2 --open-id-connect --help' ;;\n 'planets delte-image') words='--image --planet-id --base-url --timeout --max-retries --format --format-error --transform --transform-error --raw-output --debug --bearer-auth --basic-auth-username --basic-auth-password --api-key-header --api-key-query --api-key-cookie --o-auth2 --open-id-connect --help' ;;\n 'celestial-bodies') words='create --help' ;;\n 'celestial-bodies create') words='--base-url --timeout --max-retries --format --format-error --transform --transform-error --raw-output --debug --bearer-auth --basic-auth-username --basic-auth-password --api-key-header --api-key-query --api-key-cookie --o-auth2 --open-id-connect --help' ;;\n 'authentication') words='create-user create-token list-me --help' ;;\n 'authentication create-user') words='--name --email --password --base-url --timeout --max-retries --format --format-error --transform --transform-error --raw-output --debug --bearer-auth --basic-auth-username --basic-auth-password --api-key-header --api-key-query --api-key-cookie --o-auth2 --open-id-connect --help' ;;\n 'authentication create-token') words='--email --password --base-url --timeout --max-retries --format --format-error --transform --transform-error --raw-output --debug --bearer-auth --basic-auth-username --basic-auth-password --api-key-header --api-key-query --api-key-cookie --o-auth2 --open-id-connect --help' ;;\n 'authentication list-me') words='--base-url --timeout --max-retries --format --format-error --transform --transform-error --raw-output --debug --bearer-auth --basic-auth-username --basic-auth-password --api-key-header --api-key-query --api-key-cookie --o-auth2 --open-id-connect --help' ;;\n *) words='' ;;\n esac\n\n if __galaxy_path_flag \"$node $prev\"; then\n compopt -o filenames 2>/dev/null\n while IFS= read -r line; do\n COMPREPLY+=( \"$line\" )\n done < <(compgen -f -- \"$cur\")\n return\n fi\n\n while IFS= read -r line; do\n COMPREPLY+=( \"$line\" )\n done < <(compgen -W \"$words\" -- \"$cur\")\n}\n\ncomplete -F __galaxy_completion 'galaxy'\n"; + "# File generated from our OpenAPI spec by Scalar. Do not edit.\n\n__galaxy_known_command() {\n case \"$1\" in\n 'completion'|'environments'|'planets'|'planets list-all-data'|'planets create'|'planets retrieve'|'planets update'|'planets delete'|'planets delte-image'|'celestial-bodies'|'celestial-bodies create'|'authentication'|'authentication create-user'|'authentication create-token'|'authentication list-me'|'login'|'logout') return 0 ;;\n esac\n return 1\n}\n\n__galaxy_value_flag() {\n case \"$1\" in\n ' --environment'|'planets list-all-data --environment'|'planets create --environment'|'planets retrieve --environment'|'planets update --environment'|'planets delete --environment'|'planets delte-image --environment'|'celestial-bodies create --environment'|'authentication create-user --environment'|'authentication create-token --environment'|'authentication list-me --environment'|'login --environment'|'logout --environment') echo 'production void' ;;\n esac\n return 0\n}\n\n__galaxy_path_flag() {\n case \"$1\" in\n 'planets delte-image --image') return 0 ;;\n esac\n return 1\n}\n\n__galaxy_completion() {\n local cur prev node candidate words line index values\n COMPREPLY=()\n cur=\"${COMP_WORDS[COMP_CWORD]}\"\n prev=''\n if (( COMP_CWORD > 0 )); then prev=\"${COMP_WORDS[COMP_CWORD-1]}\"; fi\n node=''\n for (( index = 1; index < COMP_CWORD; index++ )); do\n case \"${COMP_WORDS[index]}\" in\n -*) continue ;;\n esac\n if [ -z \"$node\" ]; then\n candidate=\"${COMP_WORDS[index]}\"\n else\n candidate=\"$node ${COMP_WORDS[index]}\"\n fi\n if __galaxy_known_command \"$candidate\"; then node=\"$candidate\"; fi\n done\n\n case \"$node\" in\n '') words='planets celestial-bodies authentication login logout environments completion help --base-url --timeout --max-retries --format --format-error --transform --transform-error --raw-output --debug --environment --bearer-auth --basic-auth-username --basic-auth-password --api-key-header --api-key-query --api-key-cookie --o-auth2 --open-id-connect --version --help' ;;\n 'completion') words='bash zsh fish --help' ;;\n 'environments') words='--format --help' ;;\n 'planets') words='list-all-data create retrieve update delete delte-image --help' ;;\n 'planets list-all-data') words='--limit --offset --base-url --timeout --max-retries --format --format-error --transform --transform-error --raw-output --debug --environment --bearer-auth --basic-auth-username --basic-auth-password --api-key-header --api-key-query --api-key-cookie --o-auth2 --open-id-connect --help' ;;\n 'planets create') words='--name --description --type --habitability-index --physical-properties --physical-properties.mass --physical-properties.radius --physical-properties.gravity --physical-properties.temperature --physical-properties.temperature.min --physical-properties.temperature.max --physical-properties.temperature.average --atmosphere --discovered-at --image --satellite --creator --creator.id --creator.name --tag --success-callback-url --failure-callback-url --base-url --timeout --max-retries --format --format-error --transform --transform-error --raw-output --debug --environment --bearer-auth --basic-auth-username --basic-auth-password --api-key-header --api-key-query --api-key-cookie --o-auth2 --open-id-connect --help' ;;\n 'planets retrieve') words='--planet-id --base-url --timeout --max-retries --format --format-error --transform --transform-error --raw-output --debug --environment --bearer-auth --basic-auth-username --basic-auth-password --api-key-header --api-key-query --api-key-cookie --o-auth2 --open-id-connect --help' ;;\n 'planets update') words='--name --description --type --habitability-index --physical-properties --physical-properties.mass --physical-properties.radius --physical-properties.gravity --physical-properties.temperature --physical-properties.temperature.min --physical-properties.temperature.max --physical-properties.temperature.average --atmosphere --discovered-at --image --satellite --creator --creator.id --creator.name --tag --success-callback-url --failure-callback-url --planet-id --base-url --timeout --max-retries --format --format-error --transform --transform-error --raw-output --debug --environment --bearer-auth --basic-auth-username --basic-auth-password --api-key-header --api-key-query --api-key-cookie --o-auth2 --open-id-connect --help' ;;\n 'planets delete') words='--planet-id --base-url --timeout --max-retries --format --format-error --transform --transform-error --raw-output --debug --environment --bearer-auth --basic-auth-username --basic-auth-password --api-key-header --api-key-query --api-key-cookie --o-auth2 --open-id-connect --help' ;;\n 'planets delte-image') words='--image --planet-id --base-url --timeout --max-retries --format --format-error --transform --transform-error --raw-output --debug --environment --bearer-auth --basic-auth-username --basic-auth-password --api-key-header --api-key-query --api-key-cookie --o-auth2 --open-id-connect --help' ;;\n 'celestial-bodies') words='create --help' ;;\n 'celestial-bodies create') words='--base-url --timeout --max-retries --format --format-error --transform --transform-error --raw-output --debug --environment --bearer-auth --basic-auth-username --basic-auth-password --api-key-header --api-key-query --api-key-cookie --o-auth2 --open-id-connect --help' ;;\n 'authentication') words='create-user create-token list-me --help' ;;\n 'authentication create-user') words='--name --email --password --base-url --timeout --max-retries --format --format-error --transform --transform-error --raw-output --debug --environment --bearer-auth --basic-auth-username --basic-auth-password --api-key-header --api-key-query --api-key-cookie --o-auth2 --open-id-connect --help' ;;\n 'authentication create-token') words='--email --password --base-url --timeout --max-retries --format --format-error --transform --transform-error --raw-output --debug --environment --bearer-auth --basic-auth-username --basic-auth-password --api-key-header --api-key-query --api-key-cookie --o-auth2 --open-id-connect --help' ;;\n 'authentication list-me') words='--base-url --timeout --max-retries --format --format-error --transform --transform-error --raw-output --debug --environment --bearer-auth --basic-auth-username --basic-auth-password --api-key-header --api-key-query --api-key-cookie --o-auth2 --open-id-connect --help' ;;\n 'login') words='--base-url --flow --environment --help' ;;\n 'logout') words='--base-url --all --environment --help' ;;\n *) words='' ;;\n esac\n\n values=\"$(__galaxy_value_flag \"$node $prev\")\"\n if [ -n \"$values\" ]; then\n while IFS= read -r line; do\n COMPREPLY+=( \"$line\" )\n done < <(compgen -W \"$values\" -- \"$cur\")\n return\n fi\n\n if __galaxy_path_flag \"$node $prev\"; then\n compopt -o filenames 2>/dev/null\n while IFS= read -r line; do\n COMPREPLY+=( \"$line\" )\n done < <(compgen -f -- \"$cur\")\n return\n fi\n\n while IFS= read -r line; do\n COMPREPLY+=( \"$line\" )\n done < <(compgen -W \"$words\" -- \"$cur\")\n}\n\ncomplete -F __galaxy_completion 'galaxy'\n"; const zsh = - "#compdef galaxy\n# File generated from our OpenAPI spec by Scalar. Do not edit.\n\n__galaxy_known_command() {\n case \"$1\" in\n 'completion'|'planets'|'planets list-all-data'|'planets create'|'planets retrieve'|'planets update'|'planets delete'|'planets delte-image'|'celestial-bodies'|'celestial-bodies create'|'authentication'|'authentication create-user'|'authentication create-token'|'authentication list-me') return 0 ;;\n esac\n return 1\n}\n\n__galaxy_completion() {\n local node candidate\n local -a subcommands options\n local -i index matched\n node=''\n matched=1\n for (( index = 2; index < CURRENT; index++ )); do\n case ${words[index]} in\n -*) continue ;;\n esac\n if [[ -z $node ]]; then\n candidate=${words[index]}\n else\n candidate=\"$node ${words[index]}\"\n fi\n if __galaxy_known_command \"$candidate\"; then\n node=$candidate\n matched=$index\n fi\n done\n\n subcommands=()\n options=()\n case \"$node\" in\n '')\n subcommands=('planets:planets commands' 'celestial-bodies:celestial-bodies commands' 'authentication:authentication commands' 'completion:Print a shell completion script' 'help:Show help for a command')\n options=('--base-url[Override the base URL for API requests]:value:' '--timeout[Request timeout in milliseconds]:value:' '--max-retries[Number of retries for retryable failures]:value:' '--format[Output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--format-error[Error output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--transform[Dot-path transform for data output]:value:' '--transform-error[Dot-path transform for error output]:value:' '--raw-output[Print transformed string values without JSON quotes]' '--debug[Enable SDK debug logging]' '--bearer-auth[JWT Bearer token authentication ($BEARER_AUTH)]:value:' '--basic-auth-username[Basic HTTP authentication ($BASIC_AUTH_USERNAME)]:value:' '--basic-auth-password[Basic HTTP authentication ($BASIC_AUTH_PASSWORD)]:value:' '--api-key-header[API key request header ($API_KEY_HEADER)]:value:' '--api-key-query[API key query parameter ($API_KEY_QUERY)]:value:' '--api-key-cookie[API key browser cookie ($API_KEY_COOKIE)]:value:' '--o-auth2[OAuth 2.0 authentication ($SCALAR_O_AUTH2)]:value:' '--open-id-connect[OpenID Connect Authentication ($SCALAR_OPEN_ID_CONNECT)]:value:' '--version[Print the CLI version]' '--help[Show help for this command]')\n ;;\n 'completion')\n subcommands=('bash:Print a bash completion script' 'zsh:Print a zsh completion script' 'fish:Print a fish completion script')\n options=('--help[Show help for this command]')\n ;;\n 'planets')\n subcommands=('list-all-data:Get all planets' 'create:Create a planet' 'retrieve:Get a planet' 'update:Update a planet' 'delete:Delete a planet' 'delte-image:Upload an image to a planet')\n options=('--help[Show help for this command]')\n ;;\n 'planets list-all-data')\n options=('--limit[The number of items to return]:value:' '--offset[The number of items to skip before starting to collect the result set]:value:' '--base-url[Override the base URL for API requests]:value:' '--timeout[Request timeout in milliseconds]:value:' '--max-retries[Number of retries for retryable failures]:value:' '--format[Output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--format-error[Error output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--transform[Dot-path transform for data output]:value:' '--transform-error[Dot-path transform for error output]:value:' '--raw-output[Print transformed string values without JSON quotes]' '--debug[Enable SDK debug logging]' '--bearer-auth[JWT Bearer token authentication ($BEARER_AUTH)]:value:' '--basic-auth-username[Basic HTTP authentication ($BASIC_AUTH_USERNAME)]:value:' '--basic-auth-password[Basic HTTP authentication ($BASIC_AUTH_PASSWORD)]:value:' '--api-key-header[API key request header ($API_KEY_HEADER)]:value:' '--api-key-query[API key query parameter ($API_KEY_QUERY)]:value:' '--api-key-cookie[API key browser cookie ($API_KEY_COOKIE)]:value:' '--o-auth2[OAuth 2.0 authentication ($SCALAR_O_AUTH2)]:value:' '--open-id-connect[OpenID Connect Authentication ($SCALAR_OPEN_ID_CONNECT)]:value:' '--help[Show help for this command]')\n ;;\n 'planets create')\n options=('--name[The name body parameter]:value:' '--description[The description body parameter]:value:' '--type[The type body parameter]:value:' '--habitability-index[A score from 0 to 1 indicating potential habitability]:value:' '--physical-properties[The physical-properties body parameter]:value:' '--physical-properties.mass[Mass in Earth masses (must be greater than 0)]:value:' '--physical-properties.radius[Radius in Earth radii (must be greater than 0)]:value:' '--physical-properties.gravity[Surface gravity in Earth g]:value:' '--physical-properties.temperature[The physical-properties.temperature body parameter]:value:' '--physical-properties.temperature.min[Minimum temperature in Kelvin]:value:' '--physical-properties.temperature.max[Maximum temperature in Kelvin]:value:' '--physical-properties.temperature.average[Average temperature in Kelvin]:value:' '--atmosphere[Atmospheric composition]:value:' '--discovered-at[The discovered-at body parameter]:value:' '--image[The image body parameter]:value:' '--satellite[The satellite body parameter]:value:' '--creator[A user]:value:' '--creator.id[The creator.id body parameter]:value:' '--creator.name[The creator.name body parameter]:value:' '--tag[The tag body parameter]:value:' '--success-callback-url[URL which gets invoked upon a successful operation]:value:' '--failure-callback-url[URL which gets invoked upon a failed operation]:value:' '--base-url[Override the base URL for API requests]:value:' '--timeout[Request timeout in milliseconds]:value:' '--max-retries[Number of retries for retryable failures]:value:' '--format[Output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--format-error[Error output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--transform[Dot-path transform for data output]:value:' '--transform-error[Dot-path transform for error output]:value:' '--raw-output[Print transformed string values without JSON quotes]' '--debug[Enable SDK debug logging]' '--bearer-auth[JWT Bearer token authentication ($BEARER_AUTH)]:value:' '--basic-auth-username[Basic HTTP authentication ($BASIC_AUTH_USERNAME)]:value:' '--basic-auth-password[Basic HTTP authentication ($BASIC_AUTH_PASSWORD)]:value:' '--api-key-header[API key request header ($API_KEY_HEADER)]:value:' '--api-key-query[API key query parameter ($API_KEY_QUERY)]:value:' '--api-key-cookie[API key browser cookie ($API_KEY_COOKIE)]:value:' '--o-auth2[OAuth 2.0 authentication ($SCALAR_O_AUTH2)]:value:' '--open-id-connect[OpenID Connect Authentication ($SCALAR_OPEN_ID_CONNECT)]:value:' '--help[Show help for this command]')\n ;;\n 'planets retrieve')\n options=('--planet-id[The ID of the planet to get]:value:' '--base-url[Override the base URL for API requests]:value:' '--timeout[Request timeout in milliseconds]:value:' '--max-retries[Number of retries for retryable failures]:value:' '--format[Output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--format-error[Error output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--transform[Dot-path transform for data output]:value:' '--transform-error[Dot-path transform for error output]:value:' '--raw-output[Print transformed string values without JSON quotes]' '--debug[Enable SDK debug logging]' '--bearer-auth[JWT Bearer token authentication ($BEARER_AUTH)]:value:' '--basic-auth-username[Basic HTTP authentication ($BASIC_AUTH_USERNAME)]:value:' '--basic-auth-password[Basic HTTP authentication ($BASIC_AUTH_PASSWORD)]:value:' '--api-key-header[API key request header ($API_KEY_HEADER)]:value:' '--api-key-query[API key query parameter ($API_KEY_QUERY)]:value:' '--api-key-cookie[API key browser cookie ($API_KEY_COOKIE)]:value:' '--o-auth2[OAuth 2.0 authentication ($SCALAR_O_AUTH2)]:value:' '--open-id-connect[OpenID Connect Authentication ($SCALAR_OPEN_ID_CONNECT)]:value:' '--help[Show help for this command]')\n ;;\n 'planets update')\n options=('--name[The name body parameter]:value:' '--description[The description body parameter]:value:' '--type[The type body parameter]:value:' '--habitability-index[A score from 0 to 1 indicating potential habitability]:value:' '--physical-properties[The physical-properties body parameter]:value:' '--physical-properties.mass[Mass in Earth masses (must be greater than 0)]:value:' '--physical-properties.radius[Radius in Earth radii (must be greater than 0)]:value:' '--physical-properties.gravity[Surface gravity in Earth g]:value:' '--physical-properties.temperature[The physical-properties.temperature body parameter]:value:' '--physical-properties.temperature.min[Minimum temperature in Kelvin]:value:' '--physical-properties.temperature.max[Maximum temperature in Kelvin]:value:' '--physical-properties.temperature.average[Average temperature in Kelvin]:value:' '--atmosphere[Atmospheric composition]:value:' '--discovered-at[The discovered-at body parameter]:value:' '--image[The image body parameter]:value:' '--satellite[The satellite body parameter]:value:' '--creator[A user]:value:' '--creator.id[The creator.id body parameter]:value:' '--creator.name[The creator.name body parameter]:value:' '--tag[The tag body parameter]:value:' '--success-callback-url[URL which gets invoked upon a successful operation]:value:' '--failure-callback-url[URL which gets invoked upon a failed operation]:value:' '--planet-id[The ID of the planet to get]:value:' '--base-url[Override the base URL for API requests]:value:' '--timeout[Request timeout in milliseconds]:value:' '--max-retries[Number of retries for retryable failures]:value:' '--format[Output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--format-error[Error output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--transform[Dot-path transform for data output]:value:' '--transform-error[Dot-path transform for error output]:value:' '--raw-output[Print transformed string values without JSON quotes]' '--debug[Enable SDK debug logging]' '--bearer-auth[JWT Bearer token authentication ($BEARER_AUTH)]:value:' '--basic-auth-username[Basic HTTP authentication ($BASIC_AUTH_USERNAME)]:value:' '--basic-auth-password[Basic HTTP authentication ($BASIC_AUTH_PASSWORD)]:value:' '--api-key-header[API key request header ($API_KEY_HEADER)]:value:' '--api-key-query[API key query parameter ($API_KEY_QUERY)]:value:' '--api-key-cookie[API key browser cookie ($API_KEY_COOKIE)]:value:' '--o-auth2[OAuth 2.0 authentication ($SCALAR_O_AUTH2)]:value:' '--open-id-connect[OpenID Connect Authentication ($SCALAR_OPEN_ID_CONNECT)]:value:' '--help[Show help for this command]')\n ;;\n 'planets delete')\n options=('--planet-id[The ID of the planet to get]:value:' '--base-url[Override the base URL for API requests]:value:' '--timeout[Request timeout in milliseconds]:value:' '--max-retries[Number of retries for retryable failures]:value:' '--format[Output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--format-error[Error output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--transform[Dot-path transform for data output]:value:' '--transform-error[Dot-path transform for error output]:value:' '--raw-output[Print transformed string values without JSON quotes]' '--debug[Enable SDK debug logging]' '--bearer-auth[JWT Bearer token authentication ($BEARER_AUTH)]:value:' '--basic-auth-username[Basic HTTP authentication ($BASIC_AUTH_USERNAME)]:value:' '--basic-auth-password[Basic HTTP authentication ($BASIC_AUTH_PASSWORD)]:value:' '--api-key-header[API key request header ($API_KEY_HEADER)]:value:' '--api-key-query[API key query parameter ($API_KEY_QUERY)]:value:' '--api-key-cookie[API key browser cookie ($API_KEY_COOKIE)]:value:' '--o-auth2[OAuth 2.0 authentication ($SCALAR_O_AUTH2)]:value:' '--open-id-connect[OpenID Connect Authentication ($SCALAR_OPEN_ID_CONNECT)]:value:' '--help[Show help for this command]')\n ;;\n 'planets delte-image')\n options=('--image[The image file to upload]:path:_files' '--planet-id[The ID of the planet to get]:value:' '--base-url[Override the base URL for API requests]:value:' '--timeout[Request timeout in milliseconds]:value:' '--max-retries[Number of retries for retryable failures]:value:' '--format[Output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--format-error[Error output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--transform[Dot-path transform for data output]:value:' '--transform-error[Dot-path transform for error output]:value:' '--raw-output[Print transformed string values without JSON quotes]' '--debug[Enable SDK debug logging]' '--bearer-auth[JWT Bearer token authentication ($BEARER_AUTH)]:value:' '--basic-auth-username[Basic HTTP authentication ($BASIC_AUTH_USERNAME)]:value:' '--basic-auth-password[Basic HTTP authentication ($BASIC_AUTH_PASSWORD)]:value:' '--api-key-header[API key request header ($API_KEY_HEADER)]:value:' '--api-key-query[API key query parameter ($API_KEY_QUERY)]:value:' '--api-key-cookie[API key browser cookie ($API_KEY_COOKIE)]:value:' '--o-auth2[OAuth 2.0 authentication ($SCALAR_O_AUTH2)]:value:' '--open-id-connect[OpenID Connect Authentication ($SCALAR_OPEN_ID_CONNECT)]:value:' '--help[Show help for this command]')\n ;;\n 'celestial-bodies')\n subcommands=('create:Create a celestial body')\n options=('--help[Show help for this command]')\n ;;\n 'celestial-bodies create')\n options=('--base-url[Override the base URL for API requests]:value:' '--timeout[Request timeout in milliseconds]:value:' '--max-retries[Number of retries for retryable failures]:value:' '--format[Output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--format-error[Error output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--transform[Dot-path transform for data output]:value:' '--transform-error[Dot-path transform for error output]:value:' '--raw-output[Print transformed string values without JSON quotes]' '--debug[Enable SDK debug logging]' '--bearer-auth[JWT Bearer token authentication ($BEARER_AUTH)]:value:' '--basic-auth-username[Basic HTTP authentication ($BASIC_AUTH_USERNAME)]:value:' '--basic-auth-password[Basic HTTP authentication ($BASIC_AUTH_PASSWORD)]:value:' '--api-key-header[API key request header ($API_KEY_HEADER)]:value:' '--api-key-query[API key query parameter ($API_KEY_QUERY)]:value:' '--api-key-cookie[API key browser cookie ($API_KEY_COOKIE)]:value:' '--o-auth2[OAuth 2.0 authentication ($SCALAR_O_AUTH2)]:value:' '--open-id-connect[OpenID Connect Authentication ($SCALAR_OPEN_ID_CONNECT)]:value:' '--help[Show help for this command]')\n ;;\n 'authentication')\n subcommands=('create-user:Create a user' 'create-token:Get a token' 'list-me:Get authenticated user')\n options=('--help[Show help for this command]')\n ;;\n 'authentication create-user')\n options=('--name[The name body parameter]:value:' '--email[The email body parameter]:value:' '--password[The password body parameter]:value:' '--base-url[Override the base URL for API requests]:value:' '--timeout[Request timeout in milliseconds]:value:' '--max-retries[Number of retries for retryable failures]:value:' '--format[Output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--format-error[Error output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--transform[Dot-path transform for data output]:value:' '--transform-error[Dot-path transform for error output]:value:' '--raw-output[Print transformed string values without JSON quotes]' '--debug[Enable SDK debug logging]' '--bearer-auth[JWT Bearer token authentication ($BEARER_AUTH)]:value:' '--basic-auth-username[Basic HTTP authentication ($BASIC_AUTH_USERNAME)]:value:' '--basic-auth-password[Basic HTTP authentication ($BASIC_AUTH_PASSWORD)]:value:' '--api-key-header[API key request header ($API_KEY_HEADER)]:value:' '--api-key-query[API key query parameter ($API_KEY_QUERY)]:value:' '--api-key-cookie[API key browser cookie ($API_KEY_COOKIE)]:value:' '--o-auth2[OAuth 2.0 authentication ($SCALAR_O_AUTH2)]:value:' '--open-id-connect[OpenID Connect Authentication ($SCALAR_OPEN_ID_CONNECT)]:value:' '--help[Show help for this command]')\n ;;\n 'authentication create-token')\n options=('--email[The email body parameter]:value:' '--password[The password body parameter]:value:' '--base-url[Override the base URL for API requests]:value:' '--timeout[Request timeout in milliseconds]:value:' '--max-retries[Number of retries for retryable failures]:value:' '--format[Output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--format-error[Error output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--transform[Dot-path transform for data output]:value:' '--transform-error[Dot-path transform for error output]:value:' '--raw-output[Print transformed string values without JSON quotes]' '--debug[Enable SDK debug logging]' '--bearer-auth[JWT Bearer token authentication ($BEARER_AUTH)]:value:' '--basic-auth-username[Basic HTTP authentication ($BASIC_AUTH_USERNAME)]:value:' '--basic-auth-password[Basic HTTP authentication ($BASIC_AUTH_PASSWORD)]:value:' '--api-key-header[API key request header ($API_KEY_HEADER)]:value:' '--api-key-query[API key query parameter ($API_KEY_QUERY)]:value:' '--api-key-cookie[API key browser cookie ($API_KEY_COOKIE)]:value:' '--o-auth2[OAuth 2.0 authentication ($SCALAR_O_AUTH2)]:value:' '--open-id-connect[OpenID Connect Authentication ($SCALAR_OPEN_ID_CONNECT)]:value:' '--help[Show help for this command]')\n ;;\n 'authentication list-me')\n options=('--base-url[Override the base URL for API requests]:value:' '--timeout[Request timeout in milliseconds]:value:' '--max-retries[Number of retries for retryable failures]:value:' '--format[Output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--format-error[Error output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--transform[Dot-path transform for data output]:value:' '--transform-error[Dot-path transform for error output]:value:' '--raw-output[Print transformed string values without JSON quotes]' '--debug[Enable SDK debug logging]' '--bearer-auth[JWT Bearer token authentication ($BEARER_AUTH)]:value:' '--basic-auth-username[Basic HTTP authentication ($BASIC_AUTH_USERNAME)]:value:' '--basic-auth-password[Basic HTTP authentication ($BASIC_AUTH_PASSWORD)]:value:' '--api-key-header[API key request header ($API_KEY_HEADER)]:value:' '--api-key-query[API key query parameter ($API_KEY_QUERY)]:value:' '--api-key-cookie[API key browser cookie ($API_KEY_COOKIE)]:value:' '--o-auth2[OAuth 2.0 authentication ($SCALAR_O_AUTH2)]:value:' '--open-id-connect[OpenID Connect Authentication ($SCALAR_OPEN_ID_CONNECT)]:value:' '--help[Show help for this command]')\n ;;\n esac\n\n words=( \"$words[1]\" \"${(@)words[matched+1,-1]}\" )\n (( CURRENT -= matched - 1 ))\n\n (( ${#options} )) && _arguments -s -S $options\n (( ${#subcommands} )) && _describe -t commands 'command' subcommands\n}\n\nif [[ ${zsh_eval_context[-1]} == loadautofunc ]]; then\n __galaxy_completion \"$@\"\nelse\n compdef __galaxy_completion 'galaxy'\nfi\n"; + "#compdef galaxy\n# File generated from our OpenAPI spec by Scalar. Do not edit.\n\n__galaxy_known_command() {\n case \"$1\" in\n 'completion'|'environments'|'planets'|'planets list-all-data'|'planets create'|'planets retrieve'|'planets update'|'planets delete'|'planets delte-image'|'celestial-bodies'|'celestial-bodies create'|'authentication'|'authentication create-user'|'authentication create-token'|'authentication list-me'|'login'|'logout') return 0 ;;\n esac\n return 1\n}\n\n__galaxy_completion() {\n local node candidate\n local -a subcommands options\n local -i index matched\n node=''\n matched=1\n for (( index = 2; index < CURRENT; index++ )); do\n case ${words[index]} in\n -*) continue ;;\n esac\n if [[ -z $node ]]; then\n candidate=${words[index]}\n else\n candidate=\"$node ${words[index]}\"\n fi\n if __galaxy_known_command \"$candidate\"; then\n node=$candidate\n matched=$index\n fi\n done\n\n subcommands=()\n options=()\n case \"$node\" in\n '')\n subcommands=('planets:planets commands' 'celestial-bodies:celestial-bodies commands' 'authentication:authentication commands' 'login:Sign in and save credentials for later commands' 'logout:Forget saved credentials' 'environments:List the named environments --environment can select' 'completion:Print a shell completion script' 'help:Show help for a command')\n options=('--base-url[Override the base URL for API requests]:value:' '--timeout[Request timeout in milliseconds]:value:' '--max-retries[Number of retries for retryable failures]:value:' '--format[Output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--format-error[Error output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--transform[Dot-path transform for data output]:value:' '--transform-error[Dot-path transform for error output]:value:' '--raw-output[Print transformed string values without JSON quotes]' '--debug[Enable SDK debug logging]' '--environment[Named environment to target\\: production, void (default\\: production)]:value:(production void)' '--bearer-auth[JWT Bearer token authentication ($BEARER_AUTH)]:value:' '--basic-auth-username[Basic HTTP authentication ($BASIC_AUTH_USERNAME)]:value:' '--basic-auth-password[Basic HTTP authentication ($BASIC_AUTH_PASSWORD)]:value:' '--api-key-header[API key request header ($API_KEY_HEADER)]:value:' '--api-key-query[API key query parameter ($API_KEY_QUERY)]:value:' '--api-key-cookie[API key browser cookie ($API_KEY_COOKIE)]:value:' '--o-auth2[OAuth 2.0 authentication ($SCALAR_O_AUTH2)]:value:' '--open-id-connect[OpenID Connect Authentication ($SCALAR_OPEN_ID_CONNECT)]:value:' '--version[Print the CLI version]' '--help[Show help for this command]')\n ;;\n 'completion')\n subcommands=('bash:Print a bash completion script' 'zsh:Print a zsh completion script' 'fish:Print a fish completion script')\n options=('--help[Show help for this command]')\n ;;\n 'environments')\n options=('--format[Output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--help[Show help for this command]')\n ;;\n 'planets')\n subcommands=('list-all-data:Get all planets' 'create:Create a planet' 'retrieve:Get a planet' 'update:Update a planet' 'delete:Delete a planet' 'delte-image:Upload an image to a planet')\n options=('--help[Show help for this command]')\n ;;\n 'planets list-all-data')\n options=('--limit[The number of items to return]:value:' '--offset[The number of items to skip before starting to collect the result set]:value:' '--base-url[Override the base URL for API requests]:value:' '--timeout[Request timeout in milliseconds]:value:' '--max-retries[Number of retries for retryable failures]:value:' '--format[Output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--format-error[Error output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--transform[Dot-path transform for data output]:value:' '--transform-error[Dot-path transform for error output]:value:' '--raw-output[Print transformed string values without JSON quotes]' '--debug[Enable SDK debug logging]' '--environment[Named environment to target\\: production, void (default\\: production)]:value:(production void)' '--bearer-auth[JWT Bearer token authentication ($BEARER_AUTH)]:value:' '--basic-auth-username[Basic HTTP authentication ($BASIC_AUTH_USERNAME)]:value:' '--basic-auth-password[Basic HTTP authentication ($BASIC_AUTH_PASSWORD)]:value:' '--api-key-header[API key request header ($API_KEY_HEADER)]:value:' '--api-key-query[API key query parameter ($API_KEY_QUERY)]:value:' '--api-key-cookie[API key browser cookie ($API_KEY_COOKIE)]:value:' '--o-auth2[OAuth 2.0 authentication ($SCALAR_O_AUTH2)]:value:' '--open-id-connect[OpenID Connect Authentication ($SCALAR_OPEN_ID_CONNECT)]:value:' '--help[Show help for this command]')\n ;;\n 'planets create')\n options=('--name[The name body parameter]:value:' '--description[The description body parameter]:value:' '--type[The type body parameter]:value:' '--habitability-index[A score from 0 to 1 indicating potential habitability]:value:' '--physical-properties[The physical-properties body parameter]:value:' '--physical-properties.mass[Mass in Earth masses (must be greater than 0)]:value:' '--physical-properties.radius[Radius in Earth radii (must be greater than 0)]:value:' '--physical-properties.gravity[Surface gravity in Earth g]:value:' '--physical-properties.temperature[The physical-properties.temperature body parameter]:value:' '--physical-properties.temperature.min[Minimum temperature in Kelvin]:value:' '--physical-properties.temperature.max[Maximum temperature in Kelvin]:value:' '--physical-properties.temperature.average[Average temperature in Kelvin]:value:' '--atmosphere[Atmospheric composition]:value:' '--discovered-at[The discovered-at body parameter]:value:' '--image[The image body parameter]:value:' '--satellite[The satellite body parameter]:value:' '--creator[A user]:value:' '--creator.id[The creator.id body parameter]:value:' '--creator.name[The creator.name body parameter]:value:' '--tag[The tag body parameter]:value:' '--success-callback-url[URL which gets invoked upon a successful operation]:value:' '--failure-callback-url[URL which gets invoked upon a failed operation]:value:' '--base-url[Override the base URL for API requests]:value:' '--timeout[Request timeout in milliseconds]:value:' '--max-retries[Number of retries for retryable failures]:value:' '--format[Output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--format-error[Error output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--transform[Dot-path transform for data output]:value:' '--transform-error[Dot-path transform for error output]:value:' '--raw-output[Print transformed string values without JSON quotes]' '--debug[Enable SDK debug logging]' '--environment[Named environment to target\\: production, void (default\\: production)]:value:(production void)' '--bearer-auth[JWT Bearer token authentication ($BEARER_AUTH)]:value:' '--basic-auth-username[Basic HTTP authentication ($BASIC_AUTH_USERNAME)]:value:' '--basic-auth-password[Basic HTTP authentication ($BASIC_AUTH_PASSWORD)]:value:' '--api-key-header[API key request header ($API_KEY_HEADER)]:value:' '--api-key-query[API key query parameter ($API_KEY_QUERY)]:value:' '--api-key-cookie[API key browser cookie ($API_KEY_COOKIE)]:value:' '--o-auth2[OAuth 2.0 authentication ($SCALAR_O_AUTH2)]:value:' '--open-id-connect[OpenID Connect Authentication ($SCALAR_OPEN_ID_CONNECT)]:value:' '--help[Show help for this command]')\n ;;\n 'planets retrieve')\n options=('--planet-id[The ID of the planet to get]:value:' '--base-url[Override the base URL for API requests]:value:' '--timeout[Request timeout in milliseconds]:value:' '--max-retries[Number of retries for retryable failures]:value:' '--format[Output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--format-error[Error output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--transform[Dot-path transform for data output]:value:' '--transform-error[Dot-path transform for error output]:value:' '--raw-output[Print transformed string values without JSON quotes]' '--debug[Enable SDK debug logging]' '--environment[Named environment to target\\: production, void (default\\: production)]:value:(production void)' '--bearer-auth[JWT Bearer token authentication ($BEARER_AUTH)]:value:' '--basic-auth-username[Basic HTTP authentication ($BASIC_AUTH_USERNAME)]:value:' '--basic-auth-password[Basic HTTP authentication ($BASIC_AUTH_PASSWORD)]:value:' '--api-key-header[API key request header ($API_KEY_HEADER)]:value:' '--api-key-query[API key query parameter ($API_KEY_QUERY)]:value:' '--api-key-cookie[API key browser cookie ($API_KEY_COOKIE)]:value:' '--o-auth2[OAuth 2.0 authentication ($SCALAR_O_AUTH2)]:value:' '--open-id-connect[OpenID Connect Authentication ($SCALAR_OPEN_ID_CONNECT)]:value:' '--help[Show help for this command]')\n ;;\n 'planets update')\n options=('--name[The name body parameter]:value:' '--description[The description body parameter]:value:' '--type[The type body parameter]:value:' '--habitability-index[A score from 0 to 1 indicating potential habitability]:value:' '--physical-properties[The physical-properties body parameter]:value:' '--physical-properties.mass[Mass in Earth masses (must be greater than 0)]:value:' '--physical-properties.radius[Radius in Earth radii (must be greater than 0)]:value:' '--physical-properties.gravity[Surface gravity in Earth g]:value:' '--physical-properties.temperature[The physical-properties.temperature body parameter]:value:' '--physical-properties.temperature.min[Minimum temperature in Kelvin]:value:' '--physical-properties.temperature.max[Maximum temperature in Kelvin]:value:' '--physical-properties.temperature.average[Average temperature in Kelvin]:value:' '--atmosphere[Atmospheric composition]:value:' '--discovered-at[The discovered-at body parameter]:value:' '--image[The image body parameter]:value:' '--satellite[The satellite body parameter]:value:' '--creator[A user]:value:' '--creator.id[The creator.id body parameter]:value:' '--creator.name[The creator.name body parameter]:value:' '--tag[The tag body parameter]:value:' '--success-callback-url[URL which gets invoked upon a successful operation]:value:' '--failure-callback-url[URL which gets invoked upon a failed operation]:value:' '--planet-id[The ID of the planet to get]:value:' '--base-url[Override the base URL for API requests]:value:' '--timeout[Request timeout in milliseconds]:value:' '--max-retries[Number of retries for retryable failures]:value:' '--format[Output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--format-error[Error output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--transform[Dot-path transform for data output]:value:' '--transform-error[Dot-path transform for error output]:value:' '--raw-output[Print transformed string values without JSON quotes]' '--debug[Enable SDK debug logging]' '--environment[Named environment to target\\: production, void (default\\: production)]:value:(production void)' '--bearer-auth[JWT Bearer token authentication ($BEARER_AUTH)]:value:' '--basic-auth-username[Basic HTTP authentication ($BASIC_AUTH_USERNAME)]:value:' '--basic-auth-password[Basic HTTP authentication ($BASIC_AUTH_PASSWORD)]:value:' '--api-key-header[API key request header ($API_KEY_HEADER)]:value:' '--api-key-query[API key query parameter ($API_KEY_QUERY)]:value:' '--api-key-cookie[API key browser cookie ($API_KEY_COOKIE)]:value:' '--o-auth2[OAuth 2.0 authentication ($SCALAR_O_AUTH2)]:value:' '--open-id-connect[OpenID Connect Authentication ($SCALAR_OPEN_ID_CONNECT)]:value:' '--help[Show help for this command]')\n ;;\n 'planets delete')\n options=('--planet-id[The ID of the planet to get]:value:' '--base-url[Override the base URL for API requests]:value:' '--timeout[Request timeout in milliseconds]:value:' '--max-retries[Number of retries for retryable failures]:value:' '--format[Output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--format-error[Error output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--transform[Dot-path transform for data output]:value:' '--transform-error[Dot-path transform for error output]:value:' '--raw-output[Print transformed string values without JSON quotes]' '--debug[Enable SDK debug logging]' '--environment[Named environment to target\\: production, void (default\\: production)]:value:(production void)' '--bearer-auth[JWT Bearer token authentication ($BEARER_AUTH)]:value:' '--basic-auth-username[Basic HTTP authentication ($BASIC_AUTH_USERNAME)]:value:' '--basic-auth-password[Basic HTTP authentication ($BASIC_AUTH_PASSWORD)]:value:' '--api-key-header[API key request header ($API_KEY_HEADER)]:value:' '--api-key-query[API key query parameter ($API_KEY_QUERY)]:value:' '--api-key-cookie[API key browser cookie ($API_KEY_COOKIE)]:value:' '--o-auth2[OAuth 2.0 authentication ($SCALAR_O_AUTH2)]:value:' '--open-id-connect[OpenID Connect Authentication ($SCALAR_OPEN_ID_CONNECT)]:value:' '--help[Show help for this command]')\n ;;\n 'planets delte-image')\n options=('--image[The image file to upload]:path:_files' '--planet-id[The ID of the planet to get]:value:' '--base-url[Override the base URL for API requests]:value:' '--timeout[Request timeout in milliseconds]:value:' '--max-retries[Number of retries for retryable failures]:value:' '--format[Output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--format-error[Error output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--transform[Dot-path transform for data output]:value:' '--transform-error[Dot-path transform for error output]:value:' '--raw-output[Print transformed string values without JSON quotes]' '--debug[Enable SDK debug logging]' '--environment[Named environment to target\\: production, void (default\\: production)]:value:(production void)' '--bearer-auth[JWT Bearer token authentication ($BEARER_AUTH)]:value:' '--basic-auth-username[Basic HTTP authentication ($BASIC_AUTH_USERNAME)]:value:' '--basic-auth-password[Basic HTTP authentication ($BASIC_AUTH_PASSWORD)]:value:' '--api-key-header[API key request header ($API_KEY_HEADER)]:value:' '--api-key-query[API key query parameter ($API_KEY_QUERY)]:value:' '--api-key-cookie[API key browser cookie ($API_KEY_COOKIE)]:value:' '--o-auth2[OAuth 2.0 authentication ($SCALAR_O_AUTH2)]:value:' '--open-id-connect[OpenID Connect Authentication ($SCALAR_OPEN_ID_CONNECT)]:value:' '--help[Show help for this command]')\n ;;\n 'celestial-bodies')\n subcommands=('create:Create a celestial body')\n options=('--help[Show help for this command]')\n ;;\n 'celestial-bodies create')\n options=('--base-url[Override the base URL for API requests]:value:' '--timeout[Request timeout in milliseconds]:value:' '--max-retries[Number of retries for retryable failures]:value:' '--format[Output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--format-error[Error output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--transform[Dot-path transform for data output]:value:' '--transform-error[Dot-path transform for error output]:value:' '--raw-output[Print transformed string values without JSON quotes]' '--debug[Enable SDK debug logging]' '--environment[Named environment to target\\: production, void (default\\: production)]:value:(production void)' '--bearer-auth[JWT Bearer token authentication ($BEARER_AUTH)]:value:' '--basic-auth-username[Basic HTTP authentication ($BASIC_AUTH_USERNAME)]:value:' '--basic-auth-password[Basic HTTP authentication ($BASIC_AUTH_PASSWORD)]:value:' '--api-key-header[API key request header ($API_KEY_HEADER)]:value:' '--api-key-query[API key query parameter ($API_KEY_QUERY)]:value:' '--api-key-cookie[API key browser cookie ($API_KEY_COOKIE)]:value:' '--o-auth2[OAuth 2.0 authentication ($SCALAR_O_AUTH2)]:value:' '--open-id-connect[OpenID Connect Authentication ($SCALAR_OPEN_ID_CONNECT)]:value:' '--help[Show help for this command]')\n ;;\n 'authentication')\n subcommands=('create-user:Create a user' 'create-token:Get a token' 'list-me:Get authenticated user')\n options=('--help[Show help for this command]')\n ;;\n 'authentication create-user')\n options=('--name[The name body parameter]:value:' '--email[The email body parameter]:value:' '--password[The password body parameter]:value:' '--base-url[Override the base URL for API requests]:value:' '--timeout[Request timeout in milliseconds]:value:' '--max-retries[Number of retries for retryable failures]:value:' '--format[Output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--format-error[Error output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--transform[Dot-path transform for data output]:value:' '--transform-error[Dot-path transform for error output]:value:' '--raw-output[Print transformed string values without JSON quotes]' '--debug[Enable SDK debug logging]' '--environment[Named environment to target\\: production, void (default\\: production)]:value:(production void)' '--bearer-auth[JWT Bearer token authentication ($BEARER_AUTH)]:value:' '--basic-auth-username[Basic HTTP authentication ($BASIC_AUTH_USERNAME)]:value:' '--basic-auth-password[Basic HTTP authentication ($BASIC_AUTH_PASSWORD)]:value:' '--api-key-header[API key request header ($API_KEY_HEADER)]:value:' '--api-key-query[API key query parameter ($API_KEY_QUERY)]:value:' '--api-key-cookie[API key browser cookie ($API_KEY_COOKIE)]:value:' '--o-auth2[OAuth 2.0 authentication ($SCALAR_O_AUTH2)]:value:' '--open-id-connect[OpenID Connect Authentication ($SCALAR_OPEN_ID_CONNECT)]:value:' '--help[Show help for this command]')\n ;;\n 'authentication create-token')\n options=('--email[The email body parameter]:value:' '--password[The password body parameter]:value:' '--base-url[Override the base URL for API requests]:value:' '--timeout[Request timeout in milliseconds]:value:' '--max-retries[Number of retries for retryable failures]:value:' '--format[Output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--format-error[Error output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--transform[Dot-path transform for data output]:value:' '--transform-error[Dot-path transform for error output]:value:' '--raw-output[Print transformed string values without JSON quotes]' '--debug[Enable SDK debug logging]' '--environment[Named environment to target\\: production, void (default\\: production)]:value:(production void)' '--bearer-auth[JWT Bearer token authentication ($BEARER_AUTH)]:value:' '--basic-auth-username[Basic HTTP authentication ($BASIC_AUTH_USERNAME)]:value:' '--basic-auth-password[Basic HTTP authentication ($BASIC_AUTH_PASSWORD)]:value:' '--api-key-header[API key request header ($API_KEY_HEADER)]:value:' '--api-key-query[API key query parameter ($API_KEY_QUERY)]:value:' '--api-key-cookie[API key browser cookie ($API_KEY_COOKIE)]:value:' '--o-auth2[OAuth 2.0 authentication ($SCALAR_O_AUTH2)]:value:' '--open-id-connect[OpenID Connect Authentication ($SCALAR_OPEN_ID_CONNECT)]:value:' '--help[Show help for this command]')\n ;;\n 'authentication list-me')\n options=('--base-url[Override the base URL for API requests]:value:' '--timeout[Request timeout in milliseconds]:value:' '--max-retries[Number of retries for retryable failures]:value:' '--format[Output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--format-error[Error output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--transform[Dot-path transform for data output]:value:' '--transform-error[Dot-path transform for error output]:value:' '--raw-output[Print transformed string values without JSON quotes]' '--debug[Enable SDK debug logging]' '--environment[Named environment to target\\: production, void (default\\: production)]:value:(production void)' '--bearer-auth[JWT Bearer token authentication ($BEARER_AUTH)]:value:' '--basic-auth-username[Basic HTTP authentication ($BASIC_AUTH_USERNAME)]:value:' '--basic-auth-password[Basic HTTP authentication ($BASIC_AUTH_PASSWORD)]:value:' '--api-key-header[API key request header ($API_KEY_HEADER)]:value:' '--api-key-query[API key query parameter ($API_KEY_QUERY)]:value:' '--api-key-cookie[API key browser cookie ($API_KEY_COOKIE)]:value:' '--o-auth2[OAuth 2.0 authentication ($SCALAR_O_AUTH2)]:value:' '--open-id-connect[OpenID Connect Authentication ($SCALAR_OPEN_ID_CONNECT)]:value:' '--help[Show help for this command]')\n ;;\n 'login')\n options=('--base-url[Sign in against this base URL instead of the default]:value:' '--flow[Sign-in flow to use\\: bearer-auth, basic, api-key-header, api-key-query, api-key-cookie, oauth-client-credentials, oaut...]:value:' '--environment[Named environment to target\\: production, void (default\\: production)]:value:(production void)' '--help[Show help for this command]')\n ;;\n 'logout')\n options=('--base-url[Forget the credentials saved for this base URL (not with --all)]:value:' '--all[Forget every saved credential, for every base URL]' '--environment[Named environment to target\\: production, void (default\\: production)]:value:(production void)' '--help[Show help for this command]')\n ;;\n esac\n\n words=( \"$words[1]\" \"${(@)words[matched+1,-1]}\" )\n (( CURRENT -= matched - 1 ))\n\n (( ${#options} )) && _arguments -s -S $options\n (( ${#subcommands} )) && _describe -t commands 'command' subcommands\n}\n\nif [[ ${zsh_eval_context[-1]} == loadautofunc ]]; then\n __galaxy_completion \"$@\"\nelse\n compdef __galaxy_completion 'galaxy'\nfi\n"; const fish = - "# File generated from our OpenAPI spec by Scalar. Do not edit.\n\nset -g __galaxy_paths 'completion' 'planets' 'planets list-all-data' 'planets create' 'planets retrieve' 'planets update' 'planets delete' 'planets delte-image' 'celestial-bodies' 'celestial-bodies create' 'authentication' 'authentication create-user' 'authentication create-token' 'authentication list-me'\n\nfunction __galaxy_path\n set -l path ''\n set -l tokens (commandline -opc)\n for token in $tokens[2..-1]\n if string match -q -- '-*' $token\n continue\n end\n set -l candidate\n if test -z \"$path\"\n set candidate $token\n else\n set candidate \"$path $token\"\n end\n if contains -- $candidate $__galaxy_paths\n set path $candidate\n end\n end\n echo $path\nend\n\nfunction __galaxy_at --argument-names index\n set -l current (__galaxy_path)\n if test \"$index\" -eq 0\n test -z \"$current\"\n else\n test \"$current\" = \"$__galaxy_paths[$index]\"\n end\nend\n\ncomplete -c 'galaxy' -f\ncomplete -c 'galaxy' -n '__galaxy_at 0' -a 'planets' -d 'planets commands'\ncomplete -c 'galaxy' -n '__galaxy_at 0' -a 'celestial-bodies' -d 'celestial-bodies commands'\ncomplete -c 'galaxy' -n '__galaxy_at 0' -a 'authentication' -d 'authentication commands'\ncomplete -c 'galaxy' -n '__galaxy_at 0' -a 'completion' -d 'Print a shell completion script'\ncomplete -c 'galaxy' -n '__galaxy_at 0' -a 'help' -d 'Show help for a command'\ncomplete -c 'galaxy' -n '__galaxy_at 0' -l 'base-url' -d 'Override the base URL for API requests' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 0' -l 'timeout' -d 'Request timeout in milliseconds' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 0' -l 'max-retries' -d 'Number of retries for retryable failures' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 0' -l 'format' -d 'Output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 0' -l 'format-error' -d 'Error output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 0' -l 'transform' -d 'Dot-path transform for data output' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 0' -l 'transform-error' -d 'Dot-path transform for error output' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 0' -l 'raw-output' -d 'Print transformed string values without JSON quotes'\ncomplete -c 'galaxy' -n '__galaxy_at 0' -l 'debug' -d 'Enable SDK debug logging'\ncomplete -c 'galaxy' -n '__galaxy_at 0' -l 'bearer-auth' -d 'JWT Bearer token authentication ($BEARER_AUTH)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 0' -l 'basic-auth-username' -d 'Basic HTTP authentication ($BASIC_AUTH_USERNAME)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 0' -l 'basic-auth-password' -d 'Basic HTTP authentication ($BASIC_AUTH_PASSWORD)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 0' -l 'api-key-header' -d 'API key request header ($API_KEY_HEADER)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 0' -l 'api-key-query' -d 'API key query parameter ($API_KEY_QUERY)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 0' -l 'api-key-cookie' -d 'API key browser cookie ($API_KEY_COOKIE)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 0' -l 'o-auth2' -d 'OAuth 2.0 authentication ($SCALAR_O_AUTH2)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 0' -l 'open-id-connect' -d 'OpenID Connect Authentication ($SCALAR_OPEN_ID_CONNECT)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 0' -l 'version' -d 'Print the CLI version'\ncomplete -c 'galaxy' -n '__galaxy_at 0' -l 'help' -d 'Show help for this command'\ncomplete -c 'galaxy' -n '__galaxy_at 1' -a 'bash' -d 'Print a bash completion script'\ncomplete -c 'galaxy' -n '__galaxy_at 1' -a 'zsh' -d 'Print a zsh completion script'\ncomplete -c 'galaxy' -n '__galaxy_at 1' -a 'fish' -d 'Print a fish completion script'\ncomplete -c 'galaxy' -n '__galaxy_at 1' -l 'help' -d 'Show help for this command'\ncomplete -c 'galaxy' -n '__galaxy_at 2' -a 'list-all-data' -d 'Get all planets'\ncomplete -c 'galaxy' -n '__galaxy_at 2' -a 'create' -d 'Create a planet'\ncomplete -c 'galaxy' -n '__galaxy_at 2' -a 'retrieve' -d 'Get a planet'\ncomplete -c 'galaxy' -n '__galaxy_at 2' -a 'update' -d 'Update a planet'\ncomplete -c 'galaxy' -n '__galaxy_at 2' -a 'delete' -d 'Delete a planet'\ncomplete -c 'galaxy' -n '__galaxy_at 2' -a 'delte-image' -d 'Upload an image to a planet'\ncomplete -c 'galaxy' -n '__galaxy_at 2' -l 'help' -d 'Show help for this command'\ncomplete -c 'galaxy' -n '__galaxy_at 3' -l 'limit' -d 'The number of items to return' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 3' -l 'offset' -d 'The number of items to skip before starting to collect the result set' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 3' -l 'base-url' -d 'Override the base URL for API requests' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 3' -l 'timeout' -d 'Request timeout in milliseconds' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 3' -l 'max-retries' -d 'Number of retries for retryable failures' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 3' -l 'format' -d 'Output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 3' -l 'format-error' -d 'Error output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 3' -l 'transform' -d 'Dot-path transform for data output' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 3' -l 'transform-error' -d 'Dot-path transform for error output' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 3' -l 'raw-output' -d 'Print transformed string values without JSON quotes'\ncomplete -c 'galaxy' -n '__galaxy_at 3' -l 'debug' -d 'Enable SDK debug logging'\ncomplete -c 'galaxy' -n '__galaxy_at 3' -l 'bearer-auth' -d 'JWT Bearer token authentication ($BEARER_AUTH)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 3' -l 'basic-auth-username' -d 'Basic HTTP authentication ($BASIC_AUTH_USERNAME)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 3' -l 'basic-auth-password' -d 'Basic HTTP authentication ($BASIC_AUTH_PASSWORD)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 3' -l 'api-key-header' -d 'API key request header ($API_KEY_HEADER)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 3' -l 'api-key-query' -d 'API key query parameter ($API_KEY_QUERY)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 3' -l 'api-key-cookie' -d 'API key browser cookie ($API_KEY_COOKIE)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 3' -l 'o-auth2' -d 'OAuth 2.0 authentication ($SCALAR_O_AUTH2)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 3' -l 'open-id-connect' -d 'OpenID Connect Authentication ($SCALAR_OPEN_ID_CONNECT)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 3' -l 'help' -d 'Show help for this command'\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'name' -d 'The name body parameter' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'description' -d 'The description body parameter' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'type' -d 'The type body parameter' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'habitability-index' -d 'A score from 0 to 1 indicating potential habitability' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'physical-properties' -d 'The physical-properties body parameter' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'physical-properties.mass' -d 'Mass in Earth masses (must be greater than 0)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'physical-properties.radius' -d 'Radius in Earth radii (must be greater than 0)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'physical-properties.gravity' -d 'Surface gravity in Earth g' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'physical-properties.temperature' -d 'The physical-properties.temperature body parameter' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'physical-properties.temperature.min' -d 'Minimum temperature in Kelvin' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'physical-properties.temperature.max' -d 'Maximum temperature in Kelvin' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'physical-properties.temperature.average' -d 'Average temperature in Kelvin' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'atmosphere' -d 'Atmospheric composition' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'discovered-at' -d 'The discovered-at body parameter' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'image' -d 'The image body parameter' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'satellite' -d 'The satellite body parameter' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'creator' -d 'A user' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'creator.id' -d 'The creator.id body parameter' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'creator.name' -d 'The creator.name body parameter' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'tag' -d 'The tag body parameter' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'success-callback-url' -d 'URL which gets invoked upon a successful operation' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'failure-callback-url' -d 'URL which gets invoked upon a failed operation' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'base-url' -d 'Override the base URL for API requests' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'timeout' -d 'Request timeout in milliseconds' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'max-retries' -d 'Number of retries for retryable failures' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'format' -d 'Output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'format-error' -d 'Error output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'transform' -d 'Dot-path transform for data output' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'transform-error' -d 'Dot-path transform for error output' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'raw-output' -d 'Print transformed string values without JSON quotes'\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'debug' -d 'Enable SDK debug logging'\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'bearer-auth' -d 'JWT Bearer token authentication ($BEARER_AUTH)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'basic-auth-username' -d 'Basic HTTP authentication ($BASIC_AUTH_USERNAME)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'basic-auth-password' -d 'Basic HTTP authentication ($BASIC_AUTH_PASSWORD)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'api-key-header' -d 'API key request header ($API_KEY_HEADER)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'api-key-query' -d 'API key query parameter ($API_KEY_QUERY)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'api-key-cookie' -d 'API key browser cookie ($API_KEY_COOKIE)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'o-auth2' -d 'OAuth 2.0 authentication ($SCALAR_O_AUTH2)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'open-id-connect' -d 'OpenID Connect Authentication ($SCALAR_OPEN_ID_CONNECT)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'help' -d 'Show help for this command'\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'planet-id' -d 'The ID of the planet to get' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'base-url' -d 'Override the base URL for API requests' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'timeout' -d 'Request timeout in milliseconds' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'max-retries' -d 'Number of retries for retryable failures' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'format' -d 'Output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'format-error' -d 'Error output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'transform' -d 'Dot-path transform for data output' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'transform-error' -d 'Dot-path transform for error output' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'raw-output' -d 'Print transformed string values without JSON quotes'\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'debug' -d 'Enable SDK debug logging'\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'bearer-auth' -d 'JWT Bearer token authentication ($BEARER_AUTH)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'basic-auth-username' -d 'Basic HTTP authentication ($BASIC_AUTH_USERNAME)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'basic-auth-password' -d 'Basic HTTP authentication ($BASIC_AUTH_PASSWORD)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'api-key-header' -d 'API key request header ($API_KEY_HEADER)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'api-key-query' -d 'API key query parameter ($API_KEY_QUERY)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'api-key-cookie' -d 'API key browser cookie ($API_KEY_COOKIE)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'o-auth2' -d 'OAuth 2.0 authentication ($SCALAR_O_AUTH2)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'open-id-connect' -d 'OpenID Connect Authentication ($SCALAR_OPEN_ID_CONNECT)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'help' -d 'Show help for this command'\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'name' -d 'The name body parameter' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'description' -d 'The description body parameter' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'type' -d 'The type body parameter' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'habitability-index' -d 'A score from 0 to 1 indicating potential habitability' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'physical-properties' -d 'The physical-properties body parameter' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'physical-properties.mass' -d 'Mass in Earth masses (must be greater than 0)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'physical-properties.radius' -d 'Radius in Earth radii (must be greater than 0)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'physical-properties.gravity' -d 'Surface gravity in Earth g' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'physical-properties.temperature' -d 'The physical-properties.temperature body parameter' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'physical-properties.temperature.min' -d 'Minimum temperature in Kelvin' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'physical-properties.temperature.max' -d 'Maximum temperature in Kelvin' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'physical-properties.temperature.average' -d 'Average temperature in Kelvin' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'atmosphere' -d 'Atmospheric composition' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'discovered-at' -d 'The discovered-at body parameter' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'image' -d 'The image body parameter' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'satellite' -d 'The satellite body parameter' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'creator' -d 'A user' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'creator.id' -d 'The creator.id body parameter' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'creator.name' -d 'The creator.name body parameter' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'tag' -d 'The tag body parameter' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'success-callback-url' -d 'URL which gets invoked upon a successful operation' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'failure-callback-url' -d 'URL which gets invoked upon a failed operation' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'planet-id' -d 'The ID of the planet to get' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'base-url' -d 'Override the base URL for API requests' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'timeout' -d 'Request timeout in milliseconds' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'max-retries' -d 'Number of retries for retryable failures' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'format' -d 'Output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'format-error' -d 'Error output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'transform' -d 'Dot-path transform for data output' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'transform-error' -d 'Dot-path transform for error output' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'raw-output' -d 'Print transformed string values without JSON quotes'\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'debug' -d 'Enable SDK debug logging'\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'bearer-auth' -d 'JWT Bearer token authentication ($BEARER_AUTH)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'basic-auth-username' -d 'Basic HTTP authentication ($BASIC_AUTH_USERNAME)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'basic-auth-password' -d 'Basic HTTP authentication ($BASIC_AUTH_PASSWORD)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'api-key-header' -d 'API key request header ($API_KEY_HEADER)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'api-key-query' -d 'API key query parameter ($API_KEY_QUERY)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'api-key-cookie' -d 'API key browser cookie ($API_KEY_COOKIE)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'o-auth2' -d 'OAuth 2.0 authentication ($SCALAR_O_AUTH2)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'open-id-connect' -d 'OpenID Connect Authentication ($SCALAR_OPEN_ID_CONNECT)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'help' -d 'Show help for this command'\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'planet-id' -d 'The ID of the planet to get' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'base-url' -d 'Override the base URL for API requests' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'timeout' -d 'Request timeout in milliseconds' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'max-retries' -d 'Number of retries for retryable failures' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'format' -d 'Output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'format-error' -d 'Error output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'transform' -d 'Dot-path transform for data output' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'transform-error' -d 'Dot-path transform for error output' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'raw-output' -d 'Print transformed string values without JSON quotes'\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'debug' -d 'Enable SDK debug logging'\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'bearer-auth' -d 'JWT Bearer token authentication ($BEARER_AUTH)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'basic-auth-username' -d 'Basic HTTP authentication ($BASIC_AUTH_USERNAME)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'basic-auth-password' -d 'Basic HTTP authentication ($BASIC_AUTH_PASSWORD)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'api-key-header' -d 'API key request header ($API_KEY_HEADER)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'api-key-query' -d 'API key query parameter ($API_KEY_QUERY)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'api-key-cookie' -d 'API key browser cookie ($API_KEY_COOKIE)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'o-auth2' -d 'OAuth 2.0 authentication ($SCALAR_O_AUTH2)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'open-id-connect' -d 'OpenID Connect Authentication ($SCALAR_OPEN_ID_CONNECT)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'help' -d 'Show help for this command'\ncomplete -c 'galaxy' -n '__galaxy_at 8' -l 'image' -d 'The image file to upload' -r -F\ncomplete -c 'galaxy' -n '__galaxy_at 8' -l 'planet-id' -d 'The ID of the planet to get' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 8' -l 'base-url' -d 'Override the base URL for API requests' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 8' -l 'timeout' -d 'Request timeout in milliseconds' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 8' -l 'max-retries' -d 'Number of retries for retryable failures' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 8' -l 'format' -d 'Output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 8' -l 'format-error' -d 'Error output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 8' -l 'transform' -d 'Dot-path transform for data output' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 8' -l 'transform-error' -d 'Dot-path transform for error output' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 8' -l 'raw-output' -d 'Print transformed string values without JSON quotes'\ncomplete -c 'galaxy' -n '__galaxy_at 8' -l 'debug' -d 'Enable SDK debug logging'\ncomplete -c 'galaxy' -n '__galaxy_at 8' -l 'bearer-auth' -d 'JWT Bearer token authentication ($BEARER_AUTH)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 8' -l 'basic-auth-username' -d 'Basic HTTP authentication ($BASIC_AUTH_USERNAME)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 8' -l 'basic-auth-password' -d 'Basic HTTP authentication ($BASIC_AUTH_PASSWORD)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 8' -l 'api-key-header' -d 'API key request header ($API_KEY_HEADER)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 8' -l 'api-key-query' -d 'API key query parameter ($API_KEY_QUERY)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 8' -l 'api-key-cookie' -d 'API key browser cookie ($API_KEY_COOKIE)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 8' -l 'o-auth2' -d 'OAuth 2.0 authentication ($SCALAR_O_AUTH2)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 8' -l 'open-id-connect' -d 'OpenID Connect Authentication ($SCALAR_OPEN_ID_CONNECT)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 8' -l 'help' -d 'Show help for this command'\ncomplete -c 'galaxy' -n '__galaxy_at 9' -a 'create' -d 'Create a celestial body'\ncomplete -c 'galaxy' -n '__galaxy_at 9' -l 'help' -d 'Show help for this command'\ncomplete -c 'galaxy' -n '__galaxy_at 10' -l 'base-url' -d 'Override the base URL for API requests' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 10' -l 'timeout' -d 'Request timeout in milliseconds' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 10' -l 'max-retries' -d 'Number of retries for retryable failures' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 10' -l 'format' -d 'Output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 10' -l 'format-error' -d 'Error output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 10' -l 'transform' -d 'Dot-path transform for data output' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 10' -l 'transform-error' -d 'Dot-path transform for error output' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 10' -l 'raw-output' -d 'Print transformed string values without JSON quotes'\ncomplete -c 'galaxy' -n '__galaxy_at 10' -l 'debug' -d 'Enable SDK debug logging'\ncomplete -c 'galaxy' -n '__galaxy_at 10' -l 'bearer-auth' -d 'JWT Bearer token authentication ($BEARER_AUTH)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 10' -l 'basic-auth-username' -d 'Basic HTTP authentication ($BASIC_AUTH_USERNAME)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 10' -l 'basic-auth-password' -d 'Basic HTTP authentication ($BASIC_AUTH_PASSWORD)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 10' -l 'api-key-header' -d 'API key request header ($API_KEY_HEADER)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 10' -l 'api-key-query' -d 'API key query parameter ($API_KEY_QUERY)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 10' -l 'api-key-cookie' -d 'API key browser cookie ($API_KEY_COOKIE)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 10' -l 'o-auth2' -d 'OAuth 2.0 authentication ($SCALAR_O_AUTH2)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 10' -l 'open-id-connect' -d 'OpenID Connect Authentication ($SCALAR_OPEN_ID_CONNECT)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 10' -l 'help' -d 'Show help for this command'\ncomplete -c 'galaxy' -n '__galaxy_at 11' -a 'create-user' -d 'Create a user'\ncomplete -c 'galaxy' -n '__galaxy_at 11' -a 'create-token' -d 'Get a token'\ncomplete -c 'galaxy' -n '__galaxy_at 11' -a 'list-me' -d 'Get authenticated user'\ncomplete -c 'galaxy' -n '__galaxy_at 11' -l 'help' -d 'Show help for this command'\ncomplete -c 'galaxy' -n '__galaxy_at 12' -l 'name' -d 'The name body parameter' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 12' -l 'email' -d 'The email body parameter' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 12' -l 'password' -d 'The password body parameter' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 12' -l 'base-url' -d 'Override the base URL for API requests' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 12' -l 'timeout' -d 'Request timeout in milliseconds' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 12' -l 'max-retries' -d 'Number of retries for retryable failures' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 12' -l 'format' -d 'Output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 12' -l 'format-error' -d 'Error output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 12' -l 'transform' -d 'Dot-path transform for data output' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 12' -l 'transform-error' -d 'Dot-path transform for error output' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 12' -l 'raw-output' -d 'Print transformed string values without JSON quotes'\ncomplete -c 'galaxy' -n '__galaxy_at 12' -l 'debug' -d 'Enable SDK debug logging'\ncomplete -c 'galaxy' -n '__galaxy_at 12' -l 'bearer-auth' -d 'JWT Bearer token authentication ($BEARER_AUTH)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 12' -l 'basic-auth-username' -d 'Basic HTTP authentication ($BASIC_AUTH_USERNAME)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 12' -l 'basic-auth-password' -d 'Basic HTTP authentication ($BASIC_AUTH_PASSWORD)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 12' -l 'api-key-header' -d 'API key request header ($API_KEY_HEADER)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 12' -l 'api-key-query' -d 'API key query parameter ($API_KEY_QUERY)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 12' -l 'api-key-cookie' -d 'API key browser cookie ($API_KEY_COOKIE)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 12' -l 'o-auth2' -d 'OAuth 2.0 authentication ($SCALAR_O_AUTH2)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 12' -l 'open-id-connect' -d 'OpenID Connect Authentication ($SCALAR_OPEN_ID_CONNECT)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 12' -l 'help' -d 'Show help for this command'\ncomplete -c 'galaxy' -n '__galaxy_at 13' -l 'email' -d 'The email body parameter' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 13' -l 'password' -d 'The password body parameter' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 13' -l 'base-url' -d 'Override the base URL for API requests' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 13' -l 'timeout' -d 'Request timeout in milliseconds' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 13' -l 'max-retries' -d 'Number of retries for retryable failures' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 13' -l 'format' -d 'Output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 13' -l 'format-error' -d 'Error output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 13' -l 'transform' -d 'Dot-path transform for data output' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 13' -l 'transform-error' -d 'Dot-path transform for error output' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 13' -l 'raw-output' -d 'Print transformed string values without JSON quotes'\ncomplete -c 'galaxy' -n '__galaxy_at 13' -l 'debug' -d 'Enable SDK debug logging'\ncomplete -c 'galaxy' -n '__galaxy_at 13' -l 'bearer-auth' -d 'JWT Bearer token authentication ($BEARER_AUTH)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 13' -l 'basic-auth-username' -d 'Basic HTTP authentication ($BASIC_AUTH_USERNAME)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 13' -l 'basic-auth-password' -d 'Basic HTTP authentication ($BASIC_AUTH_PASSWORD)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 13' -l 'api-key-header' -d 'API key request header ($API_KEY_HEADER)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 13' -l 'api-key-query' -d 'API key query parameter ($API_KEY_QUERY)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 13' -l 'api-key-cookie' -d 'API key browser cookie ($API_KEY_COOKIE)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 13' -l 'o-auth2' -d 'OAuth 2.0 authentication ($SCALAR_O_AUTH2)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 13' -l 'open-id-connect' -d 'OpenID Connect Authentication ($SCALAR_OPEN_ID_CONNECT)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 13' -l 'help' -d 'Show help for this command'\ncomplete -c 'galaxy' -n '__galaxy_at 14' -l 'base-url' -d 'Override the base URL for API requests' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 14' -l 'timeout' -d 'Request timeout in milliseconds' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 14' -l 'max-retries' -d 'Number of retries for retryable failures' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 14' -l 'format' -d 'Output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 14' -l 'format-error' -d 'Error output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 14' -l 'transform' -d 'Dot-path transform for data output' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 14' -l 'transform-error' -d 'Dot-path transform for error output' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 14' -l 'raw-output' -d 'Print transformed string values without JSON quotes'\ncomplete -c 'galaxy' -n '__galaxy_at 14' -l 'debug' -d 'Enable SDK debug logging'\ncomplete -c 'galaxy' -n '__galaxy_at 14' -l 'bearer-auth' -d 'JWT Bearer token authentication ($BEARER_AUTH)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 14' -l 'basic-auth-username' -d 'Basic HTTP authentication ($BASIC_AUTH_USERNAME)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 14' -l 'basic-auth-password' -d 'Basic HTTP authentication ($BASIC_AUTH_PASSWORD)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 14' -l 'api-key-header' -d 'API key request header ($API_KEY_HEADER)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 14' -l 'api-key-query' -d 'API key query parameter ($API_KEY_QUERY)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 14' -l 'api-key-cookie' -d 'API key browser cookie ($API_KEY_COOKIE)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 14' -l 'o-auth2' -d 'OAuth 2.0 authentication ($SCALAR_O_AUTH2)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 14' -l 'open-id-connect' -d 'OpenID Connect Authentication ($SCALAR_OPEN_ID_CONNECT)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 14' -l 'help' -d 'Show help for this command'\n"; + "# File generated from our OpenAPI spec by Scalar. Do not edit.\n\nset -g __galaxy_paths 'completion' 'environments' 'planets' 'planets list-all-data' 'planets create' 'planets retrieve' 'planets update' 'planets delete' 'planets delte-image' 'celestial-bodies' 'celestial-bodies create' 'authentication' 'authentication create-user' 'authentication create-token' 'authentication list-me' 'login' 'logout'\n\nfunction __galaxy_path\n set -l path ''\n set -l tokens (commandline -opc)\n for token in $tokens[2..-1]\n if string match -q -- '-*' $token\n continue\n end\n set -l candidate\n if test -z \"$path\"\n set candidate $token\n else\n set candidate \"$path $token\"\n end\n if contains -- $candidate $__galaxy_paths\n set path $candidate\n end\n end\n echo $path\nend\n\nfunction __galaxy_at --argument-names index\n set -l current (__galaxy_path)\n if test \"$index\" -eq 0\n test -z \"$current\"\n else\n test \"$current\" = \"$__galaxy_paths[$index]\"\n end\nend\n\ncomplete -c 'galaxy' -f\ncomplete -c 'galaxy' -n '__galaxy_at 0' -a 'planets' -d 'planets commands'\ncomplete -c 'galaxy' -n '__galaxy_at 0' -a 'celestial-bodies' -d 'celestial-bodies commands'\ncomplete -c 'galaxy' -n '__galaxy_at 0' -a 'authentication' -d 'authentication commands'\ncomplete -c 'galaxy' -n '__galaxy_at 0' -a 'login' -d 'Sign in and save credentials for later commands'\ncomplete -c 'galaxy' -n '__galaxy_at 0' -a 'logout' -d 'Forget saved credentials'\ncomplete -c 'galaxy' -n '__galaxy_at 0' -a 'environments' -d 'List the named environments --environment can select'\ncomplete -c 'galaxy' -n '__galaxy_at 0' -a 'completion' -d 'Print a shell completion script'\ncomplete -c 'galaxy' -n '__galaxy_at 0' -a 'help' -d 'Show help for a command'\ncomplete -c 'galaxy' -n '__galaxy_at 0' -l 'base-url' -d 'Override the base URL for API requests' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 0' -l 'timeout' -d 'Request timeout in milliseconds' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 0' -l 'max-retries' -d 'Number of retries for retryable failures' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 0' -l 'format' -d 'Output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 0' -l 'format-error' -d 'Error output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 0' -l 'transform' -d 'Dot-path transform for data output' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 0' -l 'transform-error' -d 'Dot-path transform for error output' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 0' -l 'raw-output' -d 'Print transformed string values without JSON quotes'\ncomplete -c 'galaxy' -n '__galaxy_at 0' -l 'debug' -d 'Enable SDK debug logging'\ncomplete -c 'galaxy' -n '__galaxy_at 0' -l 'environment' -d 'Named environment to target: production, void (default: production)' -r -f -a 'production void'\ncomplete -c 'galaxy' -n '__galaxy_at 0' -l 'bearer-auth' -d 'JWT Bearer token authentication ($BEARER_AUTH)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 0' -l 'basic-auth-username' -d 'Basic HTTP authentication ($BASIC_AUTH_USERNAME)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 0' -l 'basic-auth-password' -d 'Basic HTTP authentication ($BASIC_AUTH_PASSWORD)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 0' -l 'api-key-header' -d 'API key request header ($API_KEY_HEADER)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 0' -l 'api-key-query' -d 'API key query parameter ($API_KEY_QUERY)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 0' -l 'api-key-cookie' -d 'API key browser cookie ($API_KEY_COOKIE)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 0' -l 'o-auth2' -d 'OAuth 2.0 authentication ($SCALAR_O_AUTH2)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 0' -l 'open-id-connect' -d 'OpenID Connect Authentication ($SCALAR_OPEN_ID_CONNECT)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 0' -l 'version' -d 'Print the CLI version'\ncomplete -c 'galaxy' -n '__galaxy_at 0' -l 'help' -d 'Show help for this command'\ncomplete -c 'galaxy' -n '__galaxy_at 1' -a 'bash' -d 'Print a bash completion script'\ncomplete -c 'galaxy' -n '__galaxy_at 1' -a 'zsh' -d 'Print a zsh completion script'\ncomplete -c 'galaxy' -n '__galaxy_at 1' -a 'fish' -d 'Print a fish completion script'\ncomplete -c 'galaxy' -n '__galaxy_at 1' -l 'help' -d 'Show help for this command'\ncomplete -c 'galaxy' -n '__galaxy_at 2' -l 'format' -d 'Output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 2' -l 'help' -d 'Show help for this command'\ncomplete -c 'galaxy' -n '__galaxy_at 3' -a 'list-all-data' -d 'Get all planets'\ncomplete -c 'galaxy' -n '__galaxy_at 3' -a 'create' -d 'Create a planet'\ncomplete -c 'galaxy' -n '__galaxy_at 3' -a 'retrieve' -d 'Get a planet'\ncomplete -c 'galaxy' -n '__galaxy_at 3' -a 'update' -d 'Update a planet'\ncomplete -c 'galaxy' -n '__galaxy_at 3' -a 'delete' -d 'Delete a planet'\ncomplete -c 'galaxy' -n '__galaxy_at 3' -a 'delte-image' -d 'Upload an image to a planet'\ncomplete -c 'galaxy' -n '__galaxy_at 3' -l 'help' -d 'Show help for this command'\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'limit' -d 'The number of items to return' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'offset' -d 'The number of items to skip before starting to collect the result set' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'base-url' -d 'Override the base URL for API requests' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'timeout' -d 'Request timeout in milliseconds' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'max-retries' -d 'Number of retries for retryable failures' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'format' -d 'Output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'format-error' -d 'Error output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'transform' -d 'Dot-path transform for data output' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'transform-error' -d 'Dot-path transform for error output' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'raw-output' -d 'Print transformed string values without JSON quotes'\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'debug' -d 'Enable SDK debug logging'\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'environment' -d 'Named environment to target: production, void (default: production)' -r -f -a 'production void'\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'bearer-auth' -d 'JWT Bearer token authentication ($BEARER_AUTH)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'basic-auth-username' -d 'Basic HTTP authentication ($BASIC_AUTH_USERNAME)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'basic-auth-password' -d 'Basic HTTP authentication ($BASIC_AUTH_PASSWORD)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'api-key-header' -d 'API key request header ($API_KEY_HEADER)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'api-key-query' -d 'API key query parameter ($API_KEY_QUERY)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'api-key-cookie' -d 'API key browser cookie ($API_KEY_COOKIE)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'o-auth2' -d 'OAuth 2.0 authentication ($SCALAR_O_AUTH2)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'open-id-connect' -d 'OpenID Connect Authentication ($SCALAR_OPEN_ID_CONNECT)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 4' -l 'help' -d 'Show help for this command'\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'name' -d 'The name body parameter' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'description' -d 'The description body parameter' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'type' -d 'The type body parameter' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'habitability-index' -d 'A score from 0 to 1 indicating potential habitability' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'physical-properties' -d 'The physical-properties body parameter' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'physical-properties.mass' -d 'Mass in Earth masses (must be greater than 0)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'physical-properties.radius' -d 'Radius in Earth radii (must be greater than 0)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'physical-properties.gravity' -d 'Surface gravity in Earth g' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'physical-properties.temperature' -d 'The physical-properties.temperature body parameter' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'physical-properties.temperature.min' -d 'Minimum temperature in Kelvin' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'physical-properties.temperature.max' -d 'Maximum temperature in Kelvin' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'physical-properties.temperature.average' -d 'Average temperature in Kelvin' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'atmosphere' -d 'Atmospheric composition' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'discovered-at' -d 'The discovered-at body parameter' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'image' -d 'The image body parameter' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'satellite' -d 'The satellite body parameter' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'creator' -d 'A user' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'creator.id' -d 'The creator.id body parameter' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'creator.name' -d 'The creator.name body parameter' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'tag' -d 'The tag body parameter' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'success-callback-url' -d 'URL which gets invoked upon a successful operation' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'failure-callback-url' -d 'URL which gets invoked upon a failed operation' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'base-url' -d 'Override the base URL for API requests' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'timeout' -d 'Request timeout in milliseconds' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'max-retries' -d 'Number of retries for retryable failures' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'format' -d 'Output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'format-error' -d 'Error output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'transform' -d 'Dot-path transform for data output' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'transform-error' -d 'Dot-path transform for error output' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'raw-output' -d 'Print transformed string values without JSON quotes'\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'debug' -d 'Enable SDK debug logging'\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'environment' -d 'Named environment to target: production, void (default: production)' -r -f -a 'production void'\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'bearer-auth' -d 'JWT Bearer token authentication ($BEARER_AUTH)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'basic-auth-username' -d 'Basic HTTP authentication ($BASIC_AUTH_USERNAME)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'basic-auth-password' -d 'Basic HTTP authentication ($BASIC_AUTH_PASSWORD)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'api-key-header' -d 'API key request header ($API_KEY_HEADER)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'api-key-query' -d 'API key query parameter ($API_KEY_QUERY)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'api-key-cookie' -d 'API key browser cookie ($API_KEY_COOKIE)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'o-auth2' -d 'OAuth 2.0 authentication ($SCALAR_O_AUTH2)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'open-id-connect' -d 'OpenID Connect Authentication ($SCALAR_OPEN_ID_CONNECT)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 5' -l 'help' -d 'Show help for this command'\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'planet-id' -d 'The ID of the planet to get' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'base-url' -d 'Override the base URL for API requests' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'timeout' -d 'Request timeout in milliseconds' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'max-retries' -d 'Number of retries for retryable failures' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'format' -d 'Output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'format-error' -d 'Error output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'transform' -d 'Dot-path transform for data output' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'transform-error' -d 'Dot-path transform for error output' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'raw-output' -d 'Print transformed string values without JSON quotes'\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'debug' -d 'Enable SDK debug logging'\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'environment' -d 'Named environment to target: production, void (default: production)' -r -f -a 'production void'\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'bearer-auth' -d 'JWT Bearer token authentication ($BEARER_AUTH)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'basic-auth-username' -d 'Basic HTTP authentication ($BASIC_AUTH_USERNAME)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'basic-auth-password' -d 'Basic HTTP authentication ($BASIC_AUTH_PASSWORD)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'api-key-header' -d 'API key request header ($API_KEY_HEADER)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'api-key-query' -d 'API key query parameter ($API_KEY_QUERY)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'api-key-cookie' -d 'API key browser cookie ($API_KEY_COOKIE)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'o-auth2' -d 'OAuth 2.0 authentication ($SCALAR_O_AUTH2)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'open-id-connect' -d 'OpenID Connect Authentication ($SCALAR_OPEN_ID_CONNECT)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 6' -l 'help' -d 'Show help for this command'\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'name' -d 'The name body parameter' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'description' -d 'The description body parameter' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'type' -d 'The type body parameter' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'habitability-index' -d 'A score from 0 to 1 indicating potential habitability' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'physical-properties' -d 'The physical-properties body parameter' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'physical-properties.mass' -d 'Mass in Earth masses (must be greater than 0)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'physical-properties.radius' -d 'Radius in Earth radii (must be greater than 0)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'physical-properties.gravity' -d 'Surface gravity in Earth g' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'physical-properties.temperature' -d 'The physical-properties.temperature body parameter' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'physical-properties.temperature.min' -d 'Minimum temperature in Kelvin' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'physical-properties.temperature.max' -d 'Maximum temperature in Kelvin' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'physical-properties.temperature.average' -d 'Average temperature in Kelvin' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'atmosphere' -d 'Atmospheric composition' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'discovered-at' -d 'The discovered-at body parameter' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'image' -d 'The image body parameter' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'satellite' -d 'The satellite body parameter' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'creator' -d 'A user' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'creator.id' -d 'The creator.id body parameter' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'creator.name' -d 'The creator.name body parameter' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'tag' -d 'The tag body parameter' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'success-callback-url' -d 'URL which gets invoked upon a successful operation' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'failure-callback-url' -d 'URL which gets invoked upon a failed operation' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'planet-id' -d 'The ID of the planet to get' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'base-url' -d 'Override the base URL for API requests' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'timeout' -d 'Request timeout in milliseconds' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'max-retries' -d 'Number of retries for retryable failures' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'format' -d 'Output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'format-error' -d 'Error output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'transform' -d 'Dot-path transform for data output' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'transform-error' -d 'Dot-path transform for error output' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'raw-output' -d 'Print transformed string values without JSON quotes'\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'debug' -d 'Enable SDK debug logging'\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'environment' -d 'Named environment to target: production, void (default: production)' -r -f -a 'production void'\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'bearer-auth' -d 'JWT Bearer token authentication ($BEARER_AUTH)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'basic-auth-username' -d 'Basic HTTP authentication ($BASIC_AUTH_USERNAME)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'basic-auth-password' -d 'Basic HTTP authentication ($BASIC_AUTH_PASSWORD)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'api-key-header' -d 'API key request header ($API_KEY_HEADER)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'api-key-query' -d 'API key query parameter ($API_KEY_QUERY)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'api-key-cookie' -d 'API key browser cookie ($API_KEY_COOKIE)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'o-auth2' -d 'OAuth 2.0 authentication ($SCALAR_O_AUTH2)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'open-id-connect' -d 'OpenID Connect Authentication ($SCALAR_OPEN_ID_CONNECT)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 7' -l 'help' -d 'Show help for this command'\ncomplete -c 'galaxy' -n '__galaxy_at 8' -l 'planet-id' -d 'The ID of the planet to get' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 8' -l 'base-url' -d 'Override the base URL for API requests' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 8' -l 'timeout' -d 'Request timeout in milliseconds' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 8' -l 'max-retries' -d 'Number of retries for retryable failures' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 8' -l 'format' -d 'Output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 8' -l 'format-error' -d 'Error output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 8' -l 'transform' -d 'Dot-path transform for data output' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 8' -l 'transform-error' -d 'Dot-path transform for error output' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 8' -l 'raw-output' -d 'Print transformed string values without JSON quotes'\ncomplete -c 'galaxy' -n '__galaxy_at 8' -l 'debug' -d 'Enable SDK debug logging'\ncomplete -c 'galaxy' -n '__galaxy_at 8' -l 'environment' -d 'Named environment to target: production, void (default: production)' -r -f -a 'production void'\ncomplete -c 'galaxy' -n '__galaxy_at 8' -l 'bearer-auth' -d 'JWT Bearer token authentication ($BEARER_AUTH)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 8' -l 'basic-auth-username' -d 'Basic HTTP authentication ($BASIC_AUTH_USERNAME)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 8' -l 'basic-auth-password' -d 'Basic HTTP authentication ($BASIC_AUTH_PASSWORD)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 8' -l 'api-key-header' -d 'API key request header ($API_KEY_HEADER)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 8' -l 'api-key-query' -d 'API key query parameter ($API_KEY_QUERY)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 8' -l 'api-key-cookie' -d 'API key browser cookie ($API_KEY_COOKIE)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 8' -l 'o-auth2' -d 'OAuth 2.0 authentication ($SCALAR_O_AUTH2)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 8' -l 'open-id-connect' -d 'OpenID Connect Authentication ($SCALAR_OPEN_ID_CONNECT)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 8' -l 'help' -d 'Show help for this command'\ncomplete -c 'galaxy' -n '__galaxy_at 9' -l 'image' -d 'The image file to upload' -r -F\ncomplete -c 'galaxy' -n '__galaxy_at 9' -l 'planet-id' -d 'The ID of the planet to get' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 9' -l 'base-url' -d 'Override the base URL for API requests' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 9' -l 'timeout' -d 'Request timeout in milliseconds' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 9' -l 'max-retries' -d 'Number of retries for retryable failures' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 9' -l 'format' -d 'Output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 9' -l 'format-error' -d 'Error output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 9' -l 'transform' -d 'Dot-path transform for data output' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 9' -l 'transform-error' -d 'Dot-path transform for error output' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 9' -l 'raw-output' -d 'Print transformed string values without JSON quotes'\ncomplete -c 'galaxy' -n '__galaxy_at 9' -l 'debug' -d 'Enable SDK debug logging'\ncomplete -c 'galaxy' -n '__galaxy_at 9' -l 'environment' -d 'Named environment to target: production, void (default: production)' -r -f -a 'production void'\ncomplete -c 'galaxy' -n '__galaxy_at 9' -l 'bearer-auth' -d 'JWT Bearer token authentication ($BEARER_AUTH)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 9' -l 'basic-auth-username' -d 'Basic HTTP authentication ($BASIC_AUTH_USERNAME)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 9' -l 'basic-auth-password' -d 'Basic HTTP authentication ($BASIC_AUTH_PASSWORD)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 9' -l 'api-key-header' -d 'API key request header ($API_KEY_HEADER)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 9' -l 'api-key-query' -d 'API key query parameter ($API_KEY_QUERY)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 9' -l 'api-key-cookie' -d 'API key browser cookie ($API_KEY_COOKIE)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 9' -l 'o-auth2' -d 'OAuth 2.0 authentication ($SCALAR_O_AUTH2)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 9' -l 'open-id-connect' -d 'OpenID Connect Authentication ($SCALAR_OPEN_ID_CONNECT)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 9' -l 'help' -d 'Show help for this command'\ncomplete -c 'galaxy' -n '__galaxy_at 10' -a 'create' -d 'Create a celestial body'\ncomplete -c 'galaxy' -n '__galaxy_at 10' -l 'help' -d 'Show help for this command'\ncomplete -c 'galaxy' -n '__galaxy_at 11' -l 'base-url' -d 'Override the base URL for API requests' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 11' -l 'timeout' -d 'Request timeout in milliseconds' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 11' -l 'max-retries' -d 'Number of retries for retryable failures' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 11' -l 'format' -d 'Output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 11' -l 'format-error' -d 'Error output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 11' -l 'transform' -d 'Dot-path transform for data output' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 11' -l 'transform-error' -d 'Dot-path transform for error output' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 11' -l 'raw-output' -d 'Print transformed string values without JSON quotes'\ncomplete -c 'galaxy' -n '__galaxy_at 11' -l 'debug' -d 'Enable SDK debug logging'\ncomplete -c 'galaxy' -n '__galaxy_at 11' -l 'environment' -d 'Named environment to target: production, void (default: production)' -r -f -a 'production void'\ncomplete -c 'galaxy' -n '__galaxy_at 11' -l 'bearer-auth' -d 'JWT Bearer token authentication ($BEARER_AUTH)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 11' -l 'basic-auth-username' -d 'Basic HTTP authentication ($BASIC_AUTH_USERNAME)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 11' -l 'basic-auth-password' -d 'Basic HTTP authentication ($BASIC_AUTH_PASSWORD)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 11' -l 'api-key-header' -d 'API key request header ($API_KEY_HEADER)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 11' -l 'api-key-query' -d 'API key query parameter ($API_KEY_QUERY)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 11' -l 'api-key-cookie' -d 'API key browser cookie ($API_KEY_COOKIE)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 11' -l 'o-auth2' -d 'OAuth 2.0 authentication ($SCALAR_O_AUTH2)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 11' -l 'open-id-connect' -d 'OpenID Connect Authentication ($SCALAR_OPEN_ID_CONNECT)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 11' -l 'help' -d 'Show help for this command'\ncomplete -c 'galaxy' -n '__galaxy_at 12' -a 'create-user' -d 'Create a user'\ncomplete -c 'galaxy' -n '__galaxy_at 12' -a 'create-token' -d 'Get a token'\ncomplete -c 'galaxy' -n '__galaxy_at 12' -a 'list-me' -d 'Get authenticated user'\ncomplete -c 'galaxy' -n '__galaxy_at 12' -l 'help' -d 'Show help for this command'\ncomplete -c 'galaxy' -n '__galaxy_at 13' -l 'name' -d 'The name body parameter' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 13' -l 'email' -d 'The email body parameter' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 13' -l 'password' -d 'The password body parameter' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 13' -l 'base-url' -d 'Override the base URL for API requests' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 13' -l 'timeout' -d 'Request timeout in milliseconds' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 13' -l 'max-retries' -d 'Number of retries for retryable failures' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 13' -l 'format' -d 'Output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 13' -l 'format-error' -d 'Error output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 13' -l 'transform' -d 'Dot-path transform for data output' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 13' -l 'transform-error' -d 'Dot-path transform for error output' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 13' -l 'raw-output' -d 'Print transformed string values without JSON quotes'\ncomplete -c 'galaxy' -n '__galaxy_at 13' -l 'debug' -d 'Enable SDK debug logging'\ncomplete -c 'galaxy' -n '__galaxy_at 13' -l 'environment' -d 'Named environment to target: production, void (default: production)' -r -f -a 'production void'\ncomplete -c 'galaxy' -n '__galaxy_at 13' -l 'bearer-auth' -d 'JWT Bearer token authentication ($BEARER_AUTH)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 13' -l 'basic-auth-username' -d 'Basic HTTP authentication ($BASIC_AUTH_USERNAME)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 13' -l 'basic-auth-password' -d 'Basic HTTP authentication ($BASIC_AUTH_PASSWORD)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 13' -l 'api-key-header' -d 'API key request header ($API_KEY_HEADER)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 13' -l 'api-key-query' -d 'API key query parameter ($API_KEY_QUERY)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 13' -l 'api-key-cookie' -d 'API key browser cookie ($API_KEY_COOKIE)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 13' -l 'o-auth2' -d 'OAuth 2.0 authentication ($SCALAR_O_AUTH2)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 13' -l 'open-id-connect' -d 'OpenID Connect Authentication ($SCALAR_OPEN_ID_CONNECT)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 13' -l 'help' -d 'Show help for this command'\ncomplete -c 'galaxy' -n '__galaxy_at 14' -l 'email' -d 'The email body parameter' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 14' -l 'password' -d 'The password body parameter' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 14' -l 'base-url' -d 'Override the base URL for API requests' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 14' -l 'timeout' -d 'Request timeout in milliseconds' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 14' -l 'max-retries' -d 'Number of retries for retryable failures' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 14' -l 'format' -d 'Output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 14' -l 'format-error' -d 'Error output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 14' -l 'transform' -d 'Dot-path transform for data output' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 14' -l 'transform-error' -d 'Dot-path transform for error output' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 14' -l 'raw-output' -d 'Print transformed string values without JSON quotes'\ncomplete -c 'galaxy' -n '__galaxy_at 14' -l 'debug' -d 'Enable SDK debug logging'\ncomplete -c 'galaxy' -n '__galaxy_at 14' -l 'environment' -d 'Named environment to target: production, void (default: production)' -r -f -a 'production void'\ncomplete -c 'galaxy' -n '__galaxy_at 14' -l 'bearer-auth' -d 'JWT Bearer token authentication ($BEARER_AUTH)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 14' -l 'basic-auth-username' -d 'Basic HTTP authentication ($BASIC_AUTH_USERNAME)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 14' -l 'basic-auth-password' -d 'Basic HTTP authentication ($BASIC_AUTH_PASSWORD)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 14' -l 'api-key-header' -d 'API key request header ($API_KEY_HEADER)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 14' -l 'api-key-query' -d 'API key query parameter ($API_KEY_QUERY)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 14' -l 'api-key-cookie' -d 'API key browser cookie ($API_KEY_COOKIE)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 14' -l 'o-auth2' -d 'OAuth 2.0 authentication ($SCALAR_O_AUTH2)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 14' -l 'open-id-connect' -d 'OpenID Connect Authentication ($SCALAR_OPEN_ID_CONNECT)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 14' -l 'help' -d 'Show help for this command'\ncomplete -c 'galaxy' -n '__galaxy_at 15' -l 'base-url' -d 'Override the base URL for API requests' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 15' -l 'timeout' -d 'Request timeout in milliseconds' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 15' -l 'max-retries' -d 'Number of retries for retryable failures' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 15' -l 'format' -d 'Output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 15' -l 'format-error' -d 'Error output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 15' -l 'transform' -d 'Dot-path transform for data output' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 15' -l 'transform-error' -d 'Dot-path transform for error output' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 15' -l 'raw-output' -d 'Print transformed string values without JSON quotes'\ncomplete -c 'galaxy' -n '__galaxy_at 15' -l 'debug' -d 'Enable SDK debug logging'\ncomplete -c 'galaxy' -n '__galaxy_at 15' -l 'environment' -d 'Named environment to target: production, void (default: production)' -r -f -a 'production void'\ncomplete -c 'galaxy' -n '__galaxy_at 15' -l 'bearer-auth' -d 'JWT Bearer token authentication ($BEARER_AUTH)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 15' -l 'basic-auth-username' -d 'Basic HTTP authentication ($BASIC_AUTH_USERNAME)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 15' -l 'basic-auth-password' -d 'Basic HTTP authentication ($BASIC_AUTH_PASSWORD)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 15' -l 'api-key-header' -d 'API key request header ($API_KEY_HEADER)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 15' -l 'api-key-query' -d 'API key query parameter ($API_KEY_QUERY)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 15' -l 'api-key-cookie' -d 'API key browser cookie ($API_KEY_COOKIE)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 15' -l 'o-auth2' -d 'OAuth 2.0 authentication ($SCALAR_O_AUTH2)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 15' -l 'open-id-connect' -d 'OpenID Connect Authentication ($SCALAR_OPEN_ID_CONNECT)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 15' -l 'help' -d 'Show help for this command'\ncomplete -c 'galaxy' -n '__galaxy_at 16' -l 'base-url' -d 'Sign in against this base URL instead of the default' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 16' -l 'flow' -d 'Sign-in flow to use: bearer-auth, basic, api-key-header, api-key-query, api-key-cookie, oauth-client-credentials, oaut...' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 16' -l 'environment' -d 'Named environment to target: production, void (default: production)' -r -f -a 'production void'\ncomplete -c 'galaxy' -n '__galaxy_at 16' -l 'help' -d 'Show help for this command'\ncomplete -c 'galaxy' -n '__galaxy_at 17' -l 'base-url' -d 'Forget the credentials saved for this base URL (not with --all)' -r -f\ncomplete -c 'galaxy' -n '__galaxy_at 17' -l 'all' -d 'Forget every saved credential, for every base URL'\ncomplete -c 'galaxy' -n '__galaxy_at 17' -l 'environment' -d 'Named environment to target: production, void (default: production)' -r -f -a 'production void'\ncomplete -c 'galaxy' -n '__galaxy_at 17' -l 'help' -d 'Show help for this command'\n"; export const completions = { bash, zsh, fish } as const; diff --git a/src/cli/credentials.ts b/src/cli/credentials.ts new file mode 100644 index 0000000..c0cef08 --- /dev/null +++ b/src/cli/credentials.ts @@ -0,0 +1,643 @@ +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +import { randomBytes } from 'node:crypto'; +import { + closeSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + rmSync, + statSync, + utimesSync, + writeFileSync, +} from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; + +import { keychainClear, keychainLookup, keychainRead, keychainStore, keychainSupported } from './keychain'; + +/** Where the credential store lives. Every field comes from the generated auth definition. */ +export type CredentialStoreLocation = { + readonly storeName: string; + readonly storeEnv: string; + /** + * Which store to use: the operating system's, a file, or the OS one with a file fallback. + * + * Resolved per call rather than once at module load so a test can point one command at a scratch + * file without the process having already decided. + */ + readonly backend: 'auto' | 'keychain' | 'file'; +}; + +/** Refresh metadata kept beside a stored OAuth access token. */ +export type StoredOAuth = { + readonly refreshToken?: string; + /** Unix epoch milliseconds the access token stops being usable, when the server said so. */ + readonly expiresAt?: number; + /** The endpoint a new access token is requested from. */ + readonly refreshUrl?: string; + /** Legacy refresh endpoint field emitted before `refreshUrl` was kept separately. */ + readonly tokenUrl?: string; + readonly clientId?: string; +}; + +/** Credentials stored for one base URL, keyed by client-option name. */ +export type StoredProfile = { + readonly credentials?: Record; + readonly oauth?: Record; + /** + * Which store actually holds this profile's secret. + * + * Only ever set on the *file* entry, which is why it can be trusted: it is written by the same + * operation that placed the secret, so nothing later has to infer where it went. Inferring it from + * a lookup cannot work — a helper that is not running and a helper reporting "no such item" are + * the same answer, and reading the first as the second is how a `logout` comes to report success + * over a credential that is still live. + * + * Absent means the file, which is all a store written before the OS backend existed can hold. + */ + readonly backend?: StoreBackend; +}; + +export type CredentialStoreFile = { + readonly version: number; + readonly profiles: Record; +}; + +const STORE_VERSION = 1; + +const EMPTY_STORE: CredentialStoreFile = { version: STORE_VERSION, profiles: {} }; + +// A generated CLI can be invoked by two shells at once. The short synchronous critical sections +// below need a cross-process lock, not an in-memory mutex that only coordinates one invocation. +const lockPause = new Int32Array(new SharedArrayBuffer(4)); +const STORE_LOCK_STALE_MS = 30_000; +// Consecutive filesystem errors while taking the lock before the attempt is abandoned. A lost race +// produces one; a path the filesystem will not answer for produces them without end. +const STORE_LOCK_MAX_ERRORS = 100; + +/** + * The store file path. + * + * The environment override wins outright so a credential can be pointed at a scratch file (a test + * harness, a container with no writable home) without touching the real one. + */ +export const storePath = (location: CredentialStoreLocation): string => { + const override = process.env[location.storeEnv]; + if (override) return override; + return join(stateDirectory(), location.storeName, 'credentials.json'); +}; + +// Per-user state, not config: the file holds tokens the CLI rewrites on its own (a refreshed access +// token) rather than settings a user edits, which is exactly what XDG_STATE_HOME is for. +const stateDirectory = (): string => { + if (process.platform === 'win32') return process.env['APPDATA'] || join(homedir(), 'AppData', 'Roaming'); + return process.env['XDG_STATE_HOME'] || join(homedir(), '.local', 'state'); +}; + +/** + * Reads the whole store, treating anything unreadable as empty. + * + * A missing file is the normal first-run case, and a corrupt one must not wedge every command in + * the CLI: the worst outcome of ignoring it is one `login` the user has to run again, whereas + * throwing would take out commands that were never going to need a stored credential at all. + */ +export const readStore = (location: CredentialStoreLocation): CredentialStoreFile => { + let text: string; + try { + text = readFileSync(storePath(location), 'utf8'); + } catch { + return EMPTY_STORE; + } + try { + const parsed: unknown = JSON.parse(text); + if (!parsed || typeof parsed !== 'object') return EMPTY_STORE; + const profiles = (parsed as { profiles?: unknown }).profiles; + if (!profiles || typeof profiles !== 'object') return EMPTY_STORE; + return { version: STORE_VERSION, profiles: profiles as Record }; + } catch { + return EMPTY_STORE; + } +}; + +/** + * Replaces the store file. + * + * Written to a sibling temp file and renamed so an interrupted write cannot leave a truncated store + * behind; the rename carries the temp file's narrow permissions onto the final path. + */ +export const writeStore = (location: CredentialStoreLocation, store: CredentialStoreFile): void => { + assertLockHeld(location); + const path = storePath(location); + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + // `wx` and a random suffix together, not `writeFileSync` with a mode. That call applies the mode + // only when it creates the file and follows a symlink when it does not, so a predictable temp name + // — the pid was the only variable — let anyone who could write this directory pre-create the path + // and be handed the credentials, or leave it `0644` and read them in the window before a chmod. + // Creating it exclusively fails instead, and the descriptor is what is written, so there is no + // second lookup of the name between the check and the write. + const temp = path + '.' + randomBytes(8).toString('hex') + '.tmp'; + const descriptor = openSync(temp, 'wx', 0o600); + try { + writeFileSync(descriptor, JSON.stringify(store, null, 2) + '\n'); + } finally { + closeSync(descriptor); + } + try { + renameSync(temp, path); + } catch (error) { + // The rename is what publishes the store; a temp file left behind after it failed is a copy of + // the credentials under a name nothing will ever read again. + rmSync(temp, { force: true }); + throw error; + } +}; + +/** Deletes the store file entirely. */ +export const deleteStore = (location: CredentialStoreLocation): void => { + assertLockHeld(location); + rmSync(storePath(location), { force: true }); +}; + +/** + * Serializes index rewrites across CLI processes. + * + * Token acquisition happens before the lock. The lock spans the keychain operation and its index + * update together, so a concurrent logout cannot clear a newly written secret while it still sees + * the old index entry. Long-running keychain helpers renew the lock lease before each command. + */ +const withStoreLock = (location: CredentialStoreLocation, operation: () => T): T => { + const path = storePath(location); + const lockPath = path + '.lock'; + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + // Written into the lock file and checked back on release. A lock this process was too slow to + // renew can be reclaimed as stale by another one, and after that it is no longer this process's + // to delete: unlinking it on the way out would drop the new holder's lock and leave two processes + // rewriting the store at once, where `writeStore` replaces the file wholesale and one of the two + // profiles is simply lost. + const token = randomBytes(16).toString('hex'); + let descriptor: number | undefined; + let consecutiveErrors = 0; + while (descriptor === undefined) { + try { + descriptor = openSync(lockPath, 'wx', 0o600); + try { + writeFileSync(descriptor, token); + } catch (error) { + // An unstamped lock is worse than none: this process is about to throw, its release will + // never run, and every other CLI would wait out the full stale window on a file nobody + // holds. Take it back out first. + closeSync(descriptor); + // And give the handle back, so the loop guard still reads "not acquired". Leaving it set + // would let an error the outer catch decided to swallow fall out of the loop and run the + // operation with no lock at all, then close this handle a second time on the way out. + descriptor = undefined; + rmSync(lockPath, { force: true }); + throw error; + } + } catch (error) { + if (!(error && typeof error === 'object' && 'code' in error && error.code === 'EEXIST')) throw error; + try { + const stale = statSync(lockPath); + if (Date.now() - stale.mtimeMs > STORE_LOCK_STALE_MS) { + // Moved aside rather than unlinked outright, and only then removed. An unconditional + // `rmSync` deletes whatever is at the path *now*, which after a pause between the stat + // and the call is a fresh lock a waiter has just taken — two processes in the critical + // section. `renameSync` moves one specific directory entry, so re-checking the inode and + // mtime afterwards establishes that what moved is the file that was found stale; anything + // else is put straight back. + const salvage = lockPath + '.' + token; + renameSync(lockPath, salvage); + const moved = statSync(salvage); + if (moved.ino === stale.ino && moved.mtimeMs === stale.mtimeMs) rmSync(salvage, { force: true }); + else renameSync(salvage, lockPath); + } + consecutiveErrors = 0; + } catch { + // Either the lock was released or reclaimed between the open and the stat — the benign race, + // which the next attempt wins — or the filesystem will not answer for this path at all, which + // retrying never fixes. Only a run of them means the second, so only a run gives up. + consecutiveErrors += 1; + if (consecutiveErrors > STORE_LOCK_MAX_ERRORS) { + throw new Error('Could not take the credential store lock at ' + lockPath + '.'); + } + } + // Every path waits, including the two that used to loop straight back to `openSync`. Without + // this a lock that cannot be stat'd or unlinked spins open+stat at full tilt with nothing on + // screen — usually for a token refresh the user never asked to watch. + Atomics.wait(lockPause, 0, 0, 10); + } + } + const previous = heldLock; + heldLock = { path: lockPath, token }; + try { + return operation(); + } finally { + heldLock = previous; + closeSync(descriptor); + try { + // Only this acquisition's own lock comes off; see `token` above. + if (readFileSync(lockPath, 'utf8') === token) rmSync(lockPath, { force: true }); + } catch { + // Already gone, which is the outcome this wanted anyway. + } + } +}; + +/** The lock this process currently holds, for {@link assertLockHeld} to check a write against. */ +let heldLock: { readonly path: string; readonly token: string } | undefined; + +/** + * Refuses a store rewrite this process no longer holds the lock for. + * + * Taking the lock is not the same as still having it. A holder stopped long enough to pass + * `STORE_LOCK_STALE_MS` — a suspended laptop, a SIGSTOP, a filesystem that blocked — has its lock + * reclaimed and another process completes a whole read-modify-write; if the first then resumes and + * writes the snapshot it read beforehand, the second's profile is gone, and under the keychain + * backend its index entry was the only record of a live keyring item. `writeStore` replaces the + * file wholesale, so there is no merge to save it. Checking the stamp again here is what makes the + * window survivable: the late writer fails loudly instead of quietly winning. + */ +const assertLockHeld = (location: CredentialStoreLocation): void => { + const lock = heldLock; + if (!lock || lock.path !== storePath(location) + '.lock') return; + let stamp: string; + try { + stamp = readFileSync(lock.path, 'utf8'); + } catch { + stamp = ''; + } + if (stamp !== lock.token) { + throw new Error( + 'The credential store changed underneath this command. Nothing was written; run it again.', + ); + } +}; + +/** Renews a held lock before a keychain helper can spend up to ten seconds waiting for the OS. */ +const refreshStoreLock = (location: CredentialStoreLocation): void => { + const now = new Date(); + try { + utimesSync(storePath(location) + '.lock', now, now); + } catch { + // The lock holder may already be unwinding after an interrupted filesystem operation. + } +}; + +/** + * The key one base URL's credentials are filed under. + * + * Keying on the URL is what keeps a sandbox token from being sent to production: a request with a + * different `--base-url` looks up a different profile and simply finds nothing. + */ +export const profileKey = (baseUrl: string): string => { + const trimmed = baseUrl.replace(/\/+$/u, ''); + if (!trimmed) return 'default'; + let url: URL; + try { + url = new URL(trimmed); + } catch { + // Not a URL this can take apart — an unusual `--base-url`, or the `default` case above. Keyed + // verbatim, which still files it consistently; it simply gets none of the normalization below. + return trimmed; + } + // Credentials never belong in the key. A `--base-url https://user:pass@host` would otherwise put + // them in the keychain account, which the Linux helper passes on its command line for any other + // process to read — the one thing this whole module is arranged to prevent. + url.username = ''; + url.password = ''; + // `URL` lower-cases the host and drops a port that is the scheme's default, so two spellings of + // one host cannot end up as two profiles — a `login` under one and a request under the other + // would look, to the user, like the sign-in simply had not taken. + url.hash = ''; + url.search = ''; + return url.toString().replace(/\/+$/u, ''); +}; + +/** Where a profile's secrets actually live. */ +export type StoreBackend = 'keychain' | 'file'; + +/** + * Whether this run may use the operating system's store at all. + * + * Nothing is probed here. Under `auto` the real operation decides: a keychain write that fails + * falls back to the file, and a keychain read that finds nothing falls through to it. That is both + * cheaper than a probe — which costs three subprocess spawns on every command — and more honest, + * since the thing that has to work is the operation itself, not a proxy for it. + */ +const mayUseKeychain = (location: CredentialStoreLocation): boolean => location.backend !== 'file'; + +/** Human-readable name for where a credential landed, for `login` and `logout` to print. */ +export const storeDescription = (location: CredentialStoreLocation, backend: StoreBackend): string => + backend === 'keychain' + ? 'your ' + osStoreName() + ' (the index of signed-in hosts is at ' + storePath(location) + ')' + : storePath(location); + +const osStoreName = (): string => { + if (process.platform === 'win32') return 'Windows Credential Manager'; + return 'system keyring'; +}; + +/** + * One stored profile. + * + * When the secret is in the OS store the file holds only the *index* — which base URLs are signed + * in, and for each one which store has it. That split is what makes `logout --all` possible: a + * keychain offers no portable way to list what is in it, so the set of profiles has to be recorded + * somewhere, and a list of hostnames is not a secret. + * + * The index also decides where to look, rather than the OS store being tried first and the file + * second. Trying in turn cannot tell an unreachable helper from an empty one, so it would answer + * with a file copy while a different, live secret sat in the OS store. + */ +export const readProfile = (location: CredentialStoreLocation, key: string): StoredProfile => + readProfileResult(location, key).profile; + +/** + * A profile read, and whether the store actually answered. + * + * `unreadable` says the marker points at the OS store and the helper never answered, so the empty + * profile beside it is the absence of an answer rather than the answer "nothing is stored". A read + * can treat the two alike — both send the user to `login` — but a write cannot: merging an update + * into an empty profile and storing the result replaces whatever was really under that key, so a + * keyring that is momentarily unreachable would cost the user every *other* credential filed there. + */ +const readProfileResult = ( + location: CredentialStoreLocation, + key: string, +): { readonly profile: StoredProfile; readonly unreadable: boolean } => { + const entry = indexEntry(location, key); + if (entry.backend !== 'keychain') { + // `keychain` exists to keep secrets out of a file, so one an earlier build left there is not a + // credential this may send — an organisation that chose the setting to forbid secrets at rest + // would otherwise keep authenticating from exactly the file it banned. It is left where it is + // rather than deleted, because a read is not the place to destroy anything; the next `login` + // replaces it. + return { profile: location.backend === 'keychain' ? {} : entry, unreadable: false }; + } + // Nothing falls through to the file: the index says the secret is not there. A helper that cannot + // run reads as "no credential", which sends the user to `login` rather than to a stale one — but + // it is reported alongside, so a *write* can refuse rather than overwrite what it could not see. + if (!mayUseKeychain(location)) { + // The configured backend is the file while the marker still says keychain. Replacing the entry + // is the intended migration here, not a loss, so this is not unreadable. + return { profile: {}, unreadable: false }; + } + const read = keychainRead(location.storeName, key); + if (read.secret === undefined) return { profile: {}, unreadable: read.unavailable }; + try { + const parsed: unknown = JSON.parse(read.secret); + if (parsed && typeof parsed === 'object') return { profile: parsed as StoredProfile, unreadable: false }; + } catch { + // An item this cannot parse is treated as absent, for the same reason a corrupt file is: one + // `login` again beats taking out every command that needed no credential. + } + return { profile: {}, unreadable: false }; +}; + +/** The file entry for one profile: the credential itself, or the marker saying where it really is. */ +const indexEntry = (location: CredentialStoreLocation, key: string): StoredProfile => { + const { profiles } = readStore(location); + // Own-property lookup only: a store carrying a key like `constructor` would otherwise resolve to + // something off `Object.prototype` that is not a profile at all. + if (!Object.prototype.hasOwnProperty.call(profiles, key)) return {}; + const profile = profiles[key]; + return profile && typeof profile === 'object' ? profile : {}; +}; + +/** Merges an update into one stored profile under the transaction lock, reporting where it landed. */ +export const writeProfile = ( + location: CredentialStoreLocation, + key: string, + update: StoredProfile, +): StoreBackend => + // The keychain write and the index write are one transaction. Locking only the file rewrite leaves + // a logout able to clear a newly stored secret while still reading the index entry that predates + // it, so both go inside the same lock. + withStoreLock(location, () => writeProfileLocked(location, key, update)); + +/** + * Merges a refreshed token into a profile that is still signed in, and does nothing when it is not. + * + * The silent refresh runs on the request path of an ordinary command: it reads the profile, spends a + * network round trip on the exchange, and only then writes the result back. A `logout` in another + * shell fits comfortably inside that window. Writing unconditionally there would file a freshly + * issued credential under a host the user has just signed out of — and under the keychain backend + * that means planting a new secret in their keyring *after* a sign-out that reported success, with + * an index entry to match. The lock serializes the two writes; this is what makes the later one + * conditional on what the earlier one left behind. + */ +export const refreshProfile = ( + location: CredentialStoreLocation, + key: string, + update: StoredProfile, +): void => { + withStoreLock(location, () => { + if (!Object.prototype.hasOwnProperty.call(readStore(location).profiles, key)) return; + // A refresh is preparation for a command, not the command, so a store that cannot be read is a + // reason to leave the credential where it is rather than to fail what the user asked for. The + // write below would refuse anyway; catching it here keeps that refusal off the request path. + if (readProfileResult(location, key).unreadable) return; + writeProfileLocked(location, key, update); + }); +}; + +/** The body of a profile write, for the callers above that have already taken the store lock. */ +const writeProfileLocked = ( + location: CredentialStoreLocation, + key: string, + update: StoredProfile, +): StoreBackend => { + const marker = indexEntry(location, key); + // Merging only after acquiring the lock preserves credentials captured by another flow while + // this flow was waiting to save its own result. + if (marker.backend === 'keychain') refreshStoreLock(location); + const existing = readProfileResult(location, key); + // Refused rather than merged into nothing. The write below replaces the whole profile, so going + // ahead on a read that never answered would file this one credential over however many were + // already there — silently, on a keyring hiccup no louder than a daemon restart. + if (existing.unreadable) { + throw new Error( + 'Could not read the credentials already saved for this host from your ' + + osStoreName() + + '. Nothing was written. Try again, or run logout for this host first.', + ); + } + const profile = mergeProfile(existing.profile, update); + if (mayUseKeychain(location)) { + refreshStoreLock(location); + if (keychainStore(location.storeName, key, JSON.stringify(withoutMarker(profile)))) { + try { + // The file records only that this base URL is signed in and which store has it — no secret + // — so `logout --all` can find it without one ever reaching disk. + writeIndex(location, key, { backend: 'keychain' }); + } catch (error) { + // The secret reached the OS store but nothing on disk points at it, and the index is the + // only thing `logout --all` can enumerate. Take it back out rather than leaving a + // credential in the user's keyring that this CLI can neither find nor remove — but only + // when this write is what put it there. `keychainStore` overwrites, so if the store was + // already holding this key the item here is the user's working credential, and clearing it + // would destroy the very thing the failed sign-in was meant to replace. + if (marker.backend !== 'keychain') { + refreshStoreLock(location); + keychainClear(location.storeName, key); + } + throw error; + } + return 'keychain'; + } + if (location.backend === 'keychain') { + throw new Error( + keychainSupported() + ? 'Could not save the credential to your ' + osStoreName() + '.' + : 'This system has no credential store ' + location.storeName + ' can use.', + ); + } + } + // Whatever this key already had in the OS store comes out before the file takes over, so the two + // cannot disagree. Only the marker decides what a later read uses, so an item left behind can no + // longer shadow this one — which is why a helper that will not answer is not worth failing the + // sign-in over. It is reported instead: wedging `login` would leave the user no way forward at + // all, since `logout` would be refusing for exactly the same reason. + if (marker.backend === 'keychain' && removeSecret(location, key) !== 'removed') { + warn( + 'Your ' + osStoreName() + ' may still hold an older credential for this host; remove it there if so.', + ); + } + writeIndex(location, key, { ...withoutMarker(profile), backend: 'file' }); + return 'file'; +}; + +const warnings: string[] = []; + +/** + * Notes something the user should know without failing what they asked for. + * + * Collected rather than printed. The same store code runs under `login` and under the silent token + * refresh that precedes an ordinary command, and writing to standard error there would interleave + * prose with the JSON document `--format-error json` puts on that same stream. Only `login` and + * `logout` drain this, so a warning reaches the one place a user is already reading sentences. + */ +const warn = (message: string): void => { + if (!warnings.includes(message)) warnings.push(message); +}; + +/** Takes the warnings collected so far, leaving none behind. */ +export const takeWarnings = (): readonly string[] => warnings.splice(0, warnings.length); + +/** + * Takes one secret out of the OS store, reporting what could be established. + * + * `keychainClear` answers `false` both for an item that is not there and for a helper that cannot + * run, so its word alone is never enough. A lookup afterwards settles the only question that + * matters: `kept` means the secret is still retrievable, and so removing the index entry would be a + * lie. Anything else means this CLI can no longer get at it, which is what being signed out is — + * `unconfirmed` says so while admitting the store may still hold something, because a helper that + * could not run cannot prove otherwise. + * + * Nothing here throws. Earlier versions failed the command when a clear did not succeed, which + * wedged `login` and `logout` together for anyone whose item had simply been removed by hand or + * whose helper had gone: both refused, and nothing the CLI offered could ever clear the entry. + */ +const removeSecret = (location: CredentialStoreLocation, key: string): 'removed' | 'unconfirmed' | 'kept' => { + refreshStoreLock(location); + if (keychainClear(location.storeName, key)) return 'removed'; + refreshStoreLock(location); + return keychainLookup(location.storeName, key) === undefined ? 'unconfirmed' : 'kept'; +}; + +/** The profile without the index's own bookkeeping, which is not part of the secret. */ +const withoutMarker = (profile: StoredProfile): StoredProfile => ({ + ...(profile.credentials ? { credentials: profile.credentials } : {}), + ...(profile.oauth ? { oauth: profile.oauth } : {}), +}); + +/** + * Removes one stored profile, reporting what became of it. + * + * A secret the OS store will not give up fails the whole removal rather than being written off. + * `readProfile` looks the OS store up by key and never through the index, so dropping the index + * entry while the secret stays behind would leave a credential that still authenticates every + * command — and that `logout --all`, which enumerates the index, could no longer even find. + */ +export const deleteProfile = (location: CredentialStoreLocation, key: string): 'removed' | 'absent' => + withStoreLock(location, () => { + const entry = indexEntry(location, key); + if (entry.backend === 'keychain') { + const outcome = removeSecret(location, key); + if (outcome === 'kept') { + // The one case worth refusing: the secret answered a lookup after the clear, so it is still + // there and still usable. Dropping the entry would report a sign-out over a live credential + // and lose the only record of where it is. + throw new Error('Could not remove the credential from your ' + osStoreName() + '.'); + } + if (outcome === 'unconfirmed') { + warn('Your ' + osStoreName() + ' may still hold a credential for this host; remove it there if so.'); + } + } + const store = readStore(location); + if (!Object.prototype.hasOwnProperty.call(store.profiles, key)) return 'absent'; + const profiles = { ...store.profiles }; + delete profiles[key]; + writeStore(location, { version: STORE_VERSION, profiles }); + return 'removed'; + }); + +/** + * Forgets everything, in both places. + * + * The keychain items go one by one, taken from the index, before the index itself does: deleting the + * file first would strand the secrets in the OS store with nothing left pointing at them — invisible + * to this CLI and never cleaned up. + */ +export const clearAll = (location: CredentialStoreLocation): void => { + const kept = withStoreLock(location, () => { + // The keychain clears and the index rewrite share one lock with login. It can hold the lock for + // several helper calls, but that is preferable to acknowledging a logout that strands a secret. + const entries = Object.entries(readStore(location).profiles); + const outcomes = entries.map( + ([key, profile]) => + [key, profile?.backend === 'keychain' ? removeSecret(location, key) : 'removed'] as const, + ); + const kept = outcomes.filter(([, outcome]) => outcome === 'kept').map(([key]) => key); + if (outcomes.some(([, outcome]) => outcome === 'unconfirmed')) { + warn('Your ' + osStoreName() + ' may still hold credentials this could not remove.'); + } + const remaining = entries.filter(([key]) => kept.includes(key)); + if (remaining.length === 0) deleteStore(location); + else writeStore(location, { version: STORE_VERSION, profiles: Object.fromEntries(remaining) }); + return kept; + }); + if (kept.length === 0) return; + // The index entry is the only record of which base URLs have a secret in the OS store, so one + // still holding a retrievable secret keeps its entry: dropping it would strand the credential + // there — still live, and invisible to this CLI for good. + throw new Error( + 'Could not remove every credential from your ' + + osStoreName() + + '; ' + + String(kept.length) + + ' still there.', + ); +}; + +/** Writes one profile into the on-disk store while the caller holds the credential-store lock. */ +const writeIndex = (location: CredentialStoreLocation, key: string, profile: StoredProfile): void => { + const store = readStore(location); + writeStore(location, { + version: STORE_VERSION, + profiles: { ...store.profiles, [key]: profile }, + }); +}; + +// Empty maps are dropped rather than written as `{}`: a store a user opens should show what is +// actually saved, and a token-only sign-in has no OAuth metadata to speak of. +const mergeProfile = (existing: StoredProfile, update: StoredProfile): StoredProfile => { + const credentials = { ...(existing.credentials ?? {}), ...(update.credentials ?? {}) }; + const oauth = { ...(existing.oauth ?? {}), ...(update.oauth ?? {}) }; + return { + ...(Object.keys(credentials).length > 0 ? { credentials } : {}), + ...(Object.keys(oauth).length > 0 ? { oauth } : {}), + }; +}; diff --git a/src/cli/keychain.ts b/src/cli/keychain.ts new file mode 100644 index 0000000..1220803 --- /dev/null +++ b/src/cli/keychain.ts @@ -0,0 +1,194 @@ +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +import { spawnSync } from 'node:child_process'; + +/** + * One platform's credential helper, as the three commands this needs from it. + * + * Every helper is a command-line tool the operating system ships (or, on Windows, a script run + * through the shell it ships), because the alternative is a native addon: `keytar` and friends are + * compiled per platform and per Node version, and a generated CLI is meant to stay dependency-free + * and installable as plain JavaScript. + */ +type KeychainHelper = { + readonly file: string; + /** Argv for reading a secret back. The secret is the command's standard output. */ + readonly lookup: (service: string, account: string) => readonly string[]; + /** Argv for writing. The secret never appears here; see {@link HELPERS} for where it travels. */ + readonly store: (service: string, account: string) => readonly string[]; + readonly clear: (service: string, account: string) => readonly string[]; + /** + * Script fed on standard input, for a helper driven through a shell. + * + * A helper with one reads its service, account and secret from the environment, because the shell + * has already taken standard input for the script. + */ + readonly script?: (mode: 'lookup' | 'store' | 'clear') => string; +}; + +/** + * Absolute path to Windows PowerShell. + * + * Never the bare name: on Windows a command name with no separator is resolved against the *current + * working directory* before `PATH`, so `spawn('powershell', ...)` inside a checked-out repository + * that happens to contain `powershell.exe` would run that instead — and hand it the secret. + */ +export const windowsPowerShell = (): string => + (process.env['SystemRoot'] ?? 'C:\\Windows') + '\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'; + +/** + * The secret NEVER travels in argv. + * + * A process's command line is readable by other processes — `ps`, `/proc//cmdline` — so a + * credential passed as an argument is exposed to every other program running as that user, and on + * many systems to other users too. + * + * That rule is what decides which platforms are here. `secret-tool store` reads the secret from + * standard input by design. The Windows helper cannot — `powershell -Command -` reads *all* of + * standard input as the script to run, leaving nothing for the script itself to read — so its secret + * goes through the environment instead, which on Windows one process cannot read from another + * without debug privileges, unlike a command line. + * + * macOS is deliberately absent. `security add-generic-password` takes the password only as the + * argument to `-w`; with no value there it is a usage error, and with a value the secret is in the + * command line. It has no standard-input form and no interactive mode this can drive safely, so a + * Mac gets the `0600` file — the same place the widely used CLIs that do not ship a native helper + * binary keep theirs. + */ +const HELPERS: Readonly> = { + // Linux: the freedesktop Secret Service, which GNOME Keyring and KWallet both implement. + // A bare command name is safe here: only Windows resolves one against the working directory. + linux: { + file: 'secret-tool', + lookup: (service, account) => ['lookup', 'service', service, 'account', account], + store: (service, account) => ['store', '--label=' + service, 'service', service, 'account', account], + clear: (service, account) => ['clear', 'service', service, 'account', account], + }, + // Windows Credential Manager, through the PasswordVault WinRT API that Windows PowerShell exposes. + // The script arrives on standard input (`-Command -`) so neither it nor the secret reaches argv. + win32: { + file: windowsPowerShell(), + lookup: () => ['-NoProfile', '-NonInteractive', '-Command', '-'], + store: () => ['-NoProfile', '-NonInteractive', '-Command', '-'], + clear: () => ['-NoProfile', '-NonInteractive', '-Command', '-'], + script: (mode) => windowsScript(mode), + }, +}; + +/** Whether this platform has a credential helper the CLI can drive at all. */ +export const keychainSupported = (): boolean => HELPERS[process.platform] !== undefined; + +/** + * PowerShell for one Credential Manager operation. + * + * The service, the account and — for a store — the secret are all read from environment variables + * rather than pasted into the script, so a base URL can never close a quote and become PowerShell. + * Standard input is not available for any of them: the shell has already taken it for this script. + */ +const windowsScript = (mode: 'lookup' | 'store' | 'clear'): string => { + const prelude = [ + '$ErrorActionPreference = "Stop"', + // Windows PowerShell encodes `[Console]::Out` with the console output code page (an OEM page + // such as CP850) when stdout is a pipe, while `spawnSync` here decodes it as UTF-8. Without + // this, a stored credential carrying any non-ASCII character comes back mangled — and a + // credential that is wrong but still parses is worse than one that fails to, because every + // command then authenticates with it, and `login` appears to fix it while storing a correct + // value that is read back wrong again. + '[Console]::OutputEncoding = [System.Text.Encoding]::UTF8', + '[void][Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime]', + '$vault = New-Object Windows.Security.Credentials.PasswordVault', + '$svc = $env:SCALAR_KEYCHAIN_SERVICE', + '$acct = $env:SCALAR_KEYCHAIN_ACCOUNT', + ]; + if (mode === 'lookup') { + return [ + ...prelude, + '$item = $vault.Retrieve($svc, $acct)', + '$item.RetrievePassword()', + '[Console]::Out.Write($item.Password)', + ].join('\n'); + } + if (mode === 'clear') { + return [...prelude, '$item = $vault.Retrieve($svc, $acct)', '$vault.Remove($item)'].join('\n'); + } + return [ + ...prelude, + '$secret = $env:SCALAR_KEYCHAIN_SECRET', + 'try { $vault.Remove($vault.Retrieve($svc, $acct)) } catch { }', + '$vault.Add((New-Object Windows.Security.Credentials.PasswordCredential($svc, $acct, $secret)))', + ].join('\n'); +}; + +/** + * Reads one secret back, saying which kind of nothing it got when there is no secret. + * + * `unavailable` is the distinction a plain lookup cannot make: the helper never ran (missing, + * timed out, no session bus over ssh) rather than ran and reported no such item. A caller about to + * *replace* a profile needs it, because merging into the empty result of a failed read and writing + * that back is how the other credentials under the same key get dropped. + */ +export const keychainRead = ( + service: string, + account: string, +): { readonly secret?: string; readonly unavailable: boolean } => { + const result = run('lookup', service, account); + // `secret-tool` terminates its output with a newline the stored value never had; `security` + // does the same. Nothing this stores is meant to end in one, so a single trailing newline comes off. + if (result.ok) return { secret: result.stdout.replace(/\n$/u, ''), unavailable: false }; + return { unavailable: result.unavailable }; +}; + +/** Reads one secret back, or `undefined` when the helper has no such item (or cannot run). */ +export const keychainLookup = (service: string, account: string): string | undefined => + keychainRead(service, account).secret; + +/** Writes one secret, reporting whether the helper accepted it. */ +export const keychainStore = (service: string, account: string, secret: string): boolean => + run('store', service, account, secret).ok; + +/** + * Removes one secret, reporting whether the helper accepted the removal. + * + * A helper asked for an item it does not have reports failure, so callers establish that the item + * exists first and read this as "the secret is gone" only when it is `true`. + */ +export const keychainClear = (service: string, account: string): boolean => run('clear', service, account).ok; + +const run = ( + mode: 'lookup' | 'store' | 'clear', + service: string, + account: string, + secret?: string, +): { readonly ok: boolean; readonly stdout: string; readonly unavailable: boolean } => { + const helper = HELPERS[process.platform]; + if (!helper) return { ok: false, stdout: '', unavailable: true }; + const script = helper.script?.(mode); + // A scripted helper consumes the whole of standard input as its script, so its secret travels in + // the environment beside the service and account; a direct helper takes the secret on stdin. + const input = script ?? secret ?? ''; + try { + const result = spawnSync(helper.file, [...helper[mode](service, account)], { + input, + encoding: 'utf8', + // A helper that blocks — a locked keychain putting up a dialog, a Secret Service with no + // daemon answering — must not hang every command. The timeout turns that into a fallback. + timeout: 10000, + env: { + ...process.env, + SCALAR_KEYCHAIN_SERVICE: service, + SCALAR_KEYCHAIN_ACCOUNT: account, + ...(script !== undefined && secret !== undefined ? { SCALAR_KEYCHAIN_SECRET: secret } : {}), + }, + }); + // A spawn that reports an `error` never delivered the question — the helper is missing, or the + // timeout above fired. A non-zero status is the helper answering, which for a lookup or a clear + // is how every implementation spells "no such item". + if (result.error) return { ok: false, stdout: '', unavailable: true }; + if (result.status !== 0) return { ok: false, stdout: '', unavailable: false }; + return { ok: true, stdout: result.stdout ?? '', unavailable: false }; + } catch { + // ENOENT for a helper this machine does not have is the common case, and it is not an error: + // it is the signal to fall back. + return { ok: false, stdout: '', unavailable: true }; + } +}; diff --git a/src/cli/list.ts b/src/cli/list.ts new file mode 100644 index 0000000..8a9db96 --- /dev/null +++ b/src/cli/list.ts @@ -0,0 +1,39 @@ +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +/** + * Cuts a line to the terminal's width. + * + * A row that wrapped would occupy two of them, and the picker's redraw counts rows rather than + * measuring them, so one wrapped label would put every following frame a line out of place. + */ +export const clip = (text: string, width: number): string => + text.length <= width ? text : text.slice(0, Math.max(0, width - 3)) + '...'; + +export type ListRowOptions = { + // Terminal width to cut the row to. + readonly columns: number; + // Marks the row with `> ` and colours it; the picker moves this with the arrow keys, and a + // static list uses it for the entry a bare invocation would pick. + readonly selected?: boolean; + // Whether the destination is a terminal. Escapes are written only then, so a redirected list + // is the same text without them. + readonly color?: boolean; +}; + +/** + * Renders one row: a two-column marker, the label, then the detail dimmed behind it. + * + * The detail carries what tells otherwise-identical labels apart — a flow name for the picker, a + * base URL for the environments list — so it is on every row rather than only the marked one, and + * dimmed so the labels still read as the list. A row too narrow to hold it is emitted as plain + * clipped text instead: the dimming would otherwise wrap a fragment whose closing bracket the clip + * has already removed. + */ +export const listRow = (label: string, detail: string, options: ListRowOptions): string => { + const head = (options.selected ? '> ' : ' ') + label; + const tail = detail ? ' ' + detail : ''; + const plain = clip(head + tail, options.columns); + if (!options.color) return plain; + const row = plain === head + tail ? head + '\u001b[2m' + tail + '\u001b[22m' : plain; + return options.selected ? '\u001b[36m' + row + '\u001b[39m' : row; +}; diff --git a/src/cli/login.ts b/src/cli/login.ts new file mode 100644 index 0000000..a98fbc2 --- /dev/null +++ b/src/cli/login.ts @@ -0,0 +1,1857 @@ +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +import { spawn } from 'node:child_process'; +import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; +import { createServer } from 'node:http'; +import { createInterface } from 'node:readline'; +import { stderr as processStderr, stdin as processStdin } from 'node:process'; + +import { + type CredentialStoreLocation, + type StoredOAuth, + type StoredProfile, + clearAll, + deleteProfile, + profileKey, + readProfile, + refreshProfile, + storeDescription, + writeProfile, +} from './credentials'; +import { windowsPowerShell } from './keychain'; +import { clip, listRow } from './list'; + +/** One way `login` can obtain a credential. Mirrors the emitter's `CliAuthMethodDefinition`. */ +export type CliAuthMethodDefinition = { + readonly name: string; + readonly label: string; +} & ( + | { readonly kind: 'token'; readonly clientKey: string; readonly prompt: string } + | { readonly kind: 'basic'; readonly usernameKey: string; readonly passwordKey: string } + | { + readonly kind: 'oauth'; + readonly grant: 'authorizationCode'; + readonly clientKey: string; + readonly tokenUrl: string; + readonly refreshUrl: string; + readonly authorizationUrl: string; + readonly scopes: readonly string[]; + readonly clientId: string; + readonly redirectPort: number; + /** Issuer the redirect's `iss` must name (RFC 9207); absent means an `iss` is not checked. */ + readonly issuer?: string; + } + | { + readonly kind: 'oauth'; + readonly grant: 'clientCredentials' | 'password'; + readonly clientKey: string; + readonly tokenUrl: string; + readonly refreshUrl: string; + readonly scopes: readonly string[]; + readonly clientId?: string; + } + | { + readonly kind: 'oauth'; + readonly grant: 'deviceAuthorization'; + readonly clientKey: string; + readonly deviceAuthorizationUrl: string; + readonly tokenUrl: string; + readonly refreshUrl: string; + readonly scopes: readonly string[]; + readonly clientId?: string; + } + | { + readonly kind: 'oauth'; + readonly grant: 'openIdConnect'; + readonly clientKey: string; + readonly discoveryUrl: string; + readonly scopes: readonly string[]; + readonly clientId: string; + readonly redirectPort: number; + /** Configured issuer the discovered one must match; absent trusts the one discovery verifies. */ + readonly issuer?: string; + } +); + +/** Everything `login` and `logout` need. Mirrors the emitter's `CliAuthDefinition`. */ +export type CliAuthDefinition = { + readonly loginPath: readonly string[]; + readonly logoutPath: readonly string[]; + readonly loginCommand: string; + readonly storeName: string; + readonly storeEnv: string; + readonly baseUrlEnv: string; + readonly backend: 'auto' | 'keychain' | 'file'; + readonly defaultBaseUrl: string; + /** Named environments and the URL each sends requests to; absent when there is no `--environment`. */ + readonly environments?: readonly { readonly name: string; readonly url: string }[]; + readonly requirements: readonly (readonly string[])[]; + readonly envByKey: Readonly>; + readonly methods: readonly CliAuthMethodDefinition[]; + /** Publisher logo for the browser sign-in page; absent means the page shows none. */ + readonly logo?: string; +}; + +/** How long the browser flow waits for the redirect before giving the terminal back. */ +const BROWSER_FLOW_TIMEOUT_MS = 300000; + +/** + * How long the browser flow waits for its answered pages to finish sending before closing the + * listener. Loopback delivers a page in milliseconds; this only bounds a browser that stops reading. + */ +const RESPONSE_FLUSH_TIMEOUT_MS = 2000; + +/** The one path the loopback listener answers as a redirect; it is what `redirect_uri` registers. */ +const REDIRECT_PATH = '/callback'; + +/** + * Ceiling on how long the device grant polls, whatever `expires_in` claims. + * + * RFC 8628 leaves the lifetime to the server, and the server is named by the OpenAPI document. This + * is the same bound the browser grant has, so neither interactive sign-in can be made to wait + * indefinitely by an endpoint that answers `authorization_pending` and a very large expiry. + */ +const DEVICE_FLOW_TIMEOUT_MS = 300000; + +/** + * How long a token request may take, and how much of its answer is read. + * + * Both bounds exist because the endpoint comes from the OpenAPI document. `fetch` has no default + * timeout, and a refresh runs before every command that has no flag or environment credential, so an + * endpoint that accepts the connection and never answers would hang the whole CLI with nothing on + * screen. The size cap keeps an endless body from being buffered into memory for a response whose + * useful part is a few hundred bytes. + */ +const TOKEN_REQUEST_TIMEOUT_MS = 30000; +const TOKEN_RESPONSE_LIMIT = 65536; + +/** Refresh this far ahead of expiry so a token cannot lapse between the check and the request. */ +const REFRESH_LEEWAY_MS = 60000; + +/** + * Deadline for the refresh that runs ahead of an ordinary command, rather than the full one above. + * + * That refresh is not what the user asked for — it is preparation for a request they are waiting on + * — and its failure is swallowed, so the whole of its cost is delay they cannot see a reason for. An + * unreachable refresh endpoint would otherwise add `TOKEN_REQUEST_TIMEOUT_MS` to *every* invocation + * for the life of the stored entry. Giving up sooner costs a refresh that a slow provider might + * still have answered; the command then goes out with the credential it already had, and a 401 says + * to sign in again. An interactive `login` keeps the full deadline, because there the exchange is + * the thing the user is waiting for. + */ +const SILENT_REFRESH_TIMEOUT_MS = 5000; + +/** + * How far past its `exp` an ID token is still accepted, to absorb clock drift between this machine + * and the provider. + * + * OpenID Connect Core leaves the allowance to the client. Five minutes is the customary figure: a + * token the provider minted seconds ago is not refused because a laptop's clock runs slow, and a + * token that genuinely expired is still refused long before it could be useful to a replay. + */ +const ID_TOKEN_CLOCK_SKEW_MS = 300000; + +/** Lifetime assumed for a token whose endpoint did not state one. See {@link oauthMetadata}. */ +const ASSUMED_TOKEN_LIFETIME_S = 3600; + +const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']); + +/** + * Shortest device-code poll interval a server can ask for. + * + * Low enough that a local authorization server still feels immediate, high enough that one asking + * for zero cannot turn the wait for authorization into a request flood. + */ +const DEVICE_POLL_FLOOR_MS = 200; + +const storeLocation = (auth: CliAuthDefinition): CredentialStoreLocation => ({ + storeName: auth.storeName, + storeEnv: auth.storeEnv, + backend: auth.backend, +}); + +/** The client option keys a method fills, which for HTTP Basic is the username and password pair. */ +const methodKeys = (method: CliAuthMethodDefinition): readonly string[] => + method.kind === 'basic' ? [method.usernameKey, method.passwordKey] : [method.clientKey]; + +/** + * Names what a request still needs when this sign-in cannot satisfy any alternative by itself. + * + * An OpenAPI `security` list is OR-of-ANDs, so an alternative can require two schemes at once — an + * API key *and* a bearer token. `login` captures one credential per run, which leaves that document + * signed in only halfway, and saying nothing would send the user off to collect a 401 whose hint + * tells them to run the command they have just run. + * + * Read back from the store rather than from what was captured, so a credential an earlier `login` + * already left behind counts towards the requirement instead of being asked for a second time. + * + * The alternative closest to complete is the one named: it is the cheapest way out for the user, + * and listing every unmet alternative would describe choices the API treats as interchangeable. + * + * `loginCommand` names the environment this `login` selected, when it selected one. The credential + * just saved is filed under that environment's URL, so a hint naming the bare command would file + * the next one under the default environment instead, and the pair would never meet. A sign-in made + * with `--base-url` selects no environment and gets the bare command, as the request hints do. + */ +const outstandingRequirement = ( + auth: CliAuthDefinition, + location: CredentialStoreLocation, + key: string, + loginCommand: string, +): string | undefined => { + if (auth.requirements.length === 0) return undefined; + // The store *and* the environment, because that is what the request path counts. `applyStoredCredentials` + // treats a non-blank environment variable as an explicit value and never consults the store for + // that key, so a user who had exported one half of an AND alternative would otherwise be sent to + // obtain a credential their very next command was going to send anyway. + const held = new Set(Object.keys(readProfile(location, key).credentials ?? {})); + for (const [clientKey, name] of Object.entries(auth.envByKey)) { + if ((process.env[name] ?? '').trim()) held.add(clientKey); + } + if (auth.requirements.some((keys) => keys.every((candidate) => held.has(candidate)))) return undefined; + const missing = auth.requirements + .map((keys) => keys.filter((candidate) => !held.has(candidate))) + .filter((keys) => keys.length > 0) + .sort((left, right) => left.length - right.length)[0]; + if (!missing || missing.length === 0) return undefined; + // A key with no method behind it cannot be asked for, so it is described rather than commanded. + // Deduplicated by method, because HTTP Basic fills two keys in one sitting and would otherwise be + // offered twice for the one sign-in that satisfies both. + const names = new Set(); + const runnable: CliAuthMethodDefinition[] = []; + for (const candidate of missing) { + const method = auth.methods.find((entry) => methodKeys(entry).includes(candidate)); + if (!method || names.has(method.name)) continue; + names.add(method.name); + runnable.push(method); + } + if (runnable.length === 0) return 'Requests here also need another credential this CLI cannot obtain.'; + const commands = runnable.map((method) => loginCommand + ' --flow ' + method.name); + return ( + 'Requests here also need ' + + (runnable.length === 1 ? 'one more credential' : String(runnable.length) + ' more credentials') + + '. Run ' + + commands.join(', then ') + + '.' + ); +}; + +/** + * Runs an interactive sign-in and saves the result, returning the line to print. + * + * Every prompt is written to standard error, so the confirmation this returns is the only thing on + * standard output and `login` stays usable in a pipeline. + * + * `loginCommand` is the sign-in command for the environment this one selected (the bare command + * when none was); it names the next step when the API needs a further credential. + */ +export const runLogin = async ( + auth: CliAuthDefinition, + baseUrl: string, + flow: string | undefined, + loginCommand: string, +): Promise => { + const method = await chooseMethod(auth, flow); + const location = storeLocation(auth); + const key = profileKey(baseUrl); + const captured = await captureCredentials(method, baseUrl, auth.logo); + // Where it landed is reported by the write itself rather than predicted, so under `auto` the + // line a user reads names the store the credential actually went into. The write merges this + // captured result inside its lock, because a browser grant can sit for minutes before it arrives. + const backend = writeProfile(location, key, captured); + const line = + 'Signed in to ' + + // The base URL can be the document's own `servers[0].url` when no flag and no environment + // variable named one, which makes it spec-derived text on its way to a terminal. + safeText(baseUrl || 'the API') + + '. Credentials saved to ' + + storeDescription(location, backend) + + '.'; + const outstanding = outstandingRequirement(auth, location, key, loginCommand); + return outstanding ? line + '\n' + outstanding : line; +}; + +/** Forgets stored credentials, returning the line to print. */ +export const runLogout = (auth: CliAuthDefinition, baseUrl: string, all: boolean): string => { + const location = storeLocation(auth); + if (all) { + clearAll(location); + return 'Signed out everywhere.'; + } + const key = profileKey(baseUrl); + const outcome = deleteProfile(location, key); + if (outcome === 'removed') return 'Signed out of ' + safeText(baseUrl || 'the API') + '.'; + return 'No stored credentials for ' + safeText(baseUrl || 'the API') + '.'; +}; + +/** + * Stored credentials for one base URL, keyed by client-option name. + * + * An OAuth access token at or near its expiry is refreshed in place first, so a CLI left alone + * overnight keeps working without a second `login`. A refresh that fails is swallowed: the stale + * token still goes out, and the 401 that follows carries the runtime's own sign-in hint, which + * says more than a refresh error would. + * + * Refresh exchanges may overlap, but their results merge into the current profile under the same + * lock as interactive sign-in. A server that rotates refresh tokens can still reject one of two + * simultaneous exchanges; that is recoverable with one `login` and never loses another flow's key. + */ +export const storedCredentials = async ( + auth: CliAuthDefinition, + baseUrl: string, +): Promise>> => { + const location = storeLocation(auth); + const key = profileKey(baseUrl); + const profile = readProfile(location, key); + const credentials: Record = { ...(profile.credentials ?? {}) }; + for (const [clientKey, meta] of Object.entries(profile.oauth ?? {})) { + // `readProfile` checks that the stored value parses and is an object, but not the shape beneath + // it, so an entry this CLI did not write can still be `null` or a scalar. Reading `expiresAt` off + // one would throw on the request path of *every* command — the exact failure the corrupt-store + // handling exists to avoid, and worse than it, since a bad OAuth entry would also take out the + // commands that need no credential at all. Skipping it falls back to `login`, as a missing + // entry does. + if (!meta || typeof meta !== 'object') continue; + // Older generated CLIs stored this as `tokenUrl`; retaining that fallback makes their saved + // credentials refreshable after an upgrade while new entries preserve a distinct refresh URL. + const refreshUrl = meta.refreshUrl ?? meta.tokenUrl; + if (!needsRefresh(meta) || !meta.refreshToken || !refreshUrl) continue; + try { + const token = await exchangeToken( + refreshUrl, + baseUrl, + { + grant_type: 'refresh_token', + refresh_token: meta.refreshToken, + ...(meta.clientId ? { client_id: meta.clientId } : {}), + }, + SILENT_REFRESH_TIMEOUT_MS, + ); + credentials[clientKey] = token.accessToken; + // Conditional, not `writeProfile`: a `logout` that landed while the exchange was in flight + // must not be undone by the token it produced. See `refreshProfile`. + refreshProfile(location, key, { + credentials: { [clientKey]: token.accessToken }, + oauth: { [clientKey]: oauthMetadata(token, refreshUrl, meta.clientId, meta.refreshToken) }, + }); + } catch { + // Deliberately silent; see this function's doc comment. + } + } + return credentials; +}; + +const needsRefresh = (meta: StoredOAuth): boolean => + meta.expiresAt !== undefined && meta.expiresAt - Date.now() < REFRESH_LEEWAY_MS; + +/** + * Picks the sign-in method to run. + * + * A named flow is matched exactly; otherwise a single method runs unprompted and several are put to + * the user. The picker needs a terminal, so a non-interactive shell is told to name a flow instead + * of being left waiting on stdin that will never arrive. + */ +const chooseMethod = async ( + auth: CliAuthDefinition, + flow: string | undefined, +): Promise => { + const names = auth.methods.map((method) => method.name); + if (flow !== undefined) { + const chosen = auth.methods.find((method) => method.name === flow); + if (!chosen) + throw new UsageError("Unknown sign-in flow '" + flow + "'. Available: " + names.join(', ') + '.'); + return chosen; + } + const first = auth.methods[0]; + if (!first) throw new Error('This CLI has no sign-in flow.'); + if (auth.methods.length === 1) return first; + const index = await promptChoice( + 'How would you like to sign in?', + auth.methods.map((method) => ({ label: method.label, hint: method.name })), + ); + // The picker only ever resolves an index it drew, so `first` stands in for a state that cannot + // occur rather than an error message describing one. + return auth.methods[index] ?? first; +}; + +const captureCredentials = async ( + method: CliAuthMethodDefinition, + baseUrl: string, + logo: string | undefined, +): Promise => { + if (method.kind === 'token') { + const value = await promptSecret(method.prompt); + if (!value) throw new UsageError('No value entered; nothing was saved.'); + return { credentials: { [method.clientKey]: value } }; + } + if (method.kind === 'basic') { + const username = await promptLine('Username: '); + if (!username) throw new UsageError('No username entered; nothing was saved.'); + const password = await promptSecret('Password: '); + if (!password) throw new UsageError('No password entered; nothing was saved.'); + return { credentials: { [method.usernameKey]: username, [method.passwordKey]: password } }; + } + const { token, clientId, refreshUrl } = await runOauthFlow(method, baseUrl, logo); + return { + credentials: { [method.clientKey]: token.accessToken }, + oauth: { [method.clientKey]: oauthMetadata(token, refreshUrl, clientId, undefined) }, + }; +}; + +const oauthMetadata = ( + token: TokenResponse, + refreshUrl: string, + clientId: string | undefined, + previousRefreshToken: string | undefined, +): StoredOAuth => { + // A refresh response may legitimately omit `refresh_token`, which means "keep using the one you + // have" rather than "you no longer have one"; dropping it would turn every refresh into the last. + const refreshToken = token.refreshToken ?? previousRefreshToken; + // `expires_in` is OPTIONAL in RFC 6749 §5.1, and an absent one used to mean no `expiresAt`, which + // `needsRefresh` reads as "never expires" — so a provider that omits it left the refresh token + // stored and never used, and the user re-ran the whole flow on every expiry. When there is + // something to refresh with, an unstated lifetime is assumed to be the hour nearly every provider + // issues; refreshing earlier than necessary costs one request, never refreshing costs the feature. + const lifetime = token.expiresIn ?? (refreshToken ? ASSUMED_TOKEN_LIFETIME_S : undefined); + return { + ...(refreshToken ? { refreshToken } : {}), + ...(lifetime === undefined ? {} : { expiresAt: Date.now() + lifetime * 1000 }), + refreshUrl, + ...(clientId ? { clientId } : {}), + }; +}; + +/** The access token an OAuth grant produced, plus what is needed to renew it. */ +type TokenResponse = { + readonly accessToken: string; + readonly refreshToken?: string; + /** Lifetime in seconds, as the token endpoint reported it. */ + readonly expiresIn?: number; + /** OpenID Connect ID token, when the endpoint returned one; checked by the OIDC flow, never stored. */ + readonly idToken?: string; +}; + +const runOauthFlow = async ( + method: Extract, + baseUrl: string, + logo: string | undefined, +): Promise => { + if (method.grant === 'authorizationCode') { + const result = await authorizationCodeFlow(method, baseUrl, logo); + return { ...result, refreshUrl: method.refreshUrl }; + } + if (method.grant === 'openIdConnect') return openIdConnectFlow(method, baseUrl, logo); + if (method.grant === 'deviceAuthorization') return deviceAuthorizationFlow(method, baseUrl); + if (method.grant === 'clientCredentials') { + const clientId = method.clientId ?? (await promptLine('Client id: ')); + if (!clientId) throw new UsageError('No client id entered; nothing was saved.'); + const clientSecret = await promptSecret('Client secret: '); + if (!clientSecret) throw new UsageError('No client secret entered; nothing was saved.'); + const token = await exchangeToken(method.tokenUrl, baseUrl, { + grant_type: 'client_credentials', + client_id: clientId, + client_secret: clientSecret, + ...scopeParam(method.scopes), + }); + return { token, clientId, refreshUrl: method.refreshUrl }; + } + // Asked for the same way the client-credentials grant above asks, so a provider that requires a + // client id on this grant — which a public client's does — can be signed into without the value + // having to be baked into the generated source. It stays optional, unlike there: RFC 6749 lets a + // client that authenticates some other way leave it out, so a blank answer omits the parameter + // rather than sending an empty one, and the prompt says which it is. + const clientId = method.clientId ?? (await promptLine('Client id (optional): ')); + const username = await promptLine('Username: '); + if (!username) throw new UsageError('No username entered; nothing was saved.'); + const password = await promptSecret('Password: '); + if (!password) throw new UsageError('No password entered; nothing was saved.'); + const token = await exchangeToken(method.tokenUrl, baseUrl, { + grant_type: 'password', + username, + password, + ...(clientId ? { client_id: clientId } : {}), + ...scopeParam(method.scopes), + }); + // Persisted so the refresh exchange can send the same client id the grant was made with; a blank + // answer stores nothing, which is what an absent one already did. + return { token, clientId: clientId || undefined, refreshUrl: method.refreshUrl }; +}; + +/** OAuth result plus the endpoint needed later to refresh a persisted token. */ +type OAuthFlowResult = { + readonly token: TokenResponse; + readonly clientId: string | undefined; + readonly refreshUrl: string; +}; + +/** + * Runs the Device Authorization Grant without needing a loopback listener or a local browser. + * + * The device code is never printed: it is the credential that the token endpoint accepts. The + * user code, by contrast, is deliberately meant to be entered in a browser and is shown beside the + * verification URL. Polling follows RFC 8628's ‘authorization_pending’ and ‘slow_down’ signals + * instead of treating them as failed sign-ins. + */ +const deviceAuthorizationFlow = async ( + method: Extract, + baseUrl: string, +): Promise => { + const clientId = method.clientId ?? (await promptLine('Client id: ')); + if (!clientId) throw new UsageError('No client id entered; nothing was saved.'); + const device = await requestDeviceAuthorization( + method.deviceAuthorizationUrl, + baseUrl, + clientId, + method.scopes, + ); + processStderr.write( + 'To sign in, visit:\n\n ' + + device.verificationUrl + + (device.userCode ? '\n\nThen enter code: ' + safeText(device.userCode) : '') + + '\n\nWaiting for authorization...\n', + ); + return { + token: await pollDeviceToken(method.tokenUrl, baseUrl, clientId, device), + clientId, + refreshUrl: method.refreshUrl, + }; +}; + +/** + * Discovers OpenID Connect endpoints, then uses the same PKCE flow as a declared authorization code + * grant — holding the redirect's `iss` and the ID token to the issuer discovery verified. + */ +const openIdConnectFlow = async ( + method: Extract, + baseUrl: string, + logo: string | undefined, +): Promise => { + const endpoints = await discoverOpenIdConnect(method.discoveryUrl, baseUrl); + // A configured issuer is the publisher saying which provider this CLI signs in with. Discovery + // only proves the document agrees with the URL it came from, and that URL is spec-derived, so a + // document that points somewhere else is refused here rather than trusted for the rest of the flow. + if (method.issuer !== undefined && !sameIssuer(endpoints.issuer, method.issuer)) { + throw new Error( + 'The OpenID Connect discovery document is issued by ' + + safeText(endpoints.issuer) + + ', not the configured issuer ' + + safeText(method.issuer) + + '.', + ); + } + // Same randomness as `state`. `state` ties the redirect to this process; the nonce ties the ID + // token to it, so a token minted for some other sign-in cannot be replayed into this one. + const nonce = base64Url(randomBytes(16)); + const result = await authorizationCodeFlow( + { + ...method, + grant: 'authorizationCode', + authorizationUrl: endpoints.authorizationUrl, + tokenUrl: endpoints.tokenUrl, + refreshUrl: endpoints.tokenUrl, + issuer: endpoints.issuer, + }, + baseUrl, + logo, + { + // Discovery always yields an issuer to compare a received `iss` with, but only a configured + // issuer or the provider's own advertisement makes a *missing* one a failure: RFC 9207 lets a + // server that never promised the parameter leave it out. + issRequired: method.issuer !== undefined || endpoints.issParameterSupported, + nonce, + // Run inside the code flow, while the browser is still waiting on its page, so a refused ID + // token is what the browser is told about too. It also runs before `login` writes anything: + // a token that fails is not stored, and neither is the access token that arrived beside it. + verify: (token) => { + if (token.idToken !== undefined) { + verifyIdToken(token.idToken, { issuer: endpoints.issuer, clientId: method.clientId, nonce }); + } + }, + }, + ); + return { ...result, refreshUrl: endpoints.tokenUrl }; +}; + +/** + * Checks the claims of an ID token the token endpoint returned, per OpenID Connect Core 3.1.3.7. + * + * The signature is deliberately not verified. Section 3.1.3.7 lets TLS server validation stand in + * for it when the token is received directly from the token endpoint — which is the only way this + * flow receives one: `exchangeToken` refuses a plain-HTTP endpoint off loopback and follows no + * redirects. Verifying it anyway would mean fetching and caching the provider's JWKS and + * implementing each signing algorithm, in a runtime that ships with no dependencies. The claims are + * what bind the token to this sign-in, and those are checked in full. + */ +const verifyIdToken = ( + idToken: string, + expected: { readonly issuer: string; readonly clientId: string; readonly nonce: string }, +): void => { + const claims = idTokenClaims(idToken); + const issuer = claims['iss']; + if (typeof issuer !== 'string' || !sameIssuer(issuer, expected.issuer)) { + throw new Error( + 'The ID token was issued by ' + + (typeof issuer === 'string' ? safeText(issuer) || 'an empty issuer' : 'no issuer') + + ', but this sign-in expected ' + + safeText(expected.issuer) + + '.', + ); + } + const audience = claims['aud']; + const audiences = typeof audience === 'string' ? [audience] : Array.isArray(audience) ? audience : []; + if (!audiences.includes(expected.clientId)) { + throw new Error('The ID token is not addressed to this client (' + safeText(expected.clientId) + ').'); + } + // With more than one audience, `azp` names the party the token was actually issued to; without + // it, a token minted for another client that merely lists this one could be passed off as ours. + // A present `azp` is held to the client id even for a single audience, as the spec recommends. + const party = claims['azp']; + if ((audiences.length > 1 || party !== undefined) && party !== expected.clientId) { + throw new Error( + party === undefined + ? 'The ID token lists several audiences but names no authorized party (azp).' + : 'The ID token was issued to another authorized party (azp) than this client.', + ); + } + const expiry = claims['exp']; + if (typeof expiry !== 'number' || !Number.isFinite(expiry)) { + throw new Error('The ID token has no usable expiry.'); + } + if (expiry * 1000 + ID_TOKEN_CLOCK_SKEW_MS <= Date.now()) { + throw new Error('The ID token has expired.'); + } + const nonce = claims['nonce']; + if (typeof nonce !== 'string' || !sameToken(nonce, expected.nonce)) { + throw new Error( + 'The ID token does not carry the nonce this sign-in sent, so it may belong to another sign-in.', + ); + } +}; + +/** + * Decodes the payload of a compact-serialized JWT into its claims, refusing anything malformed. + * + * Strict about the alphabet because Node's base64url decoder is not: it skips characters it does + * not recognise, so a mangled segment would otherwise decode into *something* rather than fail. + */ +const idTokenClaims = (idToken: string): Record => { + const malformed = new Error('The token endpoint returned a malformed ID token.'); + const segments = idToken.split('.'); + const payload = segments[1]; + if (segments.length !== 3 || !payload || !/^[A-Za-z0-9_-]+$/u.test(payload)) throw malformed; + let claims: unknown; + try { + claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); + } catch { + throw malformed; + } + if (!claims || typeof claims !== 'object' || Array.isArray(claims)) throw malformed; + return claims as Record; +}; + +const scopeParam = (scopes: readonly string[]): Record => + scopes.length > 0 ? { scope: scopes.join(' ') } : {}; + +type DeviceAuthorization = { + readonly deviceCode: string; + readonly verificationUrl: string; + readonly userCode?: string; + readonly expiresAt: number; + readonly intervalMs: number; +}; + +/** Starts a device grant and validates the response before anything is printed to the terminal. */ +const requestDeviceAuthorization = async ( + endpoint: string, + baseUrl: string, + clientId: string, + scopes: readonly string[], +): Promise => { + const url = requireSecureUrl(endpoint, baseUrl, 'device authorization endpoint'); + const { response, text } = await postForToken(url, { client_id: clientId, ...scopeParam(scopes) }); + const payload = parseJson(text); + if (!response.ok) { + throw new Error( + 'The device authorization endpoint rejected the request' + tokenErrorDetail(payload, response.status), + ); + } + const deviceCode = payload?.['device_code']; + const verificationUri = payload?.['verification_uri_complete'] ?? payload?.['verification_uri']; + const userCode = payload?.['user_code']; + const expiresIn = payload?.['expires_in']; + const interval = payload?.['interval']; + if ( + typeof deviceCode !== 'string' || + !deviceCode || + typeof verificationUri !== 'string' || + !verificationUri + ) { + throw new Error('The device authorization endpoint returned an incomplete response.'); + } + if (typeof expiresIn !== 'number' || !Number.isFinite(expiresIn) || expiresIn <= 0) { + throw new Error('The device authorization endpoint returned no usable expiry.'); + } + const verificationUrl = requireSecureUrl(verificationUri, baseUrl, 'device verification URL').toString(); + return { + deviceCode, + verificationUrl, + ...(typeof userCode === 'string' && userCode ? { userCode } : {}), + // Capped at this CLI's own ceiling rather than taken as given. `expires_in` comes from a + // spec-derived endpoint, so a hostile or simply wrong one answering `1e12` would make the only + // loop guard in `pollDeviceToken` unreachable and leave `login` waiting with no way out but + // Ctrl-C — the browser grant is bounded by `BROWSER_FLOW_TIMEOUT_MS` and this is its equal. + expiresAt: Date.now() + Math.min(expiresIn * 1000, DEVICE_FLOW_TIMEOUT_MS), + // RFC 8628 defaults to five seconds. A server may shorten it — a local authorization server + // answering instantly is the case that matters — but never below `DEVICE_POLL_FLOOR_MS`, and a + // negative or non-finite value never shortens it at all. Honouring a literal zero polled with no + // delay for the whole of `expires_in`: a request flood against someone else's token endpoint and + // a pinned core here, for a grant that answers `authorization_pending` until the user acts. + intervalMs: + typeof interval === 'number' && Number.isFinite(interval) && interval >= 0 + ? Math.max(interval * 1000, DEVICE_POLL_FLOOR_MS) + : 5000, + }; +}; + +/** Polls the token endpoint until the user authorizes, declines, or the device code expires. */ +const pollDeviceToken = async ( + endpoint: string, + baseUrl: string, + clientId: string, + device: DeviceAuthorization, +): Promise => { + const url = requireSecureUrl(endpoint, baseUrl, 'token endpoint'); + let intervalMs = device.intervalMs; + while (Date.now() < device.expiresAt) { + await waitFor(Math.min(intervalMs, Math.max(0, device.expiresAt - Date.now()))); + if (Date.now() >= device.expiresAt) break; + const { response, text } = await postForToken(url, { + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + device_code: device.deviceCode, + client_id: clientId, + }); + const payload = parseJson(text); + if (response.ok) return tokenResponse(payload); + const error = payload?.['error']; + if (error === 'authorization_pending') continue; + if (error === 'slow_down') { + intervalMs += 5000; + continue; + } + if (error === 'expired_token') throw new Error('The device code expired before authorization completed.'); + if (error === 'access_denied') throw new Error('The device authorization request was denied.'); + throw new Error( + 'The token endpoint rejected the device code' + tokenErrorDetail(payload, response.status), + ); + } + throw new Error('The device code expired before authorization completed.'); +}; + +/** Waits between device-code polls without blocking the event loop that owns terminal I/O. */ +const waitFor = (milliseconds: number): Promise => + new Promise((resolve) => setTimeout(resolve, milliseconds)); + +/** The suffix OpenID Connect Discovery appends to an issuer to reach its configuration document. */ +const OPENID_CONFIGURATION_PATH = '/.well-known/openid-configuration'; + +/** + * The issuer a discovery URL was built from, per OpenID Connect Discovery section 4. + * + * The spec builds the URL by appending the well-known path to the issuer, so removing it again is + * what the returned `issuer` has to match. A URL spelled any other way — a document naming a + * configuration endpoint that is not derived from its issuer at all — yields nothing, and the + * caller falls back to comparing origins, which still pins the answer to the host that was asked. + */ +const discoveryIssuer = (url: URL): string | undefined => + url.pathname.endsWith(OPENID_CONFIGURATION_PATH) + ? url.origin + url.pathname.slice(0, -OPENID_CONFIGURATION_PATH.length) + : undefined; + +/** Compares two issuer spellings, where a trailing slash is the one difference that is not one. */ +const sameIssuer = (left: string, right: string): boolean => + left.replace(/\/+$/u, '') === right.replace(/\/+$/u, ''); + +/** + * Fetches the OpenID Provider configuration and keeps what the browser flow needs: the two + * endpoints PKCE uses, the issuer the document was verified to come from, and whether the provider + * promises RFC 9207's `iss` on its redirects. + */ +const discoverOpenIdConnect = async ( + discoveryUrl: string, + baseUrl: string, +): Promise<{ + readonly authorizationUrl: string; + readonly tokenUrl: string; + readonly issuer: string; + readonly issParameterSupported: boolean; +}> => { + const url = requireSecureUrl(discoveryUrl, baseUrl, 'OpenID Connect discovery document'); + const { response, text } = await getJson(url, 'OpenID Connect discovery document'); + const payload = parseJson(text); + if (!response.ok) { + throw new Error( + 'The OpenID Connect discovery document could not be read' + tokenErrorDetail(payload, response.status), + ); + } + // Section 4.3 requires the returned issuer to be the one the URL was built from. It is the check + // that keeps a provider from naming somebody else's endpoints: without it this grant sends the + // user's browser, and then the authorization code, wherever an untrusted document says. + const issuer = payload?.['issuer']; + if (typeof issuer !== 'string' || !issuer) { + throw new Error('The OpenID Connect discovery document named no issuer.'); + } + const expected = discoveryIssuer(url); + const issuerUrl = requireSecureUrl(issuer, '', 'OpenID Connect issuer'); + const matches = expected === undefined ? issuerUrl.origin === url.origin : sameIssuer(issuer, expected); + if (!matches) { + throw new Error( + 'The OpenID Connect discovery document is issued by ' + + safeText(issuer) + + ', which is not the provider it was fetched from.', + ); + } + const authorizationUrl = payload?.['authorization_endpoint']; + const tokenUrl = payload?.['token_endpoint']; + if (typeof authorizationUrl !== 'string' || typeof tokenUrl !== 'string') { + throw new Error( + 'The OpenID Connect discovery document returned no usable authorization and token endpoints.', + ); + } + // Resolved against the issuer, never the API base URL: a relative `/authorize` belongs to the + // provider that answered, and resolving it against the API would point the browser — and the code + // exchange after it — at the API host instead. + return { + authorizationUrl: requireSecureUrl( + authorizationUrl, + issuerUrl.toString(), + 'OpenID Connect authorization endpoint', + ).toString(), + tokenUrl: requireSecureUrl(tokenUrl, issuerUrl.toString(), 'OpenID Connect token endpoint').toString(), + issuer, + issParameterSupported: payload?.['authorization_response_iss_parameter_supported'] === true, + }; +}; + +/** + * The browser sign-in: PKCE authorization code against a loopback redirect (RFC 8252). + * + * The CLI is a public client, so it holds no secret; PKCE is what stops an intercepted code from + * being redeemed by anything but this process. The redirect listens on 127.0.0.1 rather than a + * public interface, and the authorization URL is printed before the browser is opened so a headless + * or remote shell can still complete the flow by hand. + * + * `options.issRequired` makes an `iss`-less redirect a failure; it defaults to whether an issuer + * is known at all, which for this grant means one was configured. `options.nonce` is sent on the + * authorize request, and only OpenID Connect sends one, since only it returns an ID token to hold + * the nonce to. `options.verify` checks the exchanged token and throws to refuse it. + * + * The browser's request for the redirect is answered only after the code exchange and `verify`, + * so it never shows "Signed in" for a token this flow goes on to refuse. (Saving the credential + * comes later, in `runLogin`; a store that then refuses the write is reported in the terminal.) + * The wait is bounded by the token request's own timeout. Afterwards no redirect is held any more, + * and the listener is closed only once every answered response has finished sending — or its + * browser has gone — or `RESPONSE_FLUSH_TIMEOUT_MS` has passed. + */ +const authorizationCodeFlow = async ( + method: Extract, + baseUrl: string, + logo: string | undefined, + options: { + readonly issRequired?: boolean; + readonly nonce?: string; + readonly verify?: (token: TokenResponse) => void; + } = {}, +): Promise<{ readonly token: TokenResponse; readonly clientId: string | undefined }> => { + const clientId = method.clientId; + if (!clientId || !method.authorizationUrl) throw new Error('This flow needs a configured OAuth client id.'); + const authorizeUrl = requireSecureUrl(method.authorizationUrl, baseUrl, 'authorization endpoint'); + const verifier = base64Url(randomBytes(32)); + const challenge = base64Url(createHash('sha256').update(verifier).digest()); + const state = base64Url(randomBytes(16)); + + const server = createServer(); + // Bound first, and only then given its request handler. Registering the handler earlier meant a + // bind failure (a configured `redirectPort` already in use) rejected two promises while only + // `listen`'s was ever awaited, and the orphan took the process down with an unhandled rejection — + // losing the friendly message and the auth exit status. Nothing can reach the port in between: + // the browser has not been opened yet. + // A configured port that is already taken is the one bind failure a user can act on, and Node's + // raw `EADDRINUSE` names neither the setting that chose it nor the alternative. Port 0 lets the + // OS pick, so it cannot reach this. + const port = await listen(server, method.redirectPort ?? 0).catch((error: unknown) => { + const code = error && typeof error === 'object' && 'code' in error ? error.code : undefined; + if (code === 'EADDRINUSE' && method.redirectPort) { + throw new Error( + 'Port ' + + String(method.redirectPort) + + ' is already in use, so the sign-in redirect could not be received. Free it, or remove the configured redirect port to let the operating system choose one.', + ); + } + throw error; + }); + const listener: RedirectListener = { answered: [], closing: false }; + const redirect = awaitRedirect( + server, + state, + REDIRECT_PATH, + logo, + { expected: method.issuer, required: options.issRequired ?? method.issuer !== undefined }, + listener, + ); + const redirectUri = 'http://127.0.0.1:' + String(port) + REDIRECT_PATH; + try { + authorizeUrl.searchParams.set('response_type', 'code'); + authorizeUrl.searchParams.set('client_id', clientId); + authorizeUrl.searchParams.set('redirect_uri', redirectUri); + authorizeUrl.searchParams.set('state', state); + authorizeUrl.searchParams.set('code_challenge', challenge); + authorizeUrl.searchParams.set('code_challenge_method', 'S256'); + if (options.nonce !== undefined) authorizeUrl.searchParams.set('nonce', options.nonce); + if (method.scopes.length > 0) authorizeUrl.searchParams.set('scope', method.scopes.join(' ')); + const target = authorizeUrl.toString(); + processStderr.write( + 'Opening your browser to sign in. If it does not open, visit:\n\n ' + target + '\n\n', + ); + openBrowser(target); + const { code, respond } = await withTimeout( + redirect, + BROWSER_FLOW_TIMEOUT_MS, + 'Timed out waiting for the browser redirect.', + ); + // `respond` answers the held browser request once and ignores later calls, so the `finally` + // below can answer it with a failure on any path that got here without answering it. + try { + let token: TokenResponse; + try { + token = await exchangeToken(method.tokenUrl, baseUrl, { + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri, + client_id: clientId, + code_verifier: verifier, + }); + } catch (error) { + respond( + 'Sign-in failed', + 'Your provider did not issue a credential. Return to your terminal for details.', + false, + ); + throw error; + } + options.verify?.(token); + respond('Signed in', 'You can close this window and return to your terminal.', true); + return { token, clientId }; + } finally { + respond( + 'Sign-in could not be verified', + 'The credential your provider issued did not match this sign-in. Return to your terminal for details.', + false, + ); + } + } finally { + // From here on a redirect is answered at once with the neutral page and never held, so nothing + // that arrives during the wait below is left for nobody to answer — a redirect landing after the + // browser wait timed out included. + listener.closing = true; + // Answered pages are waited for before any socket is destroyed: `closeAllConnections` cancels + // writes still queued on a socket, and a page carrying a large publisher logo takes more than one + // write to send, so closing straight after `end()` could leave the browser a blank or truncated + // page. Bounded, so a browser that stops reading cannot hold the terminal. + // + // `server.close()` comes after the wait, not before it: since Node 19 it also closes every + // connection with no request in progress, and a connection whose response has been ended but not + // yet flushed counts as one — so closing first cut off exactly the pages this wait protects. + await drainAnswered(listener.answered, RESPONSE_FLUSH_TIMEOUT_MS); + server.close(); + // Belt and braces with the `connection: close` above: a connection opened but never used (a + // browser preconnecting to the redirect host) is not covered by that header and would keep the + // listener, and so the process, alive. + server.closeAllConnections(); + } +}; + +/** What the browser flow shares with its loopback request handler. */ +type RedirectListener = { + /** One entry per response written, settling once it has been sent or its browser has gone. */ + readonly answered: Promise[]; + /** Set when the flow is finishing: every later redirect gets the neutral page at once. */ + closing: boolean; +}; + +/** + * Waits, up to `ms` in all, for every answered response — including any answered while waiting. + * + * Re-reads the list after each batch because the listener keeps accepting until it is closed, and a + * response written during the wait is just as liable to be cut off as one written before it. + */ +const drainAnswered = async (answered: readonly Promise[], ms: number): Promise => { + const deadline = Date.now() + ms; + let waited = 0; + while (waited < answered.length && Date.now() < deadline) { + const batch = answered.slice(waited); + waited = answered.length; + await settledWithin(Promise.all(batch), deadline - Date.now()); + } +}; + +/** + * Resolves once `promise` settles or `ms` have passed, whichever is first, and never rejects. + * + * The timer is cleared when the promise wins, so a finished wait leaves nothing pending that would + * keep the process alive. + */ +const settledWithin = (promise: Promise, ms: number): Promise => + new Promise((resolve) => { + const timer = setTimeout(resolve, ms); + const done = (): void => { + clearTimeout(timer); + resolve(); + }; + promise.then(done, done); + }); + +/** + * The style for the one page a generated CLI ever puts in front of a browser. + * + * Laid out after the Scalar dashboard's own sign-in confirmation: the publisher's logo centred a + * little above the fold, then a one-line heading with its icon, then a muted line of detail — no + * card, no accent colour. Every colour is a token on `:root` so the palette can be restyled in one + * block. + * + * The font stack names Inter first and then falls back to the system UI face. Naming it is free + * where it is already installed and costs nothing where it is not — what it must never do is + * *fetch* it, for the same reason the rest of this page is inline. + */ +const PAGE_STYLE = + // Values taken from the Scalar dashboard's own theme: `--scalar-background-1` and + // `--scalar-color-1/2`, per mode. + // + // `light-dark()` rather than a `prefers-color-scheme` block, so each token states its light and + // dark value together and the palette can be read as pairs. It resolves against the + // `color-scheme` declared on the same rule, which is why that comes first. + // + // Where the function is not understood (before Firefox 120, Chrome 123, Safari 17.5) every token + // is invalid at computed-value time and the properties using them fall back to their initial + // values — black on white. Plain, still legible, and still saying the sign-in worked, which is the + // page's whole job. + ':root{color-scheme:light dark;' + + '--bg:light-dark(#fff,#0f0f0f);' + + '--fg:light-dark(#1b1b1b,#e7e7e7);' + + '--muted:light-dark(#757575,#a4a4a4)}' + + '*{box-sizing:border-box}' + + // The dashboard's auth layout: content starts 15% of the way down the viewport in a 380px column. + 'body{margin:0;padding:15vh 48px 48px;background:var(--bg);color:var(--fg);' + + // Mirrors `--scalar-font`, which also leads with Inter and falls back to the system UI face. + 'font:14px/1.5 Inter,ui-sans-serif,system-ui,sans-serif;-webkit-font-smoothing:antialiased}' + + 'main{max-width:380px;margin:0 auto;display:flex;flex-direction:column;align-items:center;gap:8px;text-align:center}' + + // 32px tall like the dashboard's mark, but width left to the image: a publisher's logo is as + // often a wordmark as a square icon. The 16px margin plus the 8px gap is the dashboard's 24px. + '.logo{display:block;height:32px;max-width:100%;margin-bottom:16px;object-fit:contain}' + + // `text-lg` at regular weight, as the dashboard sets it, with a 20px icon beside it. + 'h1{margin:0;display:flex;align-items:center;justify-content:center;gap:8px;font-size:16px;font-weight:400;line-height:24px}' + + 'h1 svg{flex:none;width:20px;height:20px}' + + // `text-sm` in `--scalar-color-2`. + 'p{margin:0;color:var(--muted);font-size:13px;line-height:20px}'; + +/** + * The page's two marks, as Phosphor's light-weight `sign-in` and `warning` glyphs on a 256 grid — + * the icons the dashboard's own confirmation and failure screens use. + * + * Phosphor Icons, Copyright (c) 2020 Phosphor Icons, MIT License (https://github.com/phosphor-icons/core). + */ +const SIGNED_IN_ICON = + 'M140.24,132.24l-40,40a6,6,0,0,1-8.48-8.48L121.51,134H24a6,6,0,0,1,0-12h97.51L91.76,92.24a6,6,0,0,1,8.48-8.48l40,40A6,6,0,0,1,140.24,132.24ZM200,34H136a6,6,0,0,0,0,12h58V210H136a6,6,0,0,0,0,12h64a6,6,0,0,0,6-6V40A6,6,0,0,0,200,34Z'; +const WARNING_ICON = + 'M235.07,189.09,147.61,37.22h0a22.75,22.75,0,0,0-39.22,0L20.93,189.09a21.53,21.53,0,0,0,0,21.72A22.35,22.35,0,0,0,40.55,222h174.9a22.35,22.35,0,0,0,19.6-11.19A21.53,21.53,0,0,0,235.07,189.09ZM224.66,204.8a10.46,10.46,0,0,1-9.21,5.2H40.55a10.46,10.46,0,0,1-9.21-5.2,9.51,9.51,0,0,1,0-9.72L118.79,43.21a10.75,10.75,0,0,1,18.42,0l87.46,151.87A9.51,9.51,0,0,1,224.66,204.8ZM122,144V104a6,6,0,0,1,12,0v40a6,6,0,0,1-12,0Zm16,36a10,10,0,1,1-10-10A10,10,0,0,1,138,180Z'; + +/** + * Escapes a value for a double-quoted HTML attribute. + * + * The logo arrives through the generator's own filter, which admits only an `https` URL or an image + * `data:` URI, but an SVG `data:` URI can legally carry quotes and angle brackets. Escaping here is + * lossless — the parser decodes the entities back before the URL is read — so the attribute cannot + * be closed early whatever the filter let through. + */ +const escapeAttribute = (value: string): string => + value.replace(/&/gu, '&').replace(/"/gu, '"').replace(//gu, '>'); + +/** + * The tab icon: the publisher's logo when one is configured, otherwise the page's own mark. + * + * Always declared, so the browser never requests `/favicon.ico` from the loopback listener — which + * would otherwise answer a 404 to a request it only receives because the page declared no icon. + * + * The fallback is deliberately a status mark and not a vendor logo: this page is served by every + * generated CLI, so a default icon identifying the SDK's *generator* would put one company's + * branding on another's sign-in. + * + * The fallback's colours are a green and a red that read against a light and a dark tab strip + * alike; `light-dark()` is no use here because the SVG is a separate document with no + * `color-scheme` of its own. Only `#`, `<` and `>` are escaped, which is all a data URI in an + * attribute needs, and the SVG quotes its attributes with apostrophes so the `href` can keep the + * double quotes. + */ +const favicon = (icon: string, ok: boolean, logo: string | undefined): string => { + if (logo) return ''; + const svg = + ""; + return ( + '' + ); +}; + +/** + * Renders the redirect page. + * + * Inlined down to the icon, with nothing fetched but a configured logo: the listener answers on + * loopback, where the machine may well have no route out at all, and a callback page that reached + * for a stylesheet or a font would also tell whoever served it that a sign-in had just happened. + * The logo is the one exception, because it is the publisher's own and the publisher's provider has + * just seen the sign-in anyway. It is sent with no referrer, so the host serving it never sees the + * loopback URL, and a `data:` URI keeps even that request from happening. A logo that fails to + * load has an empty `alt`, so it leaves nothing on the page rather than a broken-image box. + * + * Nothing the authorization server sent is interpolated here. The provider's own `error` goes to + * the terminal through `safeText` instead, which keeps untrusted text out of markup entirely rather + * than relying on this function to escape it. + */ +const redirectPage = (heading: string, detail: string, ok: boolean, logo: string | undefined): string => { + const icon = ok ? SIGNED_IN_ICON : WARNING_ICON; + return ( + '' + + '' + + '' + + favicon(icon, ok, logo) + + '' + + heading + + '
' + + (logo ? '' : '') + + '

' + + heading + + '

' + + detail + + '

' + ); +}; + +/** The code a redirect carried, and the one-shot answer to the browser request still waiting on it. */ +type HeldRedirect = { + readonly code: string; + readonly respond: (heading: string, detail: string, ok: boolean) => void; +}; + +/** + * Resolves with the authorization code the redirect carries, holding the browser's request open. + * + * A redirect that fails a check here is answered at once. One that passes is left waiting, and the + * caller answers it through `respond` once the rest of the sign-in has succeeded or failed. + * + * Anything that is not the redirect — a browser probing `/favicon.ico`, a stray request to the + * port — is answered 404 and ignored, so it cannot resolve or reject the wait. The `state` is + * compared in constant time and rejected on mismatch, which is what ties the redirect back to the + * request this process started; the `iss` is then held to `issuer` (see {@link redirectIssuerProblem}). + */ +const awaitRedirect = ( + server: ReturnType, + state: string, + path: string, + logo: string | undefined, + issuer: RedirectIssuer, + listener: RedirectListener, +): Promise => + new Promise((resolve, reject) => { + // Set once a redirect has resolved or rejected this wait. A later redirect — the user reloading + // the tab while the first is still held — can change nothing, but it still deserves an answer: + // left unanswered it would hang until the listener closed and then show an empty-response error. + let settled = false; + server.on('request', (request, response) => { + const url = new URL(request.url ?? '/', 'http://127.0.0.1'); + const params = url.searchParams; + // Listened for as soon as the request arrives, not when it is answered: a held redirect whose + // browser gives up while the code is exchanged (a reload, a closed tab) has already fired its + // `'close'` by the time it is answered, and a listener added then would never hear it — + // leaving the flow to wait out `RESPONSE_FLUSH_TIMEOUT_MS` for a page nobody will read. + const closed = new Promise((done) => response.once('close', () => done())); + const send = (status: number, type: string, body: string): void => { + // `connection: close` rather than the default keep-alive: `server.close()` waits for open + // connections, and a browser holding one would leave the CLI running after it had signed in. + response.writeHead(status, { 'content-type': type + '; charset=utf-8', connection: 'close' }); + listener.answered.push(closed); + response.end(body); + }; + const finish = (status: number, heading: string, detail: string, ok: boolean): void => { + send(status, 'text/html', redirectPage(heading, detail, ok, logo)); + }; + // The path is part of what was registered as `redirect_uri`, so anything else is not the + // redirect however it is decorated. Checked alongside the parameters rather than instead of + // them: both are cheap, and together they keep this handler from reading a request that was + // never the provider's as though it were. + if (url.pathname !== path || (!params.has('code') && !params.has('error'))) { + // Plain text, and deliberately not the page above: this answers a probe rather than a + // person — a browser asking for `/favicon.ico`, or anything else that finds the port. + send(404, 'text/plain', 'Not found.\n'); + return; + } + if (settled || listener.closing) { + finish( + 409, + 'Return to your terminal', + 'This sign-in has already been received. Your terminal shows how it ended.', + false, + ); + return; + } + settled = true; + if (!sameToken(params.get('state') ?? '', state)) { + // Ends the sign-in rather than waiting for a better redirect. A request that reaches here is + // on the registered path and carries a `code` or an `error`, which makes it the provider's + // redirect rather than a stray probe — the path check above is what turns those away — so a + // `state` that does not match is tampering or a stale tab, and neither is worth leaving the + // user watching a terminal for `BROWSER_FLOW_TIMEOUT_MS` on the chance that a second, + // better redirect arrives. + finish( + 400, + 'Sign-in could not be verified', + 'This page did not match the sign-in your terminal started. Return to your terminal and try again.', + false, + ); + reject(new Error('The browser redirect did not match this sign-in attempt.')); + return; + } + // Checked before the `error` branch as well as the code: RFC 9207 puts `iss` on error + // responses too, and an error page from a server that is not the expected one is no more its + // provider's word than a code is. + const issuerProblem = redirectIssuerProblem(params.get('iss'), issuer); + if (issuerProblem) { + finish( + 400, + 'Sign-in could not be verified', + 'This response did not come from the provider your terminal expected. Return to your terminal and try again.', + false, + ); + reject(new Error(issuerProblem)); + return; + } + const failure = params.get('error'); + if (failure) { + finish( + 400, + 'Sign-in failed', + 'Your provider declined the request. Return to your terminal for details.', + false, + ); + reject(new Error('Authorization failed: ' + safeText(failure) + '.')); + return; + } + const code = params.get('code'); + if (!code) { + finish( + 400, + 'Sign-in failed', + 'The redirect carried no authorization code. Return to your terminal and try again.', + false, + ); + reject(new Error('The browser redirect carried no authorization code.')); + return; + } + let responded = false; + resolve({ + code, + respond: (heading, detail, ok) => { + if (responded) return; + responded = true; + finish(ok ? 200 : 400, heading, detail, ok); + }, + }); + }); + server.on('error', reject); + }); + +/** The issuer a browser redirect is held to, and whether it must name one at all. */ +type RedirectIssuer = { readonly expected: string | undefined; readonly required: boolean }; + +/** + * Why a redirect's `iss` parameter (RFC 9207) is unacceptable, or `undefined` when it is fine. + * + * This is the mix-up-attack defence. `state` proves the redirect answers this process's request, + * but not which authorization server sent it: a CLI that trusts more than one provider can have its + * browser steered through an attacker's, and would then redeem the code that server handed back at + * the honest provider's token endpoint — or hand an honest code to the attacker's. Naming the issuer + * on the redirect, and refusing one that is not the server this sign-in began with, closes that + * before the code is exchanged anywhere. + * + * With no expected issuer — a plain OAuth scheme and none configured — a received `iss` is + * ignored: there is nothing to compare it with, so it can neither confirm nor refute the sender, + * and refusing it would break every provider that sends one to clients that never asked. + */ +const redirectIssuerProblem = (received: string | null, issuer: RedirectIssuer): string | undefined => { + if (issuer.expected === undefined) return undefined; + if (received === null) { + return issuer.required + ? 'The browser redirect did not say which provider sent it (no iss parameter), but this sign-in expected ' + + safeText(issuer.expected) + + '.' + : undefined; + } + if (sameIssuer(received, issuer.expected)) return undefined; + return ( + 'The browser redirect was sent by ' + + (safeText(received) || 'an empty issuer') + + ', but this sign-in expected ' + + safeText(issuer.expected) + + '.' + ); +}; + +/** Starts the loopback listener, resolving with the port the OS actually bound. */ +const listen = (server: ReturnType, port: number): Promise => + new Promise((resolve, reject) => { + server.once('error', reject); + // 127.0.0.1 rather than every interface: the redirect is for this machine's browser alone. + server.listen(port, '127.0.0.1', () => { + const address = server.address(); + if (address && typeof address === 'object') resolve(address.port); + else reject(new Error('Could not open a local port for the sign-in redirect.')); + }); + }); + +/** + * Opens a URL in the user's browser, best effort. + * + * A missing opener is not an error: the URL was printed first precisely so the flow still completes + * when this does nothing. + * + * The URL never reaches a shell. On Unix that is automatic — `open` and `xdg-open` are spawned + * directly. Windows is the trap: `cmd /c start` would make cmd itself the shell, and Node quotes an + * argument only when it contains a space, tab or quote, so every `&` in an authorization URL would + * arrive at cmd as a command separator. That breaks the flow even on a benign document (the browser + * opens on the first query parameter alone, with no `state` or `code_challenge`) and runs arbitrary + * programs on a hostile one, since `&` survives URL normalization inside the path. So Windows gets + * PowerShell with the URL in the environment, where nothing parses it as a command line at all. + */ +const openBrowser = (url: string): void => { + const windows = process.platform === 'win32'; + const [file, ...args] = windows + ? [windowsPowerShell(), '-NoProfile', '-NonInteractive', '-Command', '-'] + : process.platform === 'darwin' + ? ['open', url] + : ['xdg-open', url]; + try { + const child = spawn(file as string, args, { + stdio: windows ? ['pipe', 'ignore', 'ignore'] : 'ignore', + detached: !windows, + ...(windows ? { env: { ...process.env, SCALAR_BROWSER_URL: url } } : {}), + }); + child.on('error', () => {}); + if (windows) { + // `-FilePath` is explicit so a URL is never read as a PowerShell parameter, and the value comes + // from the environment so it is not part of any command line. + child.stdin?.end('Start-Process -FilePath $env:SCALAR_BROWSER_URL\n'); + } + child.unref(); + } catch { + // Ignored; see this function's doc comment. + } +}; + +/** + * Exchanges a grant for an access token. + * + * The endpoint comes from the OpenAPI document, which is untrusted input, so it is resolved against + * the base URL and required to be HTTPS before a secret is put in the request body. Only the + * standard `error`/`error_description` fields of a failure response are reported: a server that + * echoed the submitted secret back would otherwise have it printed to stderr and into any CI log. + */ +const exchangeToken = async ( + tokenUrl: string, + baseUrl: string, + body: Readonly>, + timeoutMs: number = TOKEN_REQUEST_TIMEOUT_MS, +): Promise => { + const url = requireSecureUrl(tokenUrl, baseUrl, 'token endpoint'); + const { response, text } = await postForToken(url, body, timeoutMs); + const payload = parseJson(text); + if (!response.ok) + throw new Error('The token endpoint rejected the request' + tokenErrorDetail(payload, response.status)); + return tokenResponse(payload); +}; + +/** Validates the standard OAuth token response fields before a token reaches the credential store. */ +const tokenResponse = (payload: Record | undefined): TokenResponse => { + const accessToken = payload?.['access_token']; + if (typeof accessToken !== 'string' || !accessToken) { + throw new Error('The token endpoint returned no access token.'); + } + const refreshToken = payload?.['refresh_token']; + const expiresIn = payload?.['expires_in']; + const idToken = payload?.['id_token']; + return { + accessToken, + ...(typeof refreshToken === 'string' && refreshToken ? { refreshToken } : {}), + // Kept as sent, even when empty or not a string, so the OIDC flow refuses a malformed one rather + // than treating it as absent. + ...(idToken === undefined || idToken === null ? {} : { idToken: String(idToken) }), + // A lifetime at or below zero is not a lifetime. Stored as one it makes `needsRefresh` true + // forever, so every command pays a full refresh round trip ahead of the request it wanted — + // on a token that is in fact perfectly good. The device grant already applies this floor to + // its own `expires_in`; treating an unusable value as unstated puts the two in step. + ...(typeof expiresIn === 'number' && Number.isFinite(expiresIn) && expiresIn > 0 ? { expiresIn } : {}), + }; +}; + +/** Fetches a bounded JSON document under the same timeout policy used for token endpoints. */ +const getJson = async ( + url: URL, + label: string, +): Promise<{ readonly response: Response; readonly text: string }> => { + try { + const response = await fetch(url, { + headers: { accept: 'application/json' }, + // Same reasoning as `postForToken`, for the document that *names* the endpoints rather than + // the exchange itself: only the URL handed to `fetch` was checked, so a spec-derived + // discovery endpoint that redirects would have its answer — the authorization and token URLs + // this whole grant is then pointed at — supplied by whichever origin it forwarded to. + redirect: 'error', + signal: AbortSignal.timeout(TOKEN_REQUEST_TIMEOUT_MS), + }); + return { response, text: await readBounded(response) }; + } catch (error) { + const name = error instanceof Error ? error.name : ''; + throw new Error( + name === 'TimeoutError' || name === 'AbortError' + ? 'The ' + label + ' did not answer within ' + String(TOKEN_REQUEST_TIMEOUT_MS / 1000) + ' seconds.' + : 'Could not reach the ' + label + '.', + ); + } +}; + +/** + * Posts the grant and reads the answer, both under one deadline. + * + * The read is inside the same `try` as the request because the deadline covers it too: a server + * that answers its headers and then stalls aborts here, and without this the user would see a raw + * `The operation was aborted due to timeout` instead of a sentence naming the endpoint. + * + * The endpoint is spec-derived, so its own error text is never repeated beyond whether the wait ran + * out: a `fetch` failure can carry the request, and the request carries the secret. + */ +const postForToken = async ( + url: URL, + body: Readonly>, + timeoutMs: number = TOKEN_REQUEST_TIMEOUT_MS, +): Promise<{ readonly response: Response; readonly text: string }> => { + try { + const response = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' }, + body: new URLSearchParams(body).toString(), + // A 307/308 preserves the POST body. Do not let a spec-derived endpoint forward a refresh + // token, authorization code, or device code to a different (possibly insecure) origin. + redirect: 'error', + signal: AbortSignal.timeout(timeoutMs), + }); + return { response, text: await readBounded(response) }; + } catch (error) { + // A deadline reached during the request is a `TimeoutError`; one reached while the body is + // still arriving surfaces as the abort itself. + const name = error instanceof Error ? error.name : ''; + throw new Error( + name === 'TimeoutError' || name === 'AbortError' + ? 'The token endpoint did not answer within ' + String(timeoutMs / 1000) + ' seconds.' + : 'Could not reach the token endpoint.', + ); + } +}; + +/** + * Reads at most {@link TOKEN_RESPONSE_LIMIT} characters of a response body. + * + * `response.text()` would buffer whatever the endpoint chooses to send before anything could cap + * it, so the stream is read in pieces and cancelled once there is more than enough to parse. + */ +const readBounded = async (response: Response): Promise => { + const body = response.body; + if (!body) return ''; + const reader = body.getReader(); + const decoder = new TextDecoder(); + let text = ''; + try { + while (text.length < TOKEN_RESPONSE_LIMIT) { + const { done, value } = await reader.read(); + if (done) break; + text += decoder.decode(value, { stream: true }); + } + } finally { + // Nothing is read after this point, and an abandoned body keeps the socket open. + void reader.cancel().catch(() => {}); + } + return text.slice(0, TOKEN_RESPONSE_LIMIT); +}; + +const parseJson = (text: string): Record | undefined => { + try { + const parsed: unknown = JSON.parse(text); + return parsed && typeof parsed === 'object' ? (parsed as Record) : undefined; + } catch { + return undefined; + } +}; + +const tokenErrorDetail = (payload: Record | undefined, status: number): string => { + const code = payload?.['error']; + const description = payload?.['error_description']; + const parts = [ + typeof code === 'string' ? code : undefined, + typeof description === 'string' ? description : undefined, + ] + .filter((part): part is string => !!part) + .map(safeText); + return parts.length > 0 ? ' (' + parts.join(': ') + ').' : ' with status ' + String(status) + '.'; +}; + +/** + * Resolves a spec-derived endpoint and refuses to send credentials to it in the clear. + * + * HTTPS is required, with the usual carve-out for loopback so a local development authorization + * server still works. Without this, a document naming an `http://` token endpoint would have the + * CLI post a client secret unencrypted to whatever host it named. + */ +const requireSecureUrl = (raw: string, baseUrl: string, label: string): URL => { + let url: URL; + try { + url = new URL(raw, baseUrl || undefined); + } catch { + throw new Error('The ' + label + ' is not a usable URL.'); + } + if (url.protocol === 'https:') return url; + if (url.protocol === 'http:' && LOOPBACK_HOSTS.has(url.hostname)) return url; + throw new Error( + 'Refusing to send credentials to a non-HTTPS ' + + label + + ' (' + + safeText(url.protocol + '//' + url.host) + + ').', + ); +}; + +/** + * Compares two opaque tokens without leaking their contents through timing. + * + * Lengths are compared first because `timingSafeEqual` throws on a mismatch; both values here are + * fixed-length base64url, so an unequal length is already a mismatch. + */ +const sameToken = (received: string, expected: string): boolean => { + const a = Buffer.from(received); + const b = Buffer.from(expected); + return a.length === b.length && timingSafeEqual(a, b); +}; + +const base64Url = (bytes: Buffer): string => bytes.toString('base64url'); + +/** + * Strips anything from a network-supplied string that a terminal would act on. + * + * An OAuth error message reaches stderr verbatim, and both the redirect query and the token + * endpoint's response body are written by whoever the document points at, so control characters + * (escape sequences that reposition the cursor or recolour the line) are removed and the result is + * capped rather than printed as received. + */ +const safeText = (value: string): string => { + // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping them is the point. + const stripped = value.replace(/[\u0000-\u001f\u007f-\u009f]/gu, ' ').trim(); + return stripped.length > 200 ? stripped.slice(0, 200) + '...' : stripped; +}; + +const withTimeout = (promise: Promise, ms: number, message: string): Promise => + new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(message)), ms); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error: unknown) => { + clearTimeout(timer); + reject(error instanceof Error ? error : new Error(String(error))); + }, + ); + }); + +/** + * A failure the user can fix by invoking the command differently, reported as a usage error. + * + * Distinguished from an authentication failure so a script can tell "you typed the wrong flow name" + * (retrying will not help) from "the credential was rejected" (a fresh sign-in might). + */ +export class UsageError extends Error {} + +const NO_TERMINAL = + 'Sign-in needs an interactive terminal. Run it from a terminal, pick a flow that needs no prompt with --flow (see --help), or set the credential through its flag or environment variable instead.'; + +/** Puts the terminal cursor back, for the picker's own exit paths and for a signal that skips them. */ +const restoreCursor = (): void => { + processStderr.write('\u001b[?25h'); +}; + +/** One row of the sign-in picker: what it reads as, and the `--flow` value that skips the prompt. */ +type Choice = { + readonly label: string; + readonly hint: string; +}; + +/** + * Puts a list of choices to the user and resolves the index of the one chosen. + * + * A highlight moved with the arrow keys rather than a number typed at a prompt, so the row being + * chosen is the row being read. Only the highlighted row carries its `--flow` value, which is what + * keeps a nine-line list from spelling out nine of them; the value is still on screen for whichever + * flow the user is about to pick, which is the one they would want to write down. + * + * Raw mode is what delivers a keystroke without Enter behind it, and is required rather than fallen + * back from, exactly as `promptSecret` requires it: a terminal Node reports as a TTY always offers + * it, so the alternative was a branch that could not be reached to be tested. A digit still jumps to + * its row, so the keys that answered the numbered prompt this replaces still answer this one. + * + * Drawn on stderr, like every other prompt, so stdout stays machine-readable. + */ +const promptChoice = async (heading: string, choices: readonly Choice[]): Promise => { + if (!processStdin.isTTY || typeof processStdin.setRawMode !== 'function') { + throw new UsageError(NO_TERMINAL); + } + return new Promise((resolve, reject) => { + let active = 0; + // How many rows the last draw wrote, which is how far back up the cursor has to go to redraw + // over them. Without it every keystroke would leave another copy of the list in the scrollback. + let drawn = 0; + const width = (): number => Number(processStderr.columns) || 80; + /** + * The slice of choices to draw, keeping the highlight inside it. + * + * A list taller than the terminal would scroll the region the redraw is about to move back over, + * putting every subsequent frame in the wrong place, so a long list is windowed instead. + */ + const window = (): readonly Choice[] => { + const room = Math.max(1, (Number(processStderr.rows) || 24) - 3); + if (choices.length <= room) return choices; + const start = Math.min(Math.max(0, active - (room >> 1)), choices.length - room); + return choices.slice(start, start + room); + }; + const render = (): void => { + if (drawn > 0) processStderr.write('\u001b[' + String(drawn) + 'A'); + const columns = width(); + const shown = window(); + const lines = [clip(heading, columns)]; + for (const choice of shown) { + const selected = choice === choices[active]; + const head = (selected ? '> ' : ' ') + choice.label; + const tail = ' (--flow ' + choice.hint + ')'; + const plain = clip(head + tail, columns); + // The flow name is kept on every row rather than only the highlighted one, because labels + // are not unique: an API key in a header, in a query parameter and in a cookie all read as + // `Enter your API key`, and the flow name is the only thing telling those rows apart. It is + // dimmed so the labels still read as the list, and dropped entirely from a row too narrow + // to hold it, where clipping has already eaten the closing bracket. + const row = plain === head + tail ? head + '\u001b[2m' + tail + '\u001b[22m' : plain; + lines.push(selected ? '\u001b[36m' + row + '\u001b[39m' : row); + } + const position = + shown.length < choices.length ? String(active + 1) + '/' + String(choices.length) + ', ' : ''; + lines.push(clip(' ' + position + 'arrows to move, enter to choose', columns)); + // Each row is cleared to its end before being rewritten: a shorter line would otherwise leave + // the tail of whatever longer one occupied that row in the frame before. + processStderr.write(lines.map((line) => '\u001b[2K' + line).join('\n') + '\n'); + drawn = lines.length; + }; + const move = (delta: number): void => { + active = (active + delta + choices.length) % choices.length; + render(); + }; + const jump = (index: number): void => { + if (index < 0 || index >= choices.length) return; + active = index; + render(); + }; + const onData = (chunk: string): void => { + let index = 0; + while (index < chunk.length) { + const character = chunk[index] ?? ''; + if (character === '\u001b') { + // An arrow is ESC [ A or ESC O A. The sequence is scanned to its final byte so that no + // part of it is read as a keystroke in its own right — the `[` of an Up arrow would + // otherwise be an ordinary printable character arriving between two frames. + let cursor = index + 1; + if (chunk[cursor] === '[' || chunk[cursor] === 'O') { + cursor += 1; + // A CSI sequence ends at its final byte, anything in the range `@` to `~`. + while (cursor < chunk.length) { + const byte = chunk[cursor] ?? ''; + if (byte >= '@' && byte <= '~') break; + cursor += 1; + } + const final = chunk[cursor] ?? ''; + if (final === 'A') move(-1); + else if (final === 'B') move(1); + else if (final === 'H') jump(0); + else if (final === 'F') jump(choices.length - 1); + index = cursor + 1; + continue; + } + index = cursor; + continue; + } + index += 1; + if (character === '\r' || character === '\n') { + finish(); + return; + } + // Ctrl-C and Ctrl-D: raw mode has taken the terminal's own handling away, so the cancel has + // to be honoured here or the picker could never be escaped. + if (character === '\u0003' || character === '\u0004') { + finish(new UsageError('Cancelled.')); + return; + } + // Ctrl-P and Ctrl-N beside the vim keys, so the three habits a terminal user might arrive + // with all work without anything on screen having to offer them. + if (character === 'k' || character === '\u0010') move(-1); + else if (character === 'j' || character === '\u000e') move(1); + else if (character >= '1' && character <= '9') jump(Number(character) - 1); + } + }; + const finish = (error?: Error): void => { + processStdin.off('data', onData); + processStdin.setRawMode(false); + processStdin.pause(); + // The list is wound back over and replaced by the single line saying what was chosen: a menu + // is worth reading while it is being answered and is clutter in the scrollback afterwards. + if (drawn > 0) processStderr.write('\u001b[' + String(drawn) + 'A\u001b[0J'); + process.off('exit', restoreCursor); + restoreCursor(); + if (error) { + reject(error); + return; + } + const chosen = choices[active]; + processStderr.write( + heading + ' ' + (chosen?.label ?? '') + ' (--flow ' + (chosen?.hint ?? '') + ')\n', + ); + resolve(active); + }; + // Hidden for the duration: the cursor would otherwise sit at the end of the last row drawn, + // blinking somewhere that has nothing to do with the highlight. Restoring it is registered with + // the hide rather than left to `finish`, because a signal that ends the process while the menu + // is up reaches neither the key handler nor the rejection paths, and an invisible cursor + // outlives the CLI it belonged to — the user's next shell prompt is where they find out. + process.on('exit', restoreCursor); + processStderr.write('\u001b[?25l'); + processStdin.setRawMode(true); + processStdin.resume(); + processStdin.setEncoding('utf8'); + processStdin.on('data', onData); + render(); + }); +}; + +/** Reads one visible line from the terminal. Prompts go to stderr so stdout stays machine-readable. */ +const promptLine = (label: string): Promise => + new Promise((resolve, reject) => { + if (!processStdin.isTTY) { + reject(new UsageError(NO_TERMINAL)); + return; + } + const rl = createInterface({ input: processStdin, output: processStderr }); + let answered = false; + // Closing the interface emits 'close' synchronously, so the guard is what keeps the end-of-input + // fallback from resolving ahead of the answer that was just typed. + rl.on('close', () => { + if (!answered) resolve(''); + }); + rl.question(label, (answer) => { + answered = true; + resolve(answer.trim()); + rl.close(); + }); + }); + +/** + * Reads one secret from the terminal without echoing it. + * + * Raw mode is entered directly rather than muting a readline interface: readline exposes no + * supported way to suppress its echo, and the credential would otherwise be left on screen and in + * any recorded terminal session. Nothing typed here is written back, so there is no cursor to + * manage — only the terminating newline is emitted. + */ +const promptSecret = (label: string): Promise => + new Promise((resolve, reject) => { + if (!processStdin.isTTY || typeof processStdin.setRawMode !== 'function') { + reject(new UsageError(NO_TERMINAL)); + return; + } + processStderr.write(label); + let value = ''; + // Where an escape sequence has got to: 0 outside one, 1 just after ESC, 2 inside CSI/SS3 + // parameters. Dropping the ESC alone is not enough — an Up arrow is ESC [ A, and the `[` and + // `A` are ordinary printable bytes that would otherwise land in the middle of a credential the + // user cannot see to correct. + let escape = 0; + const onData = (chunk: string): void => { + for (const character of chunk) { + if (escape === 1) { + escape = character === '[' || character === 'O' ? 2 : 0; + continue; + } + if (escape === 2) { + // A CSI sequence ends at its final byte, anything in the range `@` to `~`. + if (character >= '@' && character <= '~') escape = 0; + continue; + } + if (character === '\u001b') { + escape = 1; + continue; + } + if (character === '\r' || character === '\n') { + finish(); + return; + } + // Ctrl-C and Ctrl-D: raw mode has taken the terminal's own handling away, so the cancel has + // to be honoured here or the prompt could never be escaped. + if (character === '\u0003') { + finish(new UsageError('Cancelled.')); + return; + } + if (character === '\u0004') { + if (value) finish(); + else finish(new UsageError('Cancelled.')); + return; + } + if (character === '\u007f' || character === '\b') { + value = value.slice(0, -1); + continue; + } + // Any other control byte is dropped for the same reason. + if (character < ' ') continue; + value += character; + } + }; + const finish = (error?: Error): void => { + processStdin.off('data', onData); + processStdin.setRawMode(false); + processStdin.pause(); + processStderr.write('\n'); + if (error) reject(error); + else resolve(value); + }; + processStdin.setRawMode(true); + processStdin.resume(); + processStdin.setEncoding('utf8'); + processStdin.on('data', onData); + }); diff --git a/src/cli/runtime.ts b/src/cli/runtime.ts index 382788d..8867ba7 100644 --- a/src/cli/runtime.ts +++ b/src/cli/runtime.ts @@ -7,7 +7,24 @@ import as from 'ansis'; import { Command } from 'commander'; import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; +import { listRow } from './list'; import { encodeToon } from './toon.js'; +import { takeWarnings } from './credentials'; +import { type CliAuthDefinition, UsageError, runLogin, runLogout, storedCredentials } from './login'; + +const LOGIN_COMMAND = 'galaxy login'; + +// The sign-in command for the environment a request targeted. +const loginCommandFor = (environment: string | undefined): string => { + if (environment === undefined) return LOGIN_COMMAND; + const word = /^[A-Za-z0-9._-]+$/u.test(environment) ? environment : shellQuote(environment); + return LOGIN_COMMAND + ' --environment ' + word; +}; + +// Quotes one word for a POSIX shell so it reaches the command exactly as written. +// biome-ignore lint/suspicious/noControlCharactersInRegex: dropping control characters is the point. +const shellQuote = (value: string): string => + "'" + value.replace(/[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/gu, '').replace(/'/gu, "'\\''") + "'"; type OutputFormat = 'auto' | 'json' | 'jsonl' | 'pretty' | 'raw' | 'toon' | 'yaml'; @@ -36,6 +53,9 @@ export type CliFlagDefinition = { readonly itemKind?: CliValueKind; // Wire-property path under the parent param for dotted leaf flags (e.g. `--address.city`). readonly objectPath?: readonly string[]; + // For a positional, the `--` spelling it is also accepted under. Absent when the name is + // already taken, in which case `optionKey` belongs to whatever took it and must not be read. + readonly flagName?: string; }; export type CliCommandDefinition = { @@ -48,6 +68,7 @@ export type CliCommandDefinition = { readonly streaming?: 'sse' | 'jsonl'; readonly iterable: boolean; readonly callShape: 'options' | 'params' | 'body'; + readonly authClientKeyRequirements: readonly (readonly string[])[]; // Param key of a body blob that is forwarded bare or spread into params, depending on callShape. readonly bodyParamKey?: string; readonly positional: readonly CliFlagDefinition[]; @@ -73,6 +94,20 @@ export type CliClientOptionDefinition = { readonly defaultValue?: string; }; +export type CliEnvironment = { + readonly name: string; + readonly url: string; +}; + +// What `--environment` draws on: every configured environment plus the one a bare invocation +// targets. An empty list means neither the flag nor the `environments` command is registered. +type EnvironmentSelection = { + readonly environments: readonly CliEnvironment[]; + readonly defaultEnvironment: string | undefined; + readonly environmentEnv: string | undefined; + readonly baseUrlEnv: string | undefined; +}; + export type CreateProgramOptions = { readonly SDK: new (...args: any[]) => unknown; readonly binaryName: string; @@ -88,6 +123,19 @@ export type CreateProgramOptions = { // Completion script per shell, generated alongside the command table. Absent when the SDK // config disables shell completions, in which case no `completion` command is registered. readonly completions?: Readonly>; + // Named environments this API declares, in the order the embedded SDK resolves them. Absent + // when fewer than two are configured, in which case neither `--environment` nor the + // `environments` command is registered and the SDK's constant base URL is the only one. + readonly environments?: readonly CliEnvironment[]; + // The environment a bare invocation targets, which is also the one the embedded SDK falls back + // to. Carried rather than taken as `environments[0]` so both agree by construction. + readonly defaultEnvironment?: string; + // Environment variable that selects an environment the way the flag does, and the embedded + // SDK's own base-URL variable, which cannot be combined with it. Absent when the CLI offers no + // such variable, in which case only the flag selects an environment. + readonly environmentEnv?: string; + readonly baseUrlEnv?: string; + readonly auth?: CliAuthDefinition; }; type OutputOptions = { @@ -103,6 +151,7 @@ type OutputOptions = { type GlobalOptions = { readonly baseUrl?: string; + readonly environment?: string; readonly timeout?: string; readonly maxRetries?: string; readonly format?: OutputFormat; @@ -125,7 +174,13 @@ export const createProgram = ({ commands, groups, completions, + environments = [], + defaultEnvironment, + environmentEnv, + baseUrlEnv, + auth, }: CreateProgramOptions): Command => { + const selection: EnvironmentSelection = { environments, defaultEnvironment, environmentEnv, baseUrlEnv }; const program = usageExitCode(new Command()); program .enablePositionalOptions() @@ -147,15 +202,27 @@ export const createProgram = ({ .option('-r, --raw-output', 'Print transformed string values without JSON quotes') .option('--debug', 'Enable SDK debug logging'); + // Registered only for an API that declares more than one environment, because that is when the + // embedded SDK accepts an `environment` option at all. No Commander default is set: the SDK + // applies its own default, and a value here would look explicitly passed and trip the + // mutual-exclusion check below against a `--base-url` the user did pass on purpose. + if (environments.length > 0) { + program.option('--environment ', environmentFlagDescription(selection)); + } + // Register configured client options (auth credentials, org headers, etc.) as global flags. // Mirrored on each subcommand below so users can supply them either before or after the verb. for (const option of clientOptions) { program.option('--' + option.name + ' ', clientOptionDescription(option)); } - for (const definition of commands) addGeneratedCommand(program, SDK, clientOptions, definition, groups); + for (const definition of commands) + addGeneratedCommand(program, SDK, clientOptions, definition, groups, selection, auth); + + if (environments.length > 0) addEnvironmentsCommand(program, selection, defaultFormat); if (completions) addCompletionCommand(program, binaryName, completions); + if (auth) addAuthCommands(program, auth, selection); return program; }; @@ -216,6 +283,288 @@ const completionHelpExamples = (binaryName: string, shells: readonly string[]): return lines.length > 0 ? '\nAdd one of these to your shell startup file:\n' + lines.join('\n') : ''; }; +// Help text for `--environment`, naming the environments the embedded SDK accepts and the one it +// falls back to. +// +// Kept in step with `environmentFlagDescription` in the generator's `helpers/environments.ts`, which +// renders the same sentence into the man pages, the README, and the completion scripts. It is built +// here rather than passed in because this runtime is one fixed file for every generated CLI: only +// the data handed to `createProgram` varies. +const environmentFlagDescription = ({ + environments, + defaultEnvironment, + environmentEnv, +}: EnvironmentSelection): string => { + const names = environments.map((environment) => environmentLabel(environment.name)).join(', '); + return ( + 'Named environment to target: ' + + names + + (environmentEnv ? ' (can also be set with ' + environmentEnv + ' env var)' : '') + + (defaultEnvironment ? ' (default: ' + environmentLabel(defaultEnvironment) + ')' : '') + ); +}; + +// Reduces a configured environment name to text that is safe on one line of the help column. +// +// Commander wraps a flag description into a column, so a newline in a name would tear the option +// list apart, and a control character would be written to the terminal as an escape sequence when +// help is printed. Only the description is reduced: `--environment` still matches against the name +// itself, and the `environments` command prints it unchanged. +// biome-ignore lint/suspicious/noControlCharactersInRegex: dropping control characters is the point. +// Whitespace collapses first, so a line break becomes the space it reads as; dropping control +// characters first would swallow it and run the words either side of it together. +const environmentLabel = (name: string): string => + name + .replace(/\s+/gu, ' ') + .replace(/[\u0000-\u001F\u007F-\u009F]/gu, '') + .trim(); + +// Lists the configured environments, so what `--environment` accepts is discoverable from the CLI +// itself rather than only from its docs. +// +// It prints through the same writer every generated command uses and takes its own `--format`, so a +// script can read the URLs straight out of it (`--format json`) instead of parsing the table a human +// reads. +// +// "Default" is the environment a command without `--environment` uses, so an exported +// environment variable naming a configured one moves the mark there: every such command now goes +// to it. An unknown name leaves the configured default marked, since every other command refuses +// that value and this list is where a user looks up what it should have been. +const addEnvironmentsCommand = ( + program: Command, + { environments, defaultEnvironment, environmentEnv }: EnvironmentSelection, + defaultFormat: OutputFormat, +): void => { + // Read when the command runs, not when it is registered, like every other environment variable. + const listRows = (): { readonly name: string; readonly url: string; readonly default: boolean }[] => { + const ambient = ambientEnvironment(environmentEnv); + const effective = environments.some((environment) => environment.name === ambient) + ? ambient + : defaultEnvironment; + return environments.map((environment) => ({ + name: environment.name, + url: environment.url, + default: environment.name === effective, + })); + }; + program + .command('environments') + .description('List the named environments --environment can select') + .option('--format ', 'Output format: auto, json, jsonl, pretty, raw, toon, yaml') + .action(async (options: { readonly format?: string }) => { + // A machine format asked for by name is answered with the rows; anything else is a person + // reading, so the list is drawn the way the sign-in picker draws its own. `auto` lands here + // rather than on JSON because this command exists to be read, not parsed — a script names + // the format it wants, and `--format json` is what the README and the man page tell it to. + const rows = listRows(); + const requested = options.format ?? program.opts().format; + if (requested === undefined || requested === 'auto' || requested === 'pretty') { + writeEnvironmentList(rows); + return; + } + await writeOutput(rows, { format: normalizeFormat(requested, defaultFormat), title: 'environments' }); + }); +}; + +// Draws the environments as the list a person reads: the default marked and coloured, each URL +// dimmed behind its name, through the same renderer the sign-in picker uses for its rows. +// +// Escapes only when stdout is a terminal, so a redirected list is the same text without them — +// and the names are printed as configured, since this is the one place they are the value to type +// rather than prose about it. +const writeEnvironmentList = ( + rows: readonly { readonly name: string; readonly url: string; readonly default: boolean }[], +): void => { + const columns = Number(processStdout.columns) || 80; + const color = Boolean(processStdout.isTTY); + const width = Math.max(0, ...rows.map((row) => row.name.length)); + for (const row of rows) { + const label = row.name.padEnd(width, ' '); + const detail = row.url + (row.default ? ' (default)' : ''); + processStdout.write(listRow(label, detail, { columns, selected: row.default, color }) + '\n'); + } +}; + +// The environment the selecting variable names, or undefined when it is unset or not offered. +// Blank is unset, as the embedded SDK treats its own variables: an `export NAME=` left in a shell +// profile must not select an environment called "". Shared by `resolveEnvironment` and the +// `environments` list, so the two cannot disagree about which environment the variable picks. +const ambientEnvironment = (environmentEnv: string | undefined): string | undefined => + environmentEnv ? process.env[environmentEnv]?.trim() || undefined : undefined; + +// Resolves the environment a command targets: `--environment`, else the environment variable that +// stands in for it, checked against the configured list. +// +// Every failure is a usage error: no request is built for any of them, and none is one the embedded +// SDK can report in command-line terms. An unknown name reaches the client as a key it cannot index, +// leaving it with no base URL at all, and a URL paired with an environment is rejected there with a +// message naming `baseURL: null` — a library construct a command line has no spelling for, which is +// the same reason `buildClient` restates the credential guard. +// +// `login` and `logout` resolve through here too, because the environment decides which stored +// credential a request carries: the two must agree on it or a sign-in lands where no request looks. +const resolveEnvironment = ( + options: GlobalOptions, + command: Command, + { environments, environmentEnv, baseUrlEnv }: EnvironmentSelection, +): string | undefined => { + // Answered before the option bag is read at all: with no environments configured the CLI never + // registers `--environment`, so an `environment` key here belongs to something else — a client + // option of that name, whose value would otherwise be rejected as an environment (and, for a + // credential, echoed into the error). + if (environments.length === 0) return undefined; + const flagged = options.environment; + // A URL typed on this command line outranks an environment exported for the whole shell, the + // precedence every other option follows — so the variable is not even read, and a stale value in + // it cannot fail a command that named its URL. Trimmed, as the request path trims it: a blank + // `--base-url` is no flag at all, so it cannot outrank anything. + if (flagged === undefined && options.baseUrl?.trim()) return undefined; + const ambient = ambientEnvironment(environmentEnv); + const selected = flagged ?? ambient; + if (selected === undefined) return undefined; + const names = environments.map((environment) => environment.name); + if (!names.includes(selected)) { + command.error( + "error: unknown environment '" + + environmentLabel(selected) + + "'" + + (flagged === undefined ? ' in ' + environmentEnv : '') + + '. Available environments: ' + + names.map(environmentLabel).join(', '), + { exitCode: 2 }, + ); + } + if (flagged !== undefined) { + // Each names a base URL on its own, so honoring either would silently discard the other. + // Trimmed like every other reading of the flag: a blank `--base-url` names no URL, so it + // conflicts with nothing, and the variable form below already treats it that way. + if (options.baseUrl?.trim()) { + command.error( + 'error: --environment cannot be combined with --base-url, which already names the URL to call', + { exitCode: 2 }, + ); + } + return selected; + } + // Two exported variables, each naming where requests go, with nothing to say which was meant. + // The embedded SDK refuses a base URL beside an environment for the same reason, so this is not + // settled by picking one: a production URL exported last week must not quietly beat a staging + // environment exported today, or the other way round. + if (baseUrlEnv && process.env[baseUrlEnv]?.trim()) { + command.error( + 'error: ' + + environmentEnv + + ' and ' + + baseUrlEnv + + ' are both set, and each names where requests go. Unset one, or pass --environment or --base-url to choose.', + { exitCode: 2 }, + ); + } + return selected; +}; + +// `login`/`logout` sit at the top level unless the API already claims one of those names, in +// which case they fall back under an `auth` parent. That parent is created here rather than +// from the command table, so it needs its own description or `--help` lists a bare `auth`, +// which is the very thing the group table exists to prevent. Both paths are described so +// whichever one creates the group first carries it. +const authGroups = (auth: CliAuthDefinition): readonly CliCommandGroup[] => [ + { commandPath: auth.loginPath.slice(0, -1), description: 'Sign in and out' }, + { commandPath: auth.logoutPath.slice(0, -1), description: 'Sign in and out' }, +]; + +const addAuthCommands = ( + program: Command, + auth: CliAuthDefinition, + selection: EnvironmentSelection, +): void => { + const flows = auth.methods.map((method) => method.name).join(', '); + const login = usageExitCode(new Command(auth.loginPath.at(-1) ?? 'login')) + .description('Sign in and save credentials for later commands') + .showHelpAfterError() + .option('--base-url ', 'Sign in against this base URL instead of the default') + .option('--flow ', 'Sign-in flow to use: ' + flows) + .addHelpText('after', authFlowHelp(auth)) + .action(async (_options: unknown, command: Command) => { + const options = command.optsWithGlobals(); + const environment = resolveEnvironment(options, command, selection); + await runAuthCommand(() => + runLogin( + auth, + credentialBaseUrl(auth, options.baseUrl, environment), + options.flow, + loginCommandFor(environment), + ), + ); + }); + addEnvironmentOption(login, selection); + ensureCommandPath(program, auth.loginPath.slice(0, -1), authGroups(auth)).addCommand(login); + + const logout = usageExitCode(new Command(auth.logoutPath.at(-1) ?? 'logout')) + .description('Forget saved credentials') + .showHelpAfterError() + .option('--base-url ', 'Forget the credentials saved for this base URL (not with --all)') + .option('--all', 'Forget every saved credential, for every base URL') + .action(async (_options: unknown, command: Command) => { + const options = command.optsWithGlobals(); + if (options.all === true) { + if (options.baseUrl?.trim()) { + command.error( + 'error: --base-url cannot be combined with --all, which forgets the credentials of every base URL', + { exitCode: 2 }, + ); + } + if (selection.environments.length > 0 && options.environment !== undefined) { + command.error( + 'error: --environment cannot be combined with --all, which forgets the credentials of every environment', + { exitCode: 2 }, + ); + } + await runAuthCommand(() => runLogout(auth, '', true)); + return; + } + const environment = resolveEnvironment(options, command, selection); + await runAuthCommand(() => + runLogout(auth, credentialBaseUrl(auth, options.baseUrl, environment), false), + ); + }); + addEnvironmentOption(logout, selection); + ensureCommandPath(program, auth.logoutPath.slice(0, -1), authGroups(auth)).addCommand(logout); +}; + +// Lets `login` and `logout` take `--environment` after the verb, as every request command does. +const addEnvironmentOption = (command: Command, selection: EnvironmentSelection): void => { + if (selection.environments.length > 0) + command.option('--environment ', environmentFlagDescription(selection)); +}; + +const runAuthCommand = async (run: () => string | Promise): Promise => { + try { + processStdout.write((await run()) + '\n'); + } catch (error) { + process.stderr.write((error instanceof Error ? error.message : String(error)) + '\n'); + process.exitCode = error instanceof UsageError ? 2 : 10; + } finally { + for (const warning of takeWarnings()) process.stderr.write(warning + '\n'); + } +}; + +// The base URL a stored credential is filed under for this invocation. One resolver for `login`, +// `logout` and the request path, so a sign-in lands exactly where a later request looks. +const credentialBaseUrl = ( + auth: CliAuthDefinition, + flag: string | undefined, + environment: string | undefined, +): string => + flag?.trim() || + auth.environments?.find((entry) => entry.name === environment)?.url || + process.env[auth.baseUrlEnv]?.trim() || + auth.defaultBaseUrl; + +// Lists the flows by name so `--flow` can be used without first running the picker. +const authFlowHelp = (auth: CliAuthDefinition): string => + '\nSign-in flows:\n' + auth.methods.map((method) => ' ' + method.name + ' ' + method.label).join('\n'); + const clientOptionDescription = (option: CliClientOptionDefinition): string => { const parts: string[] = []; if (option.description) parts.push(option.description); @@ -225,12 +574,46 @@ const clientOptionDescription = (option: CliClientOptionDefinition): string => { return parts.join(' '); }; +// Constructs the embedded client, restating its credential guard in command-line terms. +// +// The SDK's own guard is worded for a library caller — "instantiate the X client with an apiKey +// option, like new X({ apiKey: ... })" — which names something a CLI user cannot do. That wording +// is fixed upstream to match the reference SDKs byte for byte, so it is restated here rather than +// changed there. The environment variable is the anchor for the match: it is the one token of the +// message this runtime also knows, so an unrelated constructor failure is rethrown untouched +// instead of being reported as a missing credential. +const buildClient = ( + SDK: CreateProgramOptions['SDK'], + options: Record, + clientOptions: readonly CliClientOptionDefinition[], + environment: string | undefined, +): Record => { + try { + return new SDK(options) as Record; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const missing = clientOptions.find((option) => option.env !== undefined && message.includes(option.env)); + if (!missing) throw error; + throw new Error( + 'Missing credential: run `' + + loginCommandFor(environment) + + '`, pass --' + + missing.name + + ' ' + + (missing.env ? ' or set the ' + missing.env + ' environment variable' : '') + + '.', + ); + } +}; + const addGeneratedCommand = ( program: Command, SDK: CreateProgramOptions['SDK'], clientOptions: readonly CliClientOptionDefinition[], definition: CliCommandDefinition, groups: readonly CliCommandGroup[] | undefined, + selection: EnvironmentSelection, + auth: CliAuthDefinition | undefined, ): void => { const parent = ensureCommandPath(program, definition.commandPath.slice(0, -1), groups); const commandName = definition.commandPath.at(-1) ?? definition.methodName; @@ -247,6 +630,13 @@ const addGeneratedCommand = ( .option('-r, --raw-output', 'Print transformed string values without JSON quotes') .option('--debug', 'Enable SDK debug logging'); + // Mirrored on the subcommand for the same reason the client options below are: so it can be + // passed before or after the verb. Registered only when the API declares more than one + // environment, matching the program-level registration. + if (selection.environments.length > 0) { + command.option('--environment ', environmentFlagDescription(selection)); + } + // Mirror configured client-option flags on the subcommand so they can appear before or after the verb. for (const option of clientOptions) { command.option('--' + option.name + ' ', clientOptionDescription(option)); @@ -288,18 +678,21 @@ const addGeneratedCommand = ( command.option('--' + flag.name + value, flag.description ?? ''); } - // Flag spelling for path params (`--id wkr_1`); skipped when the name is already taken by a - // client option or generated flag so Commander does not throw on a duplicate registration. + // Flag spelling for path params (`--id wkr_1`). `flagName` is absent when the name is already + // taken — by a global flag, a client option, a generated flag, or Commander's own `--help`, which + // lives in `_helpOption` where no scan of `command.options` could see it — and the param is then + // accepted positionally only. The decision is made once, by the emitter, so the man pages and the + // completion scripts advertise exactly the spellings registered here. for (const positional of definition.positional) { - if (command.options.some((option) => option.long === '--' + positional.name)) continue; - command.option('--' + positional.name + ' ', positional.description ?? ''); + if (!positional.flagName) continue; + command.option('--' + positional.flagName + ' ', positional.description ?? ''); } command.action(async (...args: unknown[]) => { const command = args.at(-1); if (!(command instanceof Command)) throw new Error('Expected Commander command context'); const positionalValues = args.slice(0, -1); - await runGeneratedCommand(SDK, clientOptions, definition, command, positionalValues); + await runGeneratedCommand(SDK, clientOptions, definition, command, positionalValues, selection, auth); }); parent.addCommand(command); @@ -348,9 +741,15 @@ const runGeneratedCommand = async ( definition: CliCommandDefinition, command: Command, positionalValues: readonly unknown[], + selection: EnvironmentSelection, + auth: CliAuthDefinition | undefined, ): Promise => { const rootOptions = command.optsWithGlobals(); const commandOptions = command.opts(); + // Resolved before the request is set up, not inside the try below: a rejected `--environment` + // is a usage error reported by Commander, and running it through the catch that classifies API + // failures would re-report it as one. + const environment = resolveEnvironment(rootOptions, command, selection); const maxItems = definition.iterable ? normalizeMaxItems(commandOptions.maxItems) : undefined; const outputOptions: OutputOptions = { format: normalizeFormat(commandOptions.format ?? rootOptions.format, 'auto'), @@ -370,7 +769,12 @@ const runGeneratedCommand = async ( }; try { - const client = new SDK(sdkClientOptions(rootOptions, command, clientOptions)) as Record; + const client = buildClient( + SDK, + await sdkClientOptions(rootOptions, command, clientOptions, definition, environment, auth), + clientOptions, + environment, + ); const method = sdkMethod(client, definition); const call = await callArguments(definition, command.opts>(), positionalValues); @@ -402,31 +806,45 @@ const runGeneratedCommand = async ( await writeOutput(resolved, outputOptions); } catch (error) { - await writeError(error, errorOptions, clientOptions, SDK); + await writeError(error, errorOptions, clientOptions, SDK, environment); process.exitCode = errorExitCode(error, SDK); } }; -const sdkClientOptions = ( +const sdkClientOptions = async ( options: GlobalOptions, command: Command, clientOptions: readonly CliClientOptionDefinition[], -): Record => { + definition: CliCommandDefinition, + environment: string | undefined, + auth: CliAuthDefinition | undefined, +): Promise> => { // Forward configured client-option flags (auth keys, org headers, etc.) to the embedded SDK // using the SDK-facing camelCased key. Only forward values that were explicitly set so the // SDK's own env-var fallback keeps working when no CLI flag was passed. - const raw = options as unknown as Record; + const rootOptions = options as unknown as Record; + const commandOptions = command.opts>(); const forwarded: Record = {}; for (const option of clientOptions) { - const value = raw[option.optionKey]; + const commandValue = commandOptions[option.optionKey]; + const value = commandValue === undefined ? rootOptions[option.optionKey] : commandValue; if (value === undefined) continue; // A credential is exactly the kind of value that belongs in a file rather than in shell // history, so a client option reads `@path` like any other flag. It never reaches the // structured decoding below it: an SDK client option is always a scalar. forwarded[option.sdkKey] = typeof value === 'string' ? clientOptionValue(value, option) : value; } + await applyStoredCredentials( + forwarded, + clientOptions, + definition.authClientKeyRequirements, + auth, + options.baseUrl, + environment, + ); return { - ...(options.baseUrl ? { baseURL: options.baseUrl } : {}), + ...(options.baseUrl?.trim() ? { baseURL: options.baseUrl.trim() } : {}), + ...(environment !== undefined ? { environment, baseURL: null } : {}), ...(options.timeout ? { timeout: Number(options.timeout) } : {}), ...(options.maxRetries ? { maxRetries: Number(options.maxRetries) } : {}), ...(options.debug ? { logLevel: 'debug' } : {}), @@ -439,6 +857,46 @@ const sdkClientOptions = ( }; }; +const applyStoredCredentials = async ( + forwarded: Record, + clientOptions: readonly CliClientOptionDefinition[], + authClientKeyRequirements: readonly (readonly string[])[], + auth: CliAuthDefinition | undefined, + baseUrl: string | undefined, + environment: string | undefined, +): Promise => { + if (!auth) return; + if (authClientKeyRequirements.length === 0) return; + const hasExplicitValue = (option: CliClientOptionDefinition): boolean => + forwarded[option.sdkKey] !== undefined || Boolean(option.env && (process.env[option.env] ?? '').trim()); + const explicit = authClientKeyRequirements.find((keys) => + keys.every((key) => { + const option = clientOptions.find((candidate) => candidate.clientKey === key); + return option !== undefined && hasExplicitValue(option); + }), + ); + if (explicit) return; + const stored = await storedCredentials(auth, credentialBaseUrl(auth, baseUrl, environment)); + const isSatisfiable = (keys: readonly string[]): boolean => + keys.every((key) => { + const option = clientOptions.find((candidate) => candidate.clientKey === key); + if (!option) return false; + return hasExplicitValue(option) || (typeof stored[key] === 'string' && stored[key] !== ''); + }); + const selected = + authClientKeyRequirements.find((keys) => isSatisfiable(keys)) ?? authClientKeyRequirements[0] ?? []; + const pending = clientOptions.filter( + (option) => option.auth && selected.includes(option.clientKey) && !hasExplicitValue(option), + ); + for (const option of pending) { + // Own-property lookup only: the store is a JSON file, so a key like "constructor" + // would otherwise resolve to something off Object.prototype rather than a credential. + if (!Object.prototype.hasOwnProperty.call(stored, option.clientKey)) continue; + const value = stored[option.clientKey]; + if (typeof value === 'string' && value) forwarded[option.sdkKey] = value; + } +}; + const sdkMethod = ( client: Record, definition: CliCommandDefinition, @@ -464,7 +922,11 @@ const callArguments = async ( ): Promise<{ readonly args: readonly unknown[]; readonly params: Record }> => { const positionalParams: Record = {}; definition.positional.forEach((param, index) => { - const value = positionalValues[index] ?? options[param.optionKey]; + // `optionKey` is only this positional's when it was given a flag spelling. Without one the key + // belongs to whatever took the name — a `--environment` selector, say — and reading it would + // fill the path param with that flag's value and satisfy the required-argument check below. + const value = + positionalValues[index] ?? (param.flagName === undefined ? undefined : options[param.optionKey]); if (value !== undefined) positionalParams[param.paramKey] = coerceValue(value, param.valueKind, undefined, param.name); }); @@ -493,7 +955,18 @@ const callArguments = async ( const stdin = await readStdinValue(); const params = mergeObjects(stdin, { ...flagParams, ...positionalParams }); const positionalArgs = definition.positional.map((param) => params[param.paramKey]); - const sdkParams = definition.transport === 'websocket' ? omitParams(params, ['send']) : params; + // A positional path param is handed to the SDK method as a leading argument, and the method + // never destructures it back out of `params` — only the path params it did *not* take + // positionally are pulled out there. Leaving it in `params` makes it fall through the + // method's `...query` / `...body` rest and reach the wire a second time, as + // `/version/1.0.0?semver=1.0.0` or as a `namespace` field in a body that has no such field. + // + // Dropping it cannot remove a field the call needs: a positional whose name collides with a + // params field is already disambiguated when the command table is built (a body `slug` beside + // a path one leaves the positional as `slug2`), so the two never share a key. + const omitted = definition.positional.map((param) => param.paramKey); + if (definition.transport === 'websocket') omitted.push('send'); + const sdkParams = omitParams(params, omitted); if (definition.callShape === 'options') return { args: [...positionalArgs, undefined], params }; if (definition.callShape === 'body') @@ -947,7 +1420,7 @@ const collectIterable = async (value: AsyncIterable, maxItems?: number) }; // `auto` renders like `json` (2-space pretty-printed): `pretty` is reserved for the distinct -// human-readable card view, matching warp-style CLI defaults. +// human-readable card view, matching common CLI defaults. const serializeOutput = (value: unknown, options: OutputOptions): string => { const normalized = options.format === 'auto' ? 'json' : options.format; if (options.rawOutput && typeof value === 'string') return value; @@ -1006,8 +1479,9 @@ const writeError = async ( options: OutputOptions, clientOptions: readonly CliClientOptionDefinition[], SDK: CreateProgramOptions['SDK'], + environment: string | undefined, ): Promise => { - const body = transformValue(errorBody(error, clientOptions, SDK), options.transform); + const body = transformValue(errorBody(error, clientOptions, SDK, environment), options.transform); if (options.rawOutput && typeof body === 'string') { process.stderr.write(body + '\n'); return; @@ -1045,10 +1519,11 @@ const errorBody = ( error: unknown, clientOptions: readonly CliClientOptionDefinition[], SDK: CreateProgramOptions['SDK'], + environment: string | undefined, ): Record => { if (error && typeof error === 'object') { const record = error as Record; - const hint = errorHint(record, clientOptions); + const hint = errorHint(record, clientOptions, environment); return { name: record.name, // Stable class identifier so a caller can branch on the kind of failure without parsing prose. @@ -1126,9 +1601,10 @@ const errorExitCode = (error: unknown, SDK: CreateProgramOptions['SDK']): number const errorHint = ( error: Record, clientOptions: readonly CliClientOptionDefinition[], + environment: string | undefined, ): string | undefined => { const status = error.status; - if (status === 401) return authHint(clientOptions); + if (status === 401) return authHint(clientOptions, environment); // 403 is a permission failure, not a credential one: the request authenticated fine, so // telling the caller to set the auth env var would send it round the same loop again. if (status === 403) return 'Access denied. The credential is valid but lacks permission for this resource.'; @@ -1139,16 +1615,23 @@ const errorHint = ( // Undefined when the SDK declares no authentication: there would be no variable to name, and a // generic "set the required environment variable" would point at something that does not exist. -const authHint = (clientOptions: readonly CliClientOptionDefinition[]): string | undefined => { +const authHint = ( + clientOptions: readonly CliClientOptionDefinition[], + environment: string | undefined, +): string | undefined => { const authOptions = clientOptions.filter((option) => option.auth); if (authOptions.length === 0) return undefined; const env = authOptions .map((option) => option.env) .filter((value): value is string => !!value) .join(', '); - return env - ? 'Authentication failed. Set ' + env + ' and try again.' - : 'Authentication failed. Set the required authentication environment variable and try again.'; + return ( + 'Authentication failed. Run `' + + loginCommandFor(environment) + + '`' + + (env ? ', or set ' + env : '') + + ', and try again.' + ); }; // Keep transforms small and dependency-free; the CLI supports the common dot-path extraction case. diff --git a/src/commands/index.ts b/src/commands/index.ts index 4677a27..029a559 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -7,8 +7,10 @@ import { type CliClientOptionDefinition, type CliCommandDefinition, type CliCommandGroup, + type CliEnvironment, } from '../cli/runtime'; import { completions } from '../cli/completions'; +import type { CliAuthDefinition } from '../cli/login'; const clientOptions = [ { @@ -96,6 +98,16 @@ const commands = [ transport: 'http', iterable: false, callShape: 'params', + authClientKeyRequirements: [ + ['bearerAuth'], + ['basicAuthUsername', 'basicAuthPassword'], + ['apiKeyQuery'], + ['apiKeyHeader'], + ['apiKeyHeader', 'apiKeyQuery'], + ['apiKeyCookie'], + ['oAuth2'], + ['openIDConnect'], + ], positional: [], flags: [ { @@ -128,6 +140,16 @@ const commands = [ transport: 'http', iterable: false, callShape: 'body', + authClientKeyRequirements: [ + ['bearerAuth'], + ['basicAuthUsername', 'basicAuthPassword'], + ['apiKeyQuery'], + ['apiKeyHeader'], + ['apiKeyHeader', 'apiKeyQuery'], + ['apiKeyCookie'], + ['oAuth2'], + ['openIDConnect'], + ], positional: [], flags: [ { @@ -344,6 +366,16 @@ const commands = [ transport: 'http', iterable: false, callShape: 'options', + authClientKeyRequirements: [ + ['bearerAuth'], + ['basicAuthUsername', 'basicAuthPassword'], + ['apiKeyQuery'], + ['apiKeyHeader'], + ['apiKeyHeader', 'apiKeyQuery'], + ['apiKeyCookie'], + ['oAuth2'], + ['openIDConnect'], + ], positional: [ { name: 'planet-id', @@ -353,6 +385,7 @@ const commands = [ required: true, description: 'The ID of the planet to get', valueKind: 'integer', + flagName: 'planet-id', }, ], flags: [], @@ -366,6 +399,16 @@ const commands = [ transport: 'http', iterable: false, callShape: 'body', + authClientKeyRequirements: [ + ['bearerAuth'], + ['basicAuthUsername', 'basicAuthPassword'], + ['apiKeyQuery'], + ['apiKeyHeader'], + ['apiKeyHeader', 'apiKeyQuery'], + ['apiKeyCookie'], + ['oAuth2'], + ['openIDConnect'], + ], positional: [ { name: 'planet-id', @@ -375,6 +418,7 @@ const commands = [ required: true, description: 'The ID of the planet to get', valueKind: 'integer', + flagName: 'planet-id', }, ], flags: [ @@ -592,6 +636,16 @@ const commands = [ transport: 'http', iterable: false, callShape: 'options', + authClientKeyRequirements: [ + ['bearerAuth'], + ['basicAuthUsername', 'basicAuthPassword'], + ['apiKeyQuery'], + ['apiKeyHeader'], + ['apiKeyHeader', 'apiKeyQuery'], + ['apiKeyCookie'], + ['oAuth2'], + ['openIDConnect'], + ], positional: [ { name: 'planet-id', @@ -601,6 +655,7 @@ const commands = [ required: true, description: 'The ID of the planet to get', valueKind: 'integer', + flagName: 'planet-id', }, ], flags: [], @@ -614,6 +669,16 @@ const commands = [ transport: 'http', iterable: false, callShape: 'body', + authClientKeyRequirements: [ + ['bearerAuth'], + ['basicAuthUsername', 'basicAuthPassword'], + ['apiKeyQuery'], + ['apiKeyHeader'], + ['apiKeyHeader', 'apiKeyQuery'], + ['apiKeyCookie'], + ['oAuth2'], + ['openIDConnect'], + ], positional: [ { name: 'planet-id', @@ -623,6 +688,7 @@ const commands = [ required: true, description: 'The ID of the planet to get', valueKind: 'integer', + flagName: 'planet-id', }, ], flags: [ @@ -647,6 +713,16 @@ const commands = [ transport: 'http', iterable: false, callShape: 'body', + authClientKeyRequirements: [ + ['bearerAuth'], + ['basicAuthUsername', 'basicAuthPassword'], + ['apiKeyQuery'], + ['apiKeyHeader'], + ['apiKeyHeader', 'apiKeyQuery'], + ['apiKeyCookie'], + ['oAuth2'], + ['openIDConnect'], + ], positional: [], flags: [], }, @@ -659,6 +735,16 @@ const commands = [ transport: 'http', iterable: false, callShape: 'body', + authClientKeyRequirements: [ + ['bearerAuth'], + ['basicAuthUsername', 'basicAuthPassword'], + ['apiKeyQuery'], + ['apiKeyHeader'], + ['apiKeyHeader', 'apiKeyQuery'], + ['apiKeyCookie'], + ['oAuth2'], + ['openIDConnect'], + ], positional: [], flags: [ { @@ -696,6 +782,16 @@ const commands = [ transport: 'http', iterable: false, callShape: 'body', + authClientKeyRequirements: [ + ['bearerAuth'], + ['basicAuthUsername', 'basicAuthPassword'], + ['apiKeyQuery'], + ['apiKeyHeader'], + ['apiKeyHeader', 'apiKeyQuery'], + ['apiKeyCookie'], + ['oAuth2'], + ['openIDConnect'], + ], positional: [], flags: [ { @@ -725,6 +821,16 @@ const commands = [ transport: 'http', iterable: false, callShape: 'options', + authClientKeyRequirements: [ + ['bearerAuth'], + ['basicAuthUsername', 'basicAuthPassword'], + ['apiKeyQuery'], + ['apiKeyHeader'], + ['apiKeyHeader', 'apiKeyQuery'], + ['apiKeyCookie'], + ['oAuth2'], + ['openIDConnect'], + ], positional: [], flags: [], }, @@ -746,16 +852,137 @@ const groups = [ }, ] as const satisfies readonly CliCommandGroup[]; +const environments = [ + { + name: 'production', + url: 'https://galaxy.scalar.com', + }, + { + name: 'void', + url: 'https://void.scalar.com/', + }, +] as const satisfies readonly CliEnvironment[]; + +const auth = { + loginPath: ['login'], + logoutPath: ['logout'], + loginCommand: 'galaxy login', + storeName: 'galaxy', + storeEnv: 'SCALAR_CREDENTIALS_FILE', + baseUrlEnv: 'SCALAR_BASE_URL', + backend: 'auto', + defaultBaseUrl: 'https://galaxy.scalar.com', + environments: [ + { + name: 'production', + url: 'https://galaxy.scalar.com', + }, + { + name: 'void', + url: 'https://void.scalar.com/', + }, + ], + requirements: [ + ['bearerAuth'], + ['basicAuthUsername', 'basicAuthPassword'], + ['apiKeyQuery'], + ['apiKeyHeader'], + ['apiKeyHeader', 'apiKeyQuery'], + ['apiKeyCookie'], + ['oAuth2'], + ['openIDConnect'], + ], + envByKey: { + bearerAuth: 'BEARER_AUTH', + basicAuthUsername: 'BASIC_AUTH_USERNAME', + basicAuthPassword: 'BASIC_AUTH_PASSWORD', + apiKeyHeader: 'API_KEY_HEADER', + apiKeyQuery: 'API_KEY_QUERY', + apiKeyCookie: 'API_KEY_COOKIE', + oAuth2: 'SCALAR_O_AUTH2', + openIDConnect: 'SCALAR_OPEN_ID_CONNECT', + }, + methods: [ + { + name: 'bearer-auth', + label: 'Enter your access token', + kind: 'token', + clientKey: 'bearerAuth', + prompt: 'Access token: ', + }, + { + name: 'basic', + label: 'Username and password for basic auth', + kind: 'basic', + usernameKey: 'basicAuthUsername', + passwordKey: 'basicAuthPassword', + }, + { + name: 'api-key-header', + label: 'Enter your API key', + kind: 'token', + clientKey: 'apiKeyHeader', + prompt: 'API key: ', + }, + { + name: 'api-key-query', + label: 'Enter your API key', + kind: 'token', + clientKey: 'apiKeyQuery', + prompt: 'API key: ', + }, + { + name: 'api-key-cookie', + label: 'Enter your API key', + kind: 'token', + clientKey: 'apiKeyCookie', + prompt: 'API key: ', + }, + { + name: 'oauth-client-credentials', + label: 'Sign in with a client id and secret (OAuth client credentials)', + kind: 'oauth', + grant: 'clientCredentials', + clientKey: 'oAuth2', + tokenUrl: 'https://galaxy.scalar.com/oauth/token', + refreshUrl: 'https://galaxy.scalar.com/oauth/token', + scopes: ['read:account', 'write:planets', 'read:planets'], + }, + { + name: 'oauth-password', + label: 'Sign in with a username and password (OAuth password grant)', + kind: 'oauth', + grant: 'password', + clientKey: 'oAuth2', + tokenUrl: 'https://galaxy.scalar.com/oauth/token', + refreshUrl: 'https://galaxy.scalar.com/oauth/token', + scopes: ['read:account', 'write:planets', 'read:planets'], + }, + { + name: 'open-id-connect', + label: 'Paste an access token you already have', + kind: 'token', + clientKey: 'openIDConnect', + prompt: 'Access token: ', + }, + ], +} as const satisfies CliAuthDefinition; + export const getProgram = (): Command => createProgram({ SDK, binaryName: 'galaxy', - version: '0.3.0', // x-release-please-version + version: '0.3.1', // x-release-please-version description: 'CLI for Scalar Galaxy', defaultFormat: 'auto', defaultErrorFormat: 'auto', clientOptions, commands, groups, + environments, + defaultEnvironment: 'production', + environmentEnv: 'SCALAR_ENVIRONMENT', + baseUrlEnv: 'SCALAR_BASE_URL', completions, + auth, }); diff --git a/src/sdk/client.ts b/src/sdk/client.ts index 8bcb2e2..4915d3b 100644 --- a/src/sdk/client.ts +++ b/src/sdk/client.ts @@ -15,6 +15,7 @@ import { formatRequestDetails, loggerFor, parseLogLevel, + redactUrl, type LogLevel, type Logger, } from './internal/utils/log'; @@ -190,12 +191,12 @@ export type GalaxyOptions = ClientOptions; * API Client for interfacing with the ScalarGalaxy API. */ export class Galaxy { - bearerAuth: string | AuthTokenProvider; - basicAuthUsername: string | AuthTokenProvider; - basicAuthPassword: string | AuthTokenProvider; - apiKeyHeader: string | AuthTokenProvider; - apiKeyQuery: string | AuthTokenProvider; - apiKeyCookie: string | AuthTokenProvider; + bearerAuth: string | AuthTokenProvider | undefined; + basicAuthUsername: string | AuthTokenProvider | undefined; + basicAuthPassword: string | AuthTokenProvider | undefined; + apiKeyHeader: string | AuthTokenProvider | undefined; + apiKeyQuery: string | AuthTokenProvider | undefined; + apiKeyCookie: string | AuthTokenProvider | undefined; oAuth2: string | AuthTokenProvider | undefined; openIDConnect: string | AuthTokenProvider | undefined; webhookSecret: string | null; @@ -247,37 +248,6 @@ export class Galaxy { webhookSecret = readEnv('SCALAR_WEBHOOK_SECRET') ?? null, ...opts }: ClientOptions = {}) { - if (bearerAuth === undefined) { - throw new Errors.GalaxyError( - "The BEARER_AUTH environment variable is missing or empty; either provide it, or instantiate the Galaxy client with an bearerAuth option, like new Galaxy({ bearerAuth: 'My Bearer Auth' }).", - ); - } - if (basicAuthUsername === undefined) { - throw new Errors.GalaxyError( - "The BASIC_AUTH_USERNAME environment variable is missing or empty; either provide it, or instantiate the Galaxy client with an basicAuthUsername option, like new Galaxy({ basicAuthUsername: 'My Basic Auth Username' }).", - ); - } - if (basicAuthPassword === undefined) { - throw new Errors.GalaxyError( - "The BASIC_AUTH_PASSWORD environment variable is missing or empty; either provide it, or instantiate the Galaxy client with an basicAuthPassword option, like new Galaxy({ basicAuthPassword: 'My Basic Auth Password' }).", - ); - } - if (apiKeyHeader === undefined) { - throw new Errors.GalaxyError( - "The API_KEY_HEADER environment variable is missing or empty; either provide it, or instantiate the Galaxy client with an apiKeyHeader option, like new Galaxy({ apiKeyHeader: 'My API Key Header' }).", - ); - } - if (apiKeyQuery === undefined) { - throw new Errors.GalaxyError( - "The API_KEY_QUERY environment variable is missing or empty; either provide it, or instantiate the Galaxy client with an apiKeyQuery option, like new Galaxy({ apiKeyQuery: 'My API Key Query' }).", - ); - } - if (apiKeyCookie === undefined) { - throw new Errors.GalaxyError( - "The API_KEY_COOKIE environment variable is missing or empty; either provide it, or instantiate the Galaxy client with an apiKeyCookie option, like new Galaxy({ apiKeyCookie: 'My API Key Cookie' }).", - ); - } - const options: ClientOptions = { bearerAuth, basicAuthUsername, @@ -391,7 +361,7 @@ export class Galaxy { } private getUserAgent(): string { - return `${this.constructor.name}/JS ${VERSION}`; + return `Galaxy/JS ${VERSION}`; } protected defaultIdempotencyKey(): string { @@ -577,7 +547,7 @@ export class Galaxy { throw new Errors.APIConnectionError({ cause: response }); } - const responseInfo = `[${requestLogID}${retryLogStr}] ${req.method} ${url} ${ + const responseInfo = `[${requestLogID}${retryLogStr}] ${req.method} ${redactUrl(url)} ${ response.ok ? 'succeeded' : 'failed' } with status ${response.status} in ${headersTime - startTime}ms`; @@ -654,7 +624,8 @@ export class Galaxy { ): Promise { const { signal, method, ...options } = init || {}; const abort = this._makeAbort(controller); - if (signal) signal.addEventListener('abort', abort, { once: true }); + if (signal?.aborted) abort(); + else if (signal) signal.addEventListener('abort', abort, { once: true }); const timeout = setTimeout(abort, ms); @@ -675,7 +646,7 @@ export class Galaxy { } try { - // use undefined this binding; fetch errors if bound to something else in browser/cloudflare + // use undefined this binding; fetch errors if bound to something else in browsers and edge runtimes / workers return await this.fetch.call(undefined, url, fetchOptions); } finally { clearTimeout(timeout); @@ -866,18 +837,20 @@ export class Galaxy { if (body == null) { return { bodyHeaders: undefined, body: undefined }; } - const headers = buildHeaders([rawHeaders]); + // A `content-type` from either header bag says how the body is already encoded; the request's + // own wins over the client-wide default, as it does on the wire. + const headers = buildHeaders([this._options.defaultHeaders, rawHeaders]); if ( // Pass raw type verbatim ArrayBuffer.isView(body) || body instanceof ArrayBuffer || body instanceof DataView || - // Always pass strings through verbatim. The previous guard required a caller-set - // `content-type` and otherwise fell through to `FallbackEncoder`, which JSON.stringifies - // the value and labels it `application/json` — silently quoting plain-text payloads and - // mislabeling them as JSON. fetch defaults a string body to `text/plain;charset=UTF-8` - // when no `content-type` is set, which is a safer default than misclaiming JSON. - typeof body === 'string' || + // A string is only already-encoded when something has said what it is encoded as. + // Generated call sites state the declared request media type, so a `text/plain` or + // ndjson payload reaches the wire byte-for-byte. A string with no `content-type` came + // from a body the document declared as JSON — `{ "type": "string" }` — and encoding it + // below is what puts the quotes the server parses for around it. + (typeof body === 'string' && headers.values.has('content-type')) || // `Blob` is superset of `File` ((globalThis as any).Blob && body instanceof (globalThis as any).Blob) || // `FormData` -> `multipart/form-data` diff --git a/src/sdk/internal/detect-platform.ts b/src/sdk/internal/detect-platform.ts index 3954ec3..c19f85e 100644 --- a/src/sdk/internal/detect-platform.ts +++ b/src/sdk/internal/detect-platform.ts @@ -104,7 +104,7 @@ const getPlatformProperties = (): PlatformProperties => { }; } - // TODO add support for Cloudflare workers, etc. + // TODO add support for worker runtimes that expose no EdgeRuntime global, etc. return { 'X-Scalar-Lang': 'js', 'X-Scalar-Package-Version': VERSION, diff --git a/src/sdk/internal/utils/log.ts b/src/sdk/internal/utils/log.ts index 648949e..d9a9138 100644 --- a/src/sdk/internal/utils/log.ts +++ b/src/sdk/internal/utils/log.ts @@ -84,6 +84,160 @@ export function loggerFor(client: Galaxy): Logger { return levelLogger; } +/** Value a credential is replaced with before it reaches a log line. */ +const REDACTED = '***'; + +/** + * Header names whose value is replaced before a request or response is logged. + * + * The fixed entries are the conventional credential headers, which is all a runtime shared by every + * SDK could know on its own. The generated ones are the headers *this* SDK's auth schemes actually + * send: an OpenAPI `apiKey` scheme names its own header, and `X-Auth-Token` or `PRIVATE-TOKEN` is no + * less a credential for being spelled differently. Without them a `logLevel: "debug"` run prints the + * credential in clear text, and the generated CLI turns that level on with `--debug`, so it reaches + * terminals and CI logs alike. + */ +const REDACTED_HEADERS: ReadonlySet = new Set([ + 'authorization', + 'api-key', + 'x-api-key', + 'cookie', + 'set-cookie', +]); + +/** + * Query parameters this SDK sends a credential in, from its `apiKey` schemes with `in: query`. + * + * A credential in the query string is worse off than one in a header: the request URL is logged at + * `info` as well as at `debug`, so it leaks a level below the one a reader would think of as + * verbose. + */ +const REDACTED_QUERY_PARAMS: readonly string[] = ['api_key']; + +/** + * Blanks the userinfo of a URL, which is a credential wherever it appears. + * + * A `--base-url` carries whatever the caller typed, and this repo already treats userinfo as secret + * where it handles a base URL elsewhere (`profileKey` blanks it before a URL becomes a keychain + * account). Both halves go rather than the password alone: a token is as often the username + * (`https://@host`) as the password, so keeping either back still prints one shape in full. + */ +const redactUserinfo = (url: string): string => { + const scheme = url.indexOf('://'); + if (scheme === -1) return url; + const start = scheme + 3; + // The authority ends at the first of these; an `@` past one of them belongs to a path or a query. + let end = url.length; + for (const delimiter of ['/', '?', '#']) { + const at = url.indexOf(delimiter, start); + if (at !== -1 && at < end) end = at; + } + const at = url.lastIndexOf('@', end); + return at === -1 || at < start ? url : url.slice(0, start) + REDACTED + url.slice(at); +}; + +/** + * Decodes one `application/x-www-form-urlencoded` parameter name, for comparison against the list. + * + * A name reaches the URL percent-encoded, so `api%5Fkey` has to match `api_key`. A malformed escape + * comes back as written rather than throwing: a log line is no place to fail over one, and a name + * that cannot be decoded cannot match the list anyway. + */ +const decodeQueryName = (name: string): string => { + try { + return decodeURIComponent(name.replace(/\+/gu, ' ')); + } catch { + return name; + } +}; + +/** + * Replaces the value of every credential-bearing query parameter in a URL about to be logged. + * + * Rewritten on the query substring rather than through `new URL`, so a relative URL is handled the + * same way an absolute one is and no parse can throw on the logging path. + */ +export const redactUrl = (url: string): string => { + const url_ = redactUserinfo(url); + if (REDACTED_QUERY_PARAMS.length === 0) return url_; + const mark = url_.indexOf('?'); + if (mark === -1) return url_; + // A fragment is not part of the query, so splitting it off keeps it out of the rewrite. + const fragment = url_.indexOf('#', mark); + const end = fragment === -1 ? url_.length : fragment; + // Each pair is rewritten where it stands rather than round-tripped through `URLSearchParams`, + // whose serializer re-spells every *other* parameter in its own safe set -- a space comes back as + // `+`, a `~` as `%7E` -- and a logged URL that is not the one the request used is a URL nobody can + // paste back. Splitting and rejoining on `&` is lossless, so a URL carrying no credential comes + // out of this exactly as it went in. + const query = url_ + .slice(mark + 1, end) + .split('&') + .map((pair) => { + const equals = pair.indexOf('='); + // A parameter with no `=` carries no value, so there is nothing in it to redact. + if (equals === -1) return pair; + const name = pair.slice(0, equals); + return REDACTED_QUERY_PARAMS.includes(decodeQueryName(name)) ? name + '=' + REDACTED : pair; + }) + .join('&'); + return url_.slice(0, mark + 1) + query + url_.slice(end); +}; + +/** + * Replaces credential values in a caller-supplied query object. + * + * The client merges its own auth query into the URL, but a caller may pass the same parameter here + * and `buildURL` lets that value win — so it is as live a credential as the one in the URL. + */ +const redactQuery = (query: object): object => { + const redacted: Record = { ...(query as Record) }; + for (const name of REDACTED_QUERY_PARAMS) { + if (hasOwn(redacted, name)) redacted[name] = REDACTED; + } + return redacted; +}; + +/** Replaces the value of every credential-bearing header, for a `Headers` or a plain record alike. */ +export const redactHeaders = (headers: Headers | Record): Record => + Object.fromEntries( + (headers instanceof Headers ? [...headers] : Object.entries(headers)).map(([name, value]) => [ + name, + REDACTED_HEADERS.has(name.toLowerCase()) ? REDACTED : value, + ]), + ); + +/** URL-shaped runs inside free text. Whitespace and quotes end one; see {@link TRAILING_PUNCTUATION}. */ +const URL_IN_TEXT = /\bhttps?:\/\/[^\s"'<>]+/gu; + +/** + * Punctuation that ends a sentence or closes a bracket rather than belonging to the URL. + * + * Trimmed back after the match instead of being excluded from it, because an IPv6 host carries its + * own `]` (`http://[::1]:8080/p?api_key=…`). Excluding the character cut the match short at the + * host and left the query — credential included — in the text. + */ +const TRAILING_PUNCTUATION = /[.,;:!?)\]}]+$/u; + +/** + * Redacts every URL quoted inside free text, leaving the rest of the text alone. + * + * Runs for every SDK, not only those with a query credential: Node's `fetch` rejects a URL carrying + * userinfo with a message that quotes the whole URL back (`Request cannot be constructed from a URL + * that includes credentials: …`), and that message is logged on the connection-failure path. Gating + * this on the query list left the password in the log for every SDK whose document happens not to + * put a credential in the query. + * + * Only the matched URL is rewritten, never the whole string: a message is not a query string, and + * handing one to `URLSearchParams` wholesale would swallow everything after the first parameter and + * take the diagnostic with it. + */ +const redactUrlsInText = (text: string): string => + text.replace(URL_IN_TEXT, (match) => { + const trailing = match.match(TRAILING_PUNCTUATION)?.[0] ?? ''; + return redactUrl(match.slice(0, match.length - trailing.length)) + trailing; + }); + export const formatRequestDetails = (details: { options?: RequestOptions | undefined; headers?: Headers | Record | undefined; @@ -97,24 +251,48 @@ export const formatRequestDetails = (details: { body?: unknown; }) => { if (details.options) { - details.options = { ...details.options }; - delete details.options['headers']; // redundant + leaks internals + // Swept through a record view rather than field by field: not every field below is declared on + // `RequestOptions` in every profile (`serverURL` belongs to the Speakeasy compatibility one), + // and the copy is what keeps the caller's own options object untouched. + const options: Record = { ...details.options }; + delete options['headers']; // redundant + leaks internals + // The Speakeasy profile re-adds a header bag under `fetchOptions`, and the client reads it as + // the request's headers whenever the flattened one is absent -- so it holds the credential a + // migrated call site passes. Dropped for the same reason as the field above rather than + // redacted, because `HeadersLike` also takes shapes (an array of pairs, `null`) that the header + // redaction does not, and the sent headers are already reported beside this, redacted. + const fetchOptions = options['fetchOptions']; + if (fetchOptions && typeof fetchOptions === 'object') { + const copy: Record = { ...(fetchOptions as Record) }; + delete copy['headers']; + options['fetchOptions'] = copy; + } + const query = options['query']; + if (query && typeof query === 'object') options['query'] = redactQuery(query); + // These three are URL strings, not route fragments: `buildURL` takes an absolute `path` whole -- + // userinfo, query string and all -- while `defaultBaseURL` and the Speakeasy profile's + // `serverURL` each replace the base URL outright. The caller-supplied escape hatches therefore + // carry a credential exactly as readily as the resolved URL beside them does. `serverURL` may + // be a `URL`, whose `username` and `password` print as fields of their own, so the redacted + // string goes back rather than the object. + for (const field of ['path', 'defaultBaseURL', 'serverURL']) { + const value = options[field]; + if (typeof value === 'string') options[field] = redactUrl(value); + else if (value instanceof URL) options[field] = redactUrl(value.toString()); + } + details.options = options as RequestOptions; + } + if (details.url) { + details.url = redactUrl(details.url); } if (details.headers) { - details.headers = Object.fromEntries( - (details.headers instanceof Headers ? [...details.headers] : Object.entries(details.headers)).map( - ([name, value]) => [ - name, - name.toLowerCase() === 'authorization' || - name.toLowerCase() === 'api-key' || - name.toLowerCase() === 'x-api-key' || - name.toLowerCase() === 'cookie' || - name.toLowerCase() === 'set-cookie' - ? '***' - : value, - ], - ), - ); + details.headers = redactHeaders(details.headers); + } + // The message is whatever the fetch implementation threw, and Deno's connection errors quote the + // request URL inside their text (see the note at the `connection failed` call sites), so a query + // credential rides along in a field none of the checks above look at. + if (typeof details.message === 'string') { + details.message = redactUrlsInText(details.message); } if ('retryOfRequestLogID' in details) { if (details.retryOfRequestLogID) { diff --git a/src/sdk/version.ts b/src/sdk/version.ts index 9f0390e..7b9493e 100644 --- a/src/sdk/version.ts +++ b/src/sdk/version.ts @@ -1,3 +1,3 @@ // File generated from our OpenAPI spec by Scalar. See README.md for details. -export const VERSION = '0.3.0'; // x-release-please-version +export const VERSION = '0.3.1'; // x-release-please-version