From 1cd021e519f7df7fc932f85e547f0c683ee36cc7 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Fri, 6 Nov 2026 10:13:05 +0100 Subject: [PATCH 1/4] fix: reword the help, errors and log messages in plain English The same thing is now said the same way across commands, for example "Use --workspace to choose another workspace". Commands in error and log text are quoted with single quotes. Co-Authored-By: Claude Opus 5.5 --- cmd/api/api.go | 25 +++--- cmd/api/api_test.go | 14 ++-- cmd/api/describe.go | 4 +- cmd/api/docs.go | 23 +++--- cmd/api/profile.go | 4 +- cmd/api/profile_test.go | 8 +- cmd/api/request.go | 10 +-- cmd/auth/login.go | 20 ++--- cmd/auth/login_test.go | 8 +- cmd/auth/logout.go | 4 +- cmd/auth/select_profile.go | 8 +- cmd/auth/select_workspace.go | 8 +- cmd/auth/status.go | 10 +-- cmd/auth/stdin.go | 2 +- cmd/auth/token.go | 11 +-- cmd/buildingblock/abort_run.go | 10 +-- cmd/buildingblock/approve_run.go | 20 ++--- .../approve_run_internal_test.go | 2 +- cmd/buildingblock/buildingblock.go | 2 +- cmd/buildingblock/list.go | 4 +- cmd/buildingblock/tfstate/exec.go | 76 ++++++++++--------- cmd/buildingblock/tfstate/force_unlock.go | 25 +++--- cmd/buildingblock/tfstate/show.go | 8 +- cmd/buildingblock/tfstate/tfstate.go | 24 +++--- cmd/buildingblock/tfstate/tfstate_test.go | 12 +-- cmd/buildingblock/trigger_run.go | 24 +++--- cmd/buildingblock/wait.go | 24 +++--- cmd/buildingblock/wait_test.go | 2 +- cmd/buildingblockdefinition/list.go | 4 +- cmd/buildingblockdefinitionversion/list.go | 6 +- cmd/buildingblockrun/list.go | 6 +- cmd/buildingblockrun/logs.go | 12 +-- cmd/buildingblockrun/logs_test.go | 2 +- cmd/eventlog/list.go | 6 +- cmd/eventlog/list_test.go | 2 +- cmd/internal/buildingblock/run_wait.go | 2 +- cmd/internal/list.go | 2 +- cmd/internal/list_test.go | 2 +- cmd/internal/markdown/credential.md.tmpl | 6 +- cmd/internal/markdown/markdown_test.go | 6 +- cmd/internal/output.go | 12 +-- cmd/internal/prompt/model.go | 2 +- cmd/internal/prompt/prompt.go | 2 +- cmd/internal/prompt/prompt_test.go | 4 +- cmd/internal/read.go | 4 +- cmd/internal/testacc/profile_test.go | 4 +- cmd/profile/add.go | 4 +- cmd/profile/delete.go | 9 ++- cmd/profile/edit.go | 9 ++- cmd/profile/form.go | 4 +- cmd/profile/model.go | 4 +- cmd/profile/model_test.go | 4 +- cmd/profile/profile.go | 12 +-- cmd/profile/profile_test.go | 14 ++-- cmd/profile/select_profile.go | 2 +- cmd/profile/show.go | 9 ++- cmd/profile/show.md.tmpl | 4 +- cmd/profile/show_test.go | 6 +- cmd/profile/store.go | 6 +- cmd/profile/store_test.go | 4 +- cmd/workspace/list.go | 4 +- internal/auth/cache.go | 2 +- internal/auth/cache_test.go | 2 +- internal/auth/credential/oidclogin.go | 2 +- internal/auth/credential_manual.go | 7 +- internal/auth/credentials.go | 6 +- internal/auth/session.go | 5 +- internal/auth/session_test.go | 4 +- internal/auth/status_test.go | 2 +- internal/meshstack/settings.go | 2 +- internal/meshstack/workspace.go | 7 +- internal/meshstack/workspaces.go | 2 +- internal/oidc/browser/browser.go | 4 +- internal/profile/name.go | 4 +- internal/profile/select.go | 2 +- internal/setting/resolve.go | 2 +- internal/setting/resolve_test.go | 2 +- internal/tfstate/buildingblock.go | 4 +- internal/tfstate/lock.go | 6 +- internal/tfstate/proxy.go | 14 ++-- internal/tfstate/proxy_test.go | 12 +-- skills/meshstack-cli/SKILL.md | 2 +- 82 files changed, 338 insertions(+), 326 deletions(-) diff --git a/cmd/api/api.go b/cmd/api/api.go index 02f6c4bc..bfcf43b9 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -28,19 +28,20 @@ func New() *cobra.Command { cmd := &cobra.Command{ Use: "api ", Short: "Send an authorized request to the meshStack API", - Long: `Send an authorized request to a path of the meshStack API, and return the response (typically JSON). -The path is relative to the endpoint but can also be an absolute one, as long as a matching profile with authentication credentials can be found. + Long: `Send an authorized request to a path of the meshStack API, and print the response (typically +JSON). The path is relative to the endpoint. It can also be a full URL, as long as a stored profile +with credentials matches its endpoint. -This exposes everything that the other CLI commands do not cover. -It supports you by taking over versioned content negotiation of meshStack's public API unless you specify it explicitly. +Use this command for everything that the other commands do not cover. It sets the API version +headers of meshStack's public API for you, unless you set them yourself. ---request-json sends a JSON file, or stdin for '-'. For a path -of no version in the API docs, the Content-Type and Accept headers default to application/json, and -without the API docs the Content-Type alone does. +--request-json sends a JSON file, or stdin for '-'. For a path that has no version in the API docs, +the Content-Type and Accept headers default to application/json. Without the API docs, only the +Content-Type header does. -An answer outside 2xx still writes its body but the command then fails. +If the response status is not 2xx, the command still prints the response body, and then fails. -Replace the same command line with 'api-docs' in place of 'api' to describe the request.`, +To describe the request, run the same command line with 'api' replaced by 'api-docs'.`, Example: ` meshstack api '/api/meshobjects/meshtenants?workspaceIdentifier=my-workspace' meshstack api -X DELETE /api/meshobjects/meshbuildingblocks//purge meshstack api -X POST /api/meshobjects/meshworkspaces --request-json workspace.json @@ -78,7 +79,7 @@ Replace the same command line with 'api-docs' in place of 'api' to describe the spec, wait, err := flags.loadApiDocs(cmd) defer wait() if err != nil { - slog.WarnContext(ctx, fmt.Sprintf("Sending the request as given, without the API docs: %s. "+ + slog.WarnContext(ctx, fmt.Sprintf("Sending the request unchanged, because the API docs are not available: %s. "+ "Run %s to see why.", err, docsCommand(cmd, args))) } else if negotiateErr := r.negotiate(ctx, spec, selector); negotiateErr != nil { return negotiateErr @@ -161,7 +162,7 @@ func (r *request) negotiate(ctx context.Context, spec openapi.Spec, selector ope r.documented = err == nil && len(selected.Operations) > 0 if err != nil { if !openapi.ParseMediaType(r.header.Get("Accept")).ApiVersion.IsZero() { - slog.WarnContext(ctx, fmt.Sprintf("Sending the request as given: %s", err)) + slog.WarnContext(ctx, fmt.Sprintf("Sending the request unchanged: %s", err)) return nil } return err @@ -174,7 +175,7 @@ func (r *request) negotiate(ctx context.Context, spec openapi.Spec, selector ope } if mediaType.ApiVersion.IsZero() { if !selector.ApiVersion.IsZero() && r.header.Get("Accept") == "" { - slog.WarnContext(ctx, fmt.Sprintf("The API docs list no version of %s %s, so no Accept header asks for %s", + slog.WarnContext(ctx, fmt.Sprintf("The API docs have no version of %s %s, so the request gets no Accept header for %s", selector.Method, selector.Path, selector.ApiVersion)) } else { slog.DebugContext(ctx, fmt.Sprintf("The API docs list no version of %s %s", selector.Method, selector.Path)) diff --git a/cmd/api/api_test.go b/cmd/api/api_test.go index 905ce92c..e02a2cb8 100644 --- a/cmd/api/api_test.go +++ b/cmd/api/api_test.go @@ -103,10 +103,10 @@ func TestApi(t *testing.T) { t.Run("refuses a body of no JSON, or one that names another version than --api-version", func(t *testing.T) { _, err := postBlock(t, `spec: {}`) - require.EqualError(t, err, "--request-json - is no JSON") + require.EqualError(t, err, "--request-json - is not valid JSON") _, err = postBlock(t, `{"apiVersion": "v1"}`, "--api-version", "v2-preview") - require.EqualError(t, err, "the Accept and Content-Type headers, --api-version and the body's apiVersion ask for v2-preview, v1") + require.EqualError(t, err, "the Accept and Content-Type headers, --api-version and the body's apiVersion request different versions: v2-preview, v1") }) t.Run("sends and asks for JSON on a path of no version, unless a header says otherwise", func(t *testing.T) { @@ -214,7 +214,7 @@ func TestApi(t *testing.T) { _, err = meshStack.run(t, api.NewDocs(), "", "-X", "PATCH") - require.EqualError(t, err, "the API docs list no operation of PATCH") + require.EqualError(t, err, "the API docs have no operation for PATCH") }) t.Run("api-docs describes the request of a command line of api", func(t *testing.T) { @@ -267,7 +267,7 @@ func TestApi(t *testing.T) { _, err = meshStack.run(t, api.NewDocs(), "", "/api/meshobjects/meshunknowns") - require.EqualError(t, err, "the API docs list no operation of /api/meshobjects/meshunknowns") + require.EqualError(t, err, "the API docs have no operation for /api/meshobjects/meshunknowns") }) t.Run("api-docs --describe writes the latest version of each operation of a kind as Markdown", func(t *testing.T) { @@ -289,10 +289,10 @@ func TestApi(t *testing.T) { t.Run("api-docs --describe takes a kind of the API docs, and neither a path nor a request", func(t *testing.T) { _, err := meshStack.run(t, api.NewDocs(), "", "--describe", "workspace") - require.EqualError(t, err, `the API docs list no meshObject kind "workspace", write one of buildingblock, buildingblockrun, tenant`) + require.EqualError(t, err, `the API docs have no meshObject kind "workspace"; use one of buildingblock, buildingblockrun, tenant`) _, err = meshStack.run(t, api.NewDocs(), "", "--describe", "tenant.list") - require.EqualError(t, err, `the API docs list no action "list" of meshTenant, write one of show, delete`) + require.EqualError(t, err, `the API docs have no action "list" for meshTenant; use one of show, delete`) _, err = meshStack.run(t, api.NewDocs(), "", "--describe", "bb", "/api/meshobjects/meshbuildingblocks") require.EqualError(t, err, "--describe takes no path, see --help") @@ -315,7 +315,7 @@ func TestApi(t *testing.T) { require.Len(t, meshStack.requests, 1) assert.Empty(t, meshStack.requests[0].accept, "a meshObject endpoint answers application/json with a 406 that names no version") assert.Equal(t, "application/json", meshStack.requests[0].contentType) - assert.Contains(t, captured.String(), "Sending the request as given, without the API docs") + assert.Contains(t, captured.String(), "Sending the request unchanged, because the API docs are not available") assert.Contains(t, captured.String(), "Run meshstack api-docs /api/meshobjects/meshbuildingblocks -X POST to see why.") _, err = meshStack.run(t, api.NewDocs(), "") diff --git a/cmd/api/describe.go b/cmd/api/describe.go index 14ced55d..c87201e1 100644 --- a/cmd/api/describe.go +++ b/cmd/api/describe.go @@ -42,12 +42,12 @@ func describeSelector(spec openapi.Spec, value string) (openapi.Selector, error) continue } if actions := spec.Actions(kind); action != "" && !slices.Contains(actions, action) { - return openapi.Selector{}, fmt.Errorf("the API docs list no action %q of %s, write one of %s", + return openapi.Selector{}, fmt.Errorf("the API docs have no action %q for %s; use one of %s", action, kind, strings.Join(actions, ", ")) } return openapi.Selector{Kind: kind, Action: action}, nil } - return openapi.Selector{}, fmt.Errorf("the API docs list no meshObject kind %q, write one of %s", + return openapi.Selector{}, fmt.Errorf("the API docs have no meshObject kind %q; use one of %s", name, strings.Join(commands, ", ")) } diff --git a/cmd/api/docs.go b/cmd/api/docs.go index 30a9670a..d7bbeaa4 100644 --- a/cmd/api/docs.go +++ b/cmd/api/docs.go @@ -35,17 +35,18 @@ func NewDocs() *cobra.Command { cmd := &cobra.Command{ Use: "api-docs []", Short: "Show what the meshStack API docs say about a request or a meshObject kind", - Long: `Show what the meshStack API docs say about a request, given as 'meshstack api' takes it, or about -the operations of a meshObject kind. + Long: `Show what the meshStack API docs say about a request, given in the same form that 'meshstack api' +takes, or about the operations of a meshObject kind. -Without a path, it lists every operation of the API, or those of the method and version the flags -name. +Without a path, it lists every operation of the API, filtered by --method and --api-version if +given. ---describe names a kind as its command does, buildingblock or the alias bb, and optionally one of -its actions after a dot, as in bb.list. It describes each operation of the kind, in the latest -version it offers. It takes no path, and no flag of the request. +--describe takes a kind by its command name, such as buildingblock or the alias bb, optionally +followed by one of its actions after a dot, as in bb.list. It describes each operation of the kind, +in the latest version that the kind offers. It cannot be combined with a path or with the request +flags. ---output json writes the part of the OpenAPI document instead. +--output json prints that part of the OpenAPI document. --dev uses the development API docs.`, Example: ` meshstack api-docs @@ -71,7 +72,7 @@ version it offers. It takes no path, and no flag of the request. return profileErr } if endpoint.URL == nil { - return fmt.Errorf("no stored profile holds %s; give the path relative to the endpoint", r.target.Redacted()) + return fmt.Errorf("no stored profile matches %s; give the path relative to the endpoint", r.target.Redacted()) } if err = r.cutTo(endpoint); err != nil { return err @@ -120,12 +121,12 @@ version it offers. It takes no path, and no flag of the request. func noOperationError(selector openapi.Selector) error { of := strings.TrimSpace(selector.Method + " " + selector.Path) if of != "" { - of = " of " + of + of = " for " + of } if !selector.ApiVersion.IsZero() { of += " in " + selector.ApiVersion.String() } - return errors.New("the API docs list no operation" + of) + return errors.New("the API docs have no operation" + of) } // methodOrder puts reading before writing. A method the list leaves out, such as PATCH, goes last. diff --git a/cmd/api/profile.go b/cmd/api/profile.go index 49385466..dd1a775d 100644 --- a/cmd/api/profile.go +++ b/cmd/api/profile.go @@ -71,7 +71,7 @@ func profileFor(ctx context.Context, requestURL *url.URL) (endpoint xurl.URL, so return xurl.URL{}, nil, err } if !current { - slog.WarnContext(ctx, fmt.Sprintf("Using profile '%s' for %s, as its endpoint %s holds it, rather than the current profile '%s'", + slog.WarnContext(ctx, fmt.Sprintf("Using profile '%s' for %s, because its endpoint %s matches the URL; the current profile is '%s'", selected, requestURL.Redacted(), selected.Endpoint, profiles.CurrentProfile)) } endpoint = selected.Endpoint @@ -85,6 +85,6 @@ func profileFor(ctx context.Context, requestURL *url.URL) (endpoint xurl.URL, so func notHeldError(profiles profile.Profiles, requestURL *url.URL, endpoint xurl.URL, ofProfile, source string) error { urlEndpoint, _ := profiles.EndpointHolding(requestURL) - return fmt.Errorf("%s is for endpoint %s, not for endpoint %s%s from %s; leave out %s, or give the path relative to %s", + return fmt.Errorf("%s is under endpoint %s, which does not match the endpoint %s%s from %s; leave out %s, or give the path relative to %s", requestURL.Redacted(), urlEndpoint, endpoint, ofProfile, source, source, endpoint) } diff --git a/cmd/api/profile_test.go b/cmd/api/profile_test.go index 13c87c67..2d81a143 100644 --- a/cmd/api/profile_test.go +++ b/cmd/api/profile_test.go @@ -80,8 +80,8 @@ func TestProfileForARequestURL(t *testing.T) { _, _, _, err = resolve(t, "https://localhost:1337/api") - require.ErrorContains(t, err, "https://localhost:1337/api is for endpoint https://localhost:1337, "+ - "not for endpoint https://meshstack.example.com/a of profile 'a' from env "+profile.NameSetting.EnvKey()) + require.ErrorContains(t, err, "https://localhost:1337/api is under endpoint https://localhost:1337, "+ + "which does not match the endpoint https://meshstack.example.com/a of profile 'a' from env "+profile.NameSetting.EnvKey()) }) t.Run("an explicit endpoint fails where it does not hold the URL", func(t *testing.T) { @@ -89,8 +89,8 @@ func TestProfileForARequestURL(t *testing.T) { _, _, _, err := resolve(t, "https://meshstack.example.com/a/api") - require.ErrorContains(t, err, "https://meshstack.example.com/a/api is for endpoint https://meshstack.example.com/a, "+ - "not for endpoint https://localhost:1337 from env "+meshstack.EndpointSetting.EnvKey()) + require.ErrorContains(t, err, "https://meshstack.example.com/a/api is under endpoint https://meshstack.example.com/a, "+ + "which does not match the endpoint https://localhost:1337 from env "+meshstack.EndpointSetting.EnvKey()) }) t.Run("with no profile stored, an explicit endpoint that holds the URL leaves the name of the first profile to the session", func(t *testing.T) { diff --git a/cmd/api/request.go b/cmd/api/request.go index 86735828..b00fe59f 100644 --- a/cmd/api/request.go +++ b/cmd/api/request.go @@ -32,7 +32,7 @@ func (f *requestFlags) register(cmd *cobra.Command, defaultMethod, methodUsage s cmd.Flags().StringArrayVarP(&f.headers, "header", "H", nil, "add a request header, as 'key: value'") f.requestJson.Register(cmd.Flags(), "request-json", "JSON file to send as the request body, or - for stdin") cmd.Flags().StringVar(&f.apiVersion, "api-version", "", "version of the meshObject API, such as v1 or v2-preview, instead of the latest") - cmd.Flags().BoolVar(&f.dev, "dev", false, "read the API docs of what meshStack's develop branch has merged") + cmd.Flags().BoolVar(&f.dev, "dev", false, "use the API docs of meshStack's development version") } type request struct { @@ -50,7 +50,7 @@ func (f *requestFlags) parse(cmd *cobra.Command, args []string) (request, error) for _, line := range f.headers { key, value, ok := strings.Cut(line, ":") if !ok || strings.TrimSpace(key) == "" { - return r, fmt.Errorf("header '%s' is not of 'key: value' format", line) + return r, fmt.Errorf("header '%s' is not in the format 'key: value'", line) } r.header.Add(strings.TrimSpace(key), strings.TrimSpace(value)) } @@ -67,7 +67,7 @@ func (f *requestFlags) parse(cmd *cobra.Command, args []string) (request, error) return r, err } if r.target.Host != "" && !r.target.IsAbs() { - return r, fmt.Errorf("'%s' names a host but no scheme; give a full URL or a path relative to the endpoint", args[0]) + return r, fmt.Errorf("'%s' has a host but no scheme; give a full URL or a path relative to the endpoint", args[0]) } } return r, nil @@ -76,7 +76,7 @@ func (f *requestFlags) parse(cmd *cobra.Command, args []string) (request, error) func (r *request) cutTo(endpoint xurl.URL) error { path, held := endpoint.PathTo(r.target) if !held { - return fmt.Errorf("endpoint %s does not hold %s", endpoint, r.target.Redacted()) + return fmt.Errorf("%s is not under endpoint %s", r.target.Redacted(), endpoint) } r.target = &url.URL{Path: path, RawQuery: r.target.RawQuery} return nil @@ -105,7 +105,7 @@ func (f *requestFlags) selector(r request) (openapi.Selector, error) { } } if len(slices.Compact(slices.Clone(given))) > 1 { - return selector, fmt.Errorf("the Accept and Content-Type headers, --api-version and the body's apiVersion ask for %s", strings.Join(given, ", ")) + return selector, fmt.Errorf("the Accept and Content-Type headers, --api-version and the body's apiVersion request different versions: %s", strings.Join(given, ", ")) } if len(given) > 0 { var err error diff --git a/cmd/auth/login.go b/cmd/auth/login.go index d69f31fa..f3fbb76a 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -41,15 +41,15 @@ func newLogin(profileSources setting.Sources) *cobra.Command { cmd := &cobra.Command{ Use: "login", Short: "Log in to meshStack", - Long: `Log in to meshStack and store the credential in a profile, creating that profile where it -does not exist yet. + Long: `Log in to meshStack and store the credential in a profile. The command creates the profile if +it does not exist yet. -Without a profile named, it asks which stored profile to log in to, among those for the endpoint. -With no flag this is a browser login, and it asks which workspace to work in unless one is named. -A browser login with another access level than the profile's earlier one ends that earlier login -first. +Without --` + internal.ProfileFlag.Name.String() + `, it asks which of the stored profiles for the endpoint to log in to. Without +--apikey or --apitoken, it opens a browser login and asks which workspace to work in. Use +--` + internal.WorkspaceFlag.Name.String() + ` to choose the workspace without the question. If you choose a different access level +than in the profile's previous browser login, the command ends that previous login first. -It ends with what meshstack auth status shows for the new login.`, +Afterwards it prints the same output as meshstack auth status for the new login.`, Args: func(cmd *cobra.Command, args []string) error { if len(args) == 0 { return nil @@ -58,7 +58,7 @@ It ends with what meshstack auth status shows for the new login.`, return fmt.Errorf("an API key id needs an equals sign: write `--%s=%s`", apiKeyFlag.Name, args[0]) } - return fmt.Errorf("%s takes no arguments such as %q; everything comes from flags and the environment", cmd.CommandPath(), args[0]) + return fmt.Errorf("%s takes no arguments (got %q); use flags or environment variables", cmd.CommandPath(), args[0]) }, RunE: func(cmd *cobra.Command, _ []string) (err error) { ctx := cmd.Context() @@ -84,7 +84,7 @@ It ends with what meshstack auth status shows for the new login.`, opts.SettingSources = append(opts.SettingSources, newPromptingSource(setting.ApiToken.EnvKey(), &openStdinFlag, promptedFrom, "API Token")) default: if openStdinFlag.Value { - return fmt.Errorf("--%s reads the secret of --%s or --%s, and a browser login has none", + return fmt.Errorf("--%s works only with --%s or --%s; a browser login has no secret to read", openStdinFlag.Name, apiKeyFlag.Name, apiTokenFlag.Name) } authWith = credential.OidcLoginName @@ -96,7 +96,7 @@ It ends with what meshstack auth status shows for the new login.`, session, storeSession, unlock, err := auth.Login(ctx, authWith, opts) if errors.Is(err, profile.ErrInUse) { // Only the cause, as the setting a lookup failed for adds nothing to do about it. - return fmt.Errorf("cannot log in while %w, such as meshstack profile or another login; quit it and try again", profile.ErrInUse) + return fmt.Errorf("cannot log in, because %w, such as 'meshstack login' or 'meshstack profile'; wait for it to finish, then try again", profile.ErrInUse) } if err != nil { return err diff --git a/cmd/auth/login_test.go b/cmd/auth/login_test.go index 48710073..42717ed6 100644 --- a/cmd/auth/login_test.go +++ b/cmd/auth/login_test.go @@ -67,8 +67,8 @@ func TestProfileSelection(t *testing.T) { { name: "an endpoint no profile is for suggests creating one", selection: profile.Selection{Profiles: []*profile.Profile{second}, Current: "second", Endpoint: &unknown}, - wantErr: "no profile is for endpoint 'https://unknown.example.io', only second (https://b.example.io); " + - "create a new profile for it with `meshstack login --endpoint https://unknown.example.io --profile `", + wantErr: "no profile has the endpoint 'https://unknown.example.io', only second (https://b.example.io); " + + "create a new profile for it with 'meshstack login --endpoint https://unknown.example.io --profile '", }, { name: "an input that ends takes the current profile", @@ -93,7 +93,7 @@ func TestProfileSelection(t *testing.T) { name: "with --stdin a current profile for another endpoint is an error", selection: profile.Selection{Profiles: all, Current: "second", Endpoint: &endpointA}, stdin: true, - wantErr: "name one with --profile", + wantErr: "use --profile to choose one", }, } for _, tt := range tests { @@ -146,7 +146,7 @@ func TestWorkspaceSelection(t *testing.T) { }{ {name: "an answer selects", answers: answer("2"), want: "second"}, {name: "an optional selection answered selects as well", optional: true, answers: answer("2"), want: "second"}, - {name: "an input that ends is an error", answers: answer(""), wantErr: "nothing was entered for the workspace selection: the prompt reached the end of its input; name one with --workspace"}, + {name: "an input that ends is an error", answers: answer(""), wantErr: "nothing was entered for the workspace selection: no more input to read from stdin; use --workspace to choose a workspace"}, {name: "an input that ends takes the profile's default", profileDefault: "second", answers: answer(""), want: "second"}, {name: "an answer selects over the profile's default", profileDefault: "second", answers: answer("1"), want: "first"}, {name: "an optional selection takes an input that ends as none", optional: true, answers: answer("")}, diff --git a/cmd/auth/logout.go b/cmd/auth/logout.go index 8213d256..44f51529 100644 --- a/cmd/auth/logout.go +++ b/cmd/auth/logout.go @@ -20,7 +20,7 @@ func newLogout() *cobra.Command { Long: `Remove this profile's stored credentials. For a browser login, this also ends the login's session at meshStack, so its tokens stop working. Your browser stays signed in to meshStack. -Without a profile named, it asks which stored profile to log out of, among those for the endpoint.`, +Without --` + internal.ProfileFlag.Name.String() + `, it asks which of the stored profiles for the endpoint to log out of.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { ctx := cmd.Context() @@ -29,7 +29,7 @@ Without a profile named, it asks which stored profile to log out of, among those StoredOnly: true, }) if errors.Is(err, profile.ErrNoStoredProfile) { - slog.WarnContext(ctx, "Nothing to log out of, as "+err.Error()) + slog.WarnContext(ctx, "Nothing to log out of: "+err.Error()) return nil } else if err != nil { return err diff --git a/cmd/auth/select_profile.go b/cmd/auth/select_profile.go index 5c010a35..b9a39579 100644 --- a/cmd/auth/select_profile.go +++ b/cmd/auth/select_profile.go @@ -42,20 +42,20 @@ func newProfileSelectionSource(p prompt.Prompt, stdinCarriesSecret bool) setting return "", nil case len(candidates) == 0: // A profile for another endpoint would send its credential there. - return "", fmt.Errorf("no profile is for endpoint '%s', only %s; "+ - "create a new profile for it with `meshstack login --%s %s --%s `", + return "", fmt.Errorf("no profile has the endpoint '%s', only %s; "+ + "create a new profile for it with 'meshstack login --%s %s --%s '", selection.Endpoint, labelProfiles(selection.Profiles), internal.EndpointFlag.Name, selection.Endpoint, internal.ProfileFlag.Name) case stdinCarriesSecret && len(candidates) > 1: if currentIsCandidate { return string(selection.Current), nil } - return "", fmt.Errorf("this login could go to any of %s; name one with --%s, as --%s leaves no stdin to ask on", + return "", fmt.Errorf("several profiles match: %s; use --%s to choose one, because --%s reads the secret from stdin, so the CLI cannot ask which profile to use", labelProfiles(candidates), internal.ProfileFlag.Name, stdinFlagName) } selected, err := prompt.Select(ctx, p, "profile", candidates, isCurrent) if currentIsCandidate && errors.Is(err, prompt.ErrEndOfInput) { - slog.InfoContext(ctx, fmt.Sprintf("Selecting the current profile '%s', as the input ended before an answer", selection.Current)) + slog.InfoContext(ctx, fmt.Sprintf("No answer was given, so the current profile '%s' is used", selection.Current)) return string(selection.Current), nil } if err != nil { diff --git a/cmd/auth/select_workspace.go b/cmd/auth/select_workspace.go index 0f77397d..0bffa11d 100644 --- a/cmd/auth/select_workspace.go +++ b/cmd/auth/select_workspace.go @@ -25,7 +25,7 @@ func newWorkspaceSelectionSource(p prompt.Prompt, optional bool) setting.Fallbac func(ctx context.Context) (string, error) { workspaces, err := setting.WorkspacesFromContext(ctx) if optional && err != nil { - slog.WarnContext(ctx, "No workspace to select, the profile keeps its default workspace: "+err.Error()) + slog.WarnContext(ctx, "Cannot offer workspaces to select, so the profile's default workspace stays unchanged: "+err.Error()) return "", nil //nolint:nilerr // an optional selection goes on without one, as the warning says } if err != nil { @@ -42,13 +42,13 @@ func newWorkspaceSelectionSource(p prompt.Prompt, optional bool) setting.Fallbac if errors.Is(err, prompt.ErrEndOfInput) { switch { case profileDefault != nil: - slog.InfoContext(ctx, fmt.Sprintf("Selecting the profile's default workspace '%s', as the input ended before an answer", profileDefault.Name())) + slog.InfoContext(ctx, fmt.Sprintf("No answer was given, so the profile's default workspace '%s' is used", profileDefault.Name())) return string(profileDefault.Name()), nil case optional: - slog.InfoContext(ctx, "No workspace was selected, as the input ended; the profile keeps its default workspace") + slog.InfoContext(ctx, "No answer was given, so the profile's default workspace stays unchanged") return "", nil default: - return "", fmt.Errorf("%w; name one with --%s", err, internal.WorkspaceFlag.Name) + return "", fmt.Errorf("%w; use --%s to choose a workspace", err, internal.WorkspaceFlag.Name) } } if err != nil { diff --git a/cmd/auth/status.go b/cmd/auth/status.go index 5bbdef9e..2e895fb3 100644 --- a/cmd/auth/status.go +++ b/cmd/auth/status.go @@ -39,12 +39,12 @@ func newStatus() *cobra.Command { var output internal.ShowFlag cmd := &cobra.Command{ Use: "status", - Short: "Show the credential this run authenticates with", - Long: `Show the credential a command run with the same flags and environment would authenticate with: -where it comes from, who it is for, and until when it lasts. It never shows a secret. + Short: "Show which credential commands authenticate with", + Long: `Show which credential other commands with the same flags and environment would authenticate +with: where it comes from, who it is for, and how long it is valid. It never shows a secret. -A browser login is not renewed for this, so its token may show as expired, and it is renewed on -next use.`, +This command does not renew the token of a browser login, so the token may show as expired. The +next command that needs the token renews it.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { session, err := auth.ResolveSession(cmd.Context(), internal.ResolveClientOptions()) diff --git a/cmd/auth/stdin.go b/cmd/auth/stdin.go index 82583ee6..8ff1b88c 100644 --- a/cmd/auth/stdin.go +++ b/cmd/auth/stdin.go @@ -33,7 +33,7 @@ func newPromptingSource(settingEnvKey string, openStdinFlag *internal.Flag[bool] // An error, not an empty value: ResolveSetting reads an empty value as this source // having nothing and falls through to the environment, and a token exported there is // not what was asked for at this prompt. - return "", errors.New("no non-whitespace input provided in prompt") + return "", errors.New("nothing was entered") } return answer, nil }) diff --git a/cmd/auth/token.go b/cmd/auth/token.go index 4407c79b..28dfa724 100644 --- a/cmd/auth/token.go +++ b/cmd/auth/token.go @@ -13,12 +13,13 @@ func newToken() *cobra.Command { return &cobra.Command{ Use: "token", Short: "Print an access token for the meshStack API", - Long: `Print the access token a command run with the same flags and environment would send, renewed -where it is about to expire. Nothing else goes to stdout, so $(meshstack auth token) works. + Long: `Print the access token that other commands with the same flags and environment would send. +The command renews the token first if it is about to expire. It prints nothing else to stdout, so +$(meshstack auth token) works. -Paste it as the Bearer token in the API docs to try a request against your meshStack. The token is -short-lived: run this again once the API answers 401. For scripts, meshstack api renews the token by -itself. A browser login prints a token for the workspace this run resolves.`, +Paste the token as the Bearer token in the API docs to try a request against your meshStack. The +token is short-lived: run this command again once the API returns 401. In scripts, use meshstack +api, which renews the token by itself. For a browser login, the token is for the current workspace.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { session, err := auth.ResolveSession(cmd.Context(), internal.ResolveClientOptions()) diff --git a/cmd/buildingblock/abort_run.go b/cmd/buildingblock/abort_run.go index e0632ed7..24872824 100644 --- a/cmd/buildingblock/abort_run.go +++ b/cmd/buildingblock/abort_run.go @@ -24,10 +24,10 @@ func newAbortRun() *cobra.Command { cmd := &cobra.Command{ Use: "abort-run ", Short: "Abort the latest run of a building block", - Long: `Abort the latest run of a building block, or its dry run when the dry run is newer. + Long: `Abort the latest run of a building block, or its dry run if the dry run is newer. -The command shows the run and asks you to confirm, so it runs only on a terminal. After the abort, -it writes the building block.`, +The command shows the run and asks you to confirm, so it needs a terminal. After the abort, it +prints the building block.`, Example: ` meshstack buildingblock abort-run 0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90 meshstack bb abort-run 0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90`, Args: internal.UuidArg(&buildingBlockUuid), @@ -60,8 +60,8 @@ func (a runAction) abort(ctx context.Context) error { return err } if run.Links.Abort == nil { - return fmt.Errorf("building block %s has the action %s. Its run %s has the status %s, and it cannot be aborted, or you may not abort it", - a.buildingBlockUuid, block.Status.Action, run.Metadata.Uuid, run.Status) + return fmt.Errorf("run %s of building block %s (action %s) has the status %s and cannot be aborted, or you are not allowed to abort it", + run.Metadata.Uuid, a.buildingBlockUuid, block.Status.Action, run.Status) } if err = a.prompt.Printf("Run %d of building block %q is a %s run with the status %s. The building block has the action %s.\n", run.Spec.RunNumber, run.Spec.BuildingBlock.Spec.DisplayName, run.Spec.Behavior, run.Status, block.Status.Action); err != nil { diff --git a/cmd/buildingblock/approve_run.go b/cmd/buildingblock/approve_run.go index 655f31e3..7c4277cc 100644 --- a/cmd/buildingblock/approve_run.go +++ b/cmd/buildingblock/approve_run.go @@ -24,15 +24,15 @@ func newApproveRun() *cobra.Command { cmd := &cobra.Command{ Use: "approve-run ", - Short: "Approve the plan of a building block run that waits for approval", - Long: `Approve the plan of the latest run of a building block, when the run waits for approval. + Short: "Approve the plan of a building block run that is waiting for approval", + Long: `Approve the plan of the latest run of a building block, if the run is waiting for approval. -The command shows the plan and asks you to approve it, so it runs only on a terminal. It approves +The command shows the plan and asks you to approve it, so it needs a terminal. It approves exactly the plan it showed. If meshStack planned the run again in the meantime, the approval fails, and you can run the command again to review the new plan. A building block whose status.action is APPROVE_RUN in "meshstack buildingblock list" has a run -that waits for approval. After the approval, the command writes the building block.`, +that is waiting for approval. After the approval, the command prints the building block.`, Example: ` meshstack buildingblock approve-run 0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90 meshstack bb approve-run 0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90`, Args: internal.UuidArg(&buildingBlockUuid), @@ -64,18 +64,18 @@ func (a runAction) approve(ctx context.Context) error { return err } if run.Links.Approve == nil { - return fmt.Errorf("building block %s has the action %s. Its latest run %s does not wait for approval, or you may not approve it", - a.buildingBlockUuid, block.Status.Action, run.Metadata.Uuid) + return fmt.Errorf("the latest run %s of building block %s (action %s) is not waiting for approval, or you are not allowed to approve it", + run.Metadata.Uuid, a.buildingBlockUuid, block.Status.Action) } if run.Links.Predecessor == nil { - return fmt.Errorf("run %s waits for approval, but meshStack names no run that planned it", run.Metadata.Uuid) + return fmt.Errorf("run %s is waiting for approval, but meshStack does not say which run created its plan", run.Metadata.Uuid) } plan, err := a.meshStack.BuildingBlockRunAction.ReadRun(ctx, *run.Links.Predecessor) if err != nil { return err } if plan.Links.DownloadLogs == nil { - return fmt.Errorf("meshStack shows you no logs of run %s, which planned run %s", plan.Metadata.Uuid, run.Metadata.Uuid) + return fmt.Errorf("cannot read the logs of run %s, which created the plan for run %s", plan.Metadata.Uuid, run.Metadata.Uuid) } logs, err := a.meshStack.BuildingBlockRunAction.ReadLogs(ctx, *plan.Links.DownloadLogs) if err != nil { @@ -85,7 +85,7 @@ func (a runAction) approve(ctx context.Context) error { if err = buildingblockrun.WriteLogs(&shown, logs); err != nil { return err } - if err = a.prompt.Printf("Run %d of building block %q waits for approval of the plan of run %s:\n\n%s\n", + if err = a.prompt.Printf("Run %d of building block %q is waiting for approval of the plan from run %s:\n\n%s\n", run.Spec.RunNumber, run.Spec.BuildingBlock.Spec.DisplayName, plan.Metadata.Uuid, shown.String()); err != nil { return err } @@ -103,7 +103,7 @@ func (a runAction) approve(ctx context.Context) error { case gohttp.StatusConflict: return fmt.Errorf("the plan of run %s changed after you saw it. Run the command again to review the new plan", run.Metadata.Uuid) case gohttp.StatusBadRequest: - return fmt.Errorf("run %s no longer waits for approval", run.Metadata.Uuid) + return fmt.Errorf("run %s is no longer waiting for approval", run.Metadata.Uuid) } } if err != nil { diff --git a/cmd/buildingblock/approve_run_internal_test.go b/cmd/buildingblock/approve_run_internal_test.go index 87e76ce2..b4b9e60d 100644 --- a/cmd/buildingblock/approve_run_internal_test.go +++ b/cmd/buildingblock/approve_run_internal_test.go @@ -75,7 +75,7 @@ func TestApproveRunApprovesOnlyThePlanItShowed(t *testing.T) { cmd.SetErr(io.Discard) cmd.SetArgs([]string{"approve-run", buildingBlockUuid}) - require.EqualError(t, cmd.ExecuteContext(t.Context()), "approve-run asks you before it acts, so it runs only on a terminal") + require.EqualError(t, cmd.ExecuteContext(t.Context()), "approve-run needs a terminal, because it asks you to confirm") assert.Empty(t, server.TakeRequests()) }) diff --git a/cmd/buildingblock/buildingblock.go b/cmd/buildingblock/buildingblock.go index a2c652f3..084fd853 100644 --- a/cmd/buildingblock/buildingblock.go +++ b/cmd/buildingblock/buildingblock.go @@ -53,7 +53,7 @@ func newRunAction(cmd *cobra.Command, buildingBlockUuid uuid.UUID, format intern p := prompt.New(cmd.InOrStdin(), cmd.ErrOrStderr()) // A script cannot approve or abort: the decision belongs to a person who saw the run. if !p.UsesTerminal() { - return runAction{}, fmt.Errorf("%s asks you before it acts, so it runs only on a terminal", cmd.Name()) + return runAction{}, fmt.Errorf("%s needs a terminal, because it asks you to confirm", cmd.Name()) } meshStack, err := internal.ResolveClient(cmd.Context()) if err != nil { diff --git a/cmd/buildingblock/list.go b/cmd/buildingblock/list.go index 7650d214..3937c2c4 100644 --- a/cmd/buildingblock/list.go +++ b/cmd/buildingblock/list.go @@ -13,8 +13,8 @@ func newList() *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "List building blocks, newest first", - Long: `List the building blocks of the workspace, newest first. A session that works in no workspace -lists every building block the credential can see.`, + Long: `List the building blocks of the workspace, newest first. If no workspace is set, the command +lists every building block that your credential can access.`, Example: ` meshstack buildingblock list --workspace my-workspace meshstack bb list --limit unlimited -o ndjson`, Args: cobra.NoArgs, diff --git a/cmd/buildingblock/tfstate/exec.go b/cmd/buildingblock/tfstate/exec.go index 0b3f6839..729cd1ca 100644 --- a/cmd/buildingblock/tfstate/exec.go +++ b/cmd/buildingblock/tfstate/exec.go @@ -61,39 +61,41 @@ func newExec() *cobra.Command { cmd := &cobra.Command{ Use: "exec -- [args...]", Short: "Run tofu against the state of a building block", - Long: `Run a command, such as tofu plan, against the state of a building block, named by its uuid. - -exec serves the state to tofu's http backend through the TF_HTTP_* variables, and exits with the -exit code of the command. The state is the one stored under the workspace of the building block, -metadata.ownedByWorkspace, unless --workspace names another one. - -The module needs a backend "http" block, which the runner adds only while it runs. ---override-backend adds one while the command runs: it writes the file -` + tfstate.BackendOverrideFile + ` into the directory that tofu -chdir names, or else into the -working directory, where it also replaces a backend of the module's own, and removes the file when -the command ends. Run a command that wraps tofu in the module's directory, and run tofu init through -exec with --override-backend once. - -tofu's meshstack provider talks to meshStack through exec as well, with the login of the CLI. This -works with any version of the provider, as long as its provider "meshstack" block sets no endpoint -and no credentials, which would win over what exec gives it. - ---mode read, the default, refuses to store a state, and lets the provider only read. It takes no -lock, so it never makes a run of the building block wait, and a plan may read a state that a run is -changing. - ---mode readwrite stores what the command writes, and locks the state in meshStack whenever tofu -asks for a lock. A run of the building block waits for that lock for up to 5 minutes, and then -fails. Unless --force, --mode readwrite refuses - - while a run of the building block is pending, in progress or waits for approval; - - a state whose lineage is not that of the stored state, or whose serial is not above it. -With --force, you can stop a destructive run that someone approved by mistake: while tofu holds -the lock, the run waits for it and then fails. -With --backup-dir, it copies the stored state into that directory before it replaces or deletes it.`, + Long: `Run a command, such as tofu plan, against the state of a building block. Give the building +block by its uuid. + +exec passes the state to tofu's http backend through the TF_HTTP_* variables. It exits with the exit +code of the command. By default, it uses the state stored in the building block's own workspace, +metadata.ownedByWorkspace. Use --` + internal.WorkspaceFlag.Name.String() + ` to choose another workspace. + +The module needs a backend "http" block. The runner adds this block only during its own runs. With +--override-backend, exec adds the block while the command runs: it writes the file +` + tfstate.BackendOverrideFile + ` into the directory given by tofu -chdir, or into the working +directory if there is no -chdir. In that directory, the file also replaces any backend that the +module defines. exec removes the file when the command ends. If the command is a wrapper that runs +tofu, run it in the module's directory. Run tofu init through exec with --override-backend once. + +tofu's meshstack provider also connects to meshStack through exec, with the login of the meshStack +CLI. This works with any version of the provider, as long as its provider "meshstack" block sets no +endpoint and no credentials. Settings in that block take precedence over the ones exec provides. + +--mode read is the default. In this mode, exec refuses to store a state, and the provider can only +read. exec takes no lock, so a run of the building block never has to wait for it. A plan may read +a state that a run is changing at the same time. + +--mode readwrite stores what the command writes, and locks the state in meshStack whenever tofu asks +for a lock. A run of the building block waits up to 5 minutes for that lock, and then fails. Without +--force, --mode readwrite fails in these cases: + - a run of the building block is pending, in progress or waiting for approval; + - the new state has another lineage than the stored state, or its serial is not higher. +With --force, you can stop a destructive run that someone approved by mistake: while tofu holds the +lock, the run waits for it and then fails. +With --backup-dir, exec copies the stored state into that directory before it replaces or deletes +it.`, Example: ` meshstack bb tfstate exec 0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90 --mode readwrite -- tofu apply`, Args: func(cmd *cobra.Command, args []string) error { if cmd.ArgsLenAtDash() != 1 || len(args) < 2 { - return fmt.Errorf("%s takes the building block uuid, then -- and the command to run, such as `%s -- tofu plan`", + return fmt.Errorf("%s takes the building block uuid, then -- and the command to run, such as '%s -- tofu plan'", cmd.CommandPath(), cmd.CommandPath()) } command = args[1:] @@ -173,11 +175,11 @@ With --backup-dir, it copies the stored state into that directory before it repl switch { case requests > 0: case overrideBackend: - slog.WarnContext(ctx, fmt.Sprintf("%s asked for no state, although --override-backend added the backend in %s. Run a command that wraps tofu in the module's directory", + slog.WarnContext(ctx, fmt.Sprintf("%s did not request any state, although --override-backend added the backend in %s. If the command is a wrapper that runs tofu, run it in the module's directory", command[0], tfstate.ModuleDir(command))) default: - slog.WarnContext(ctx, fmt.Sprintf("%s asked for no state, so the module likely has no backend \"http\" block, which the runner adds only while it runs. "+ - "Run again with --override-backend, and run `%s %s --override-backend -- tofu init` once", + slog.WarnContext(ctx, fmt.Sprintf("%s did not request any state. The module probably has no backend \"http\" block, because the runner adds one only during its own runs. "+ + "Run again with --override-backend, and run '%s %s --override-backend -- tofu init' once", command[0], cmd.CommandPath(), buildingBlockUuid)) } return exitWith(cmd, ran, withRightsHint(errors.Join(problems...))) @@ -185,10 +187,10 @@ With --backup-dir, it copies the stored state into that directory before it repl } flags := cmd.Flags() - flags.Var(&access, "mode", "read serves the state, readwrite stores what the command writes as well") - flags.BoolVar(&force, "force", false, "store the state even while a run of the building block is pending, in progress or waits for approval, or where it does not follow the stored state") - flags.BoolVar(&overrideBackend, "override-backend", false, "add the backend \"http\" block to the module in tofu's -chdir or the working directory while the command runs") - flags.StringVar(&backupDir, "backup-dir", "", "with --mode readwrite, copy the stored state into this directory before each write, in files only you can read, since a state can hold secrets") + flags.Var(&access, "mode", "read: only read the state (default); readwrite: also store what the command writes") + flags.BoolVar(&force, "force", false, "with --mode readwrite, store the state even while a run of the building block is pending, in progress or waiting for approval, or when the new state does not follow the stored state") + flags.BoolVar(&overrideBackend, "override-backend", false, "while the command runs, add a backend \"http\" block to the module in tofu's -chdir directory or in the working directory") + flags.StringVar(&backupDir, "backup-dir", "", "with --mode readwrite, copy the stored state into this directory before each write; only you can read the copies, because a state can hold secrets") return cmd } diff --git a/cmd/buildingblock/tfstate/force_unlock.go b/cmd/buildingblock/tfstate/force_unlock.go index a082891a..6a9d6ed0 100644 --- a/cmd/buildingblock/tfstate/force_unlock.go +++ b/cmd/buildingblock/tfstate/force_unlock.go @@ -21,17 +21,18 @@ func newForceUnlock() *cobra.Command { cmd := &cobra.Command{ Use: "force-unlock ", - Short: "Release a lock on the state of a building block that nobody releases", - Long: `Release the lock on the state of a building block, named by its uuid, that its holder left -behind, such as a tofu process that was killed. Unlike tofu force-unlock, it needs no module -directory and no lock ID: it names the holder, and asks before it releases the lock it found. Only -the answer yes releases it; a script can pipe yes to stdin. + Short: "Release a stale lock on the state of a building block", + Long: `Release a lock on the state of a building block that its holder left behind, for example +because a tofu process was killed. Give the building block by its uuid. Unlike tofu force-unlock, +this command needs no module directory and no lock ID. It shows who holds the lock, and asks before +it releases the lock. Only the answer yes releases the lock; a script can pipe yes to stdin. -It refuses to release the lock of a run of the building block that is pending or in progress, -unless --force: that run is still writing the state. --force does not skip the question. +If the lock belongs to a run of the building block that is pending or in progress, the command +refuses to release it, because that run is still writing the state. Use --force to release it +anyway. --force does not skip the question. -The state is the one stored under the workspace of the building block, metadata.ownedByWorkspace, -unless --workspace names another one.`, +By default, the command uses the state stored in the building block's own workspace, +metadata.ownedByWorkspace. Use --` + internal.WorkspaceFlag.Name.String() + ` to choose another workspace.`, Example: ` meshstack bb tfstate force-unlock 0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90 echo yes | meshstack bb tfstate force-unlock 0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90 --force`, Args: internal.UuidArg(&buildingBlockUuid), @@ -60,14 +61,14 @@ unless --workspace names another one.`, } } p := prompt.New(cmd.InOrStdin(), cmd.ErrOrStderr()) - if err = p.Printf("Release the lock %s? Only yes releases it: ", lock.Info.ID); err != nil { + if err = p.Printf("Release the lock %s? Type yes to release it: ", lock.Info.ID); err != nil { return err } answer, err := p.Next(ctx, "confirmation") if err != nil { return fmt.Errorf("kept the lock %s: %w", lock.Info.ID, err) } else if answer != "yes" { - return fmt.Errorf("kept the lock %s: the answer was %q, and only yes releases it", lock.Info.ID, answer) + return fmt.Errorf("kept the lock %s: the answer was %q, and only yes releases the lock", lock.Info.ID, answer) } if err = store.Release(ctx, lock); errors.Is(err, tfstate.ErrLockReplaced) { return fmt.Errorf("%w; run again to see the new one", err) @@ -79,7 +80,7 @@ unless --workspace names another one.`, }, } - cmd.Flags().BoolVar(&force, "force", false, "release the lock even of a run that is pending or in progress") + cmd.Flags().BoolVar(&force, "force", false, "release the lock even if it belongs to a run that is pending or in progress") return cmd } diff --git a/cmd/buildingblock/tfstate/show.go b/cmd/buildingblock/tfstate/show.go index 6b4dfe7c..e942553a 100644 --- a/cmd/buildingblock/tfstate/show.go +++ b/cmd/buildingblock/tfstate/show.go @@ -17,11 +17,11 @@ func newShow() *cobra.Command { return &cobra.Command{ Use: "show ", Short: "Show the state of a building block", - Long: `Show the state of a building block, named by its uuid, as meshStack stores it. For a building -block that has no state yet, the command writes nothing and succeeds. + Long: `Show the state of a building block as meshStack stores it. Give the building block by its uuid. +If the building block has no state yet, the command prints nothing and succeeds. -The state is the one stored under the workspace of the building block, metadata.ownedByWorkspace, -unless --workspace names another one.`, +By default, the command uses the state stored in the building block's own workspace, +metadata.ownedByWorkspace. Use --` + internal.WorkspaceFlag.Name.String() + ` to choose another workspace.`, Example: ` meshstack bb tfstate show 0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90 | jq .resources`, Args: internal.UuidArg(&buildingBlockUuid), RunE: func(cmd *cobra.Command, _ []string) error { diff --git a/cmd/buildingblock/tfstate/tfstate.go b/cmd/buildingblock/tfstate/tfstate.go index cc380b74..be7151bc 100644 --- a/cmd/buildingblock/tfstate/tfstate.go +++ b/cmd/buildingblock/tfstate/tfstate.go @@ -15,31 +15,31 @@ import ( ) const rights = `meshStack gives the state to a login that has the rights of one of these rows in the -workspace it works in: +workspace that the login works in: Workspace the login works in To read To write (--mode readwrite) the building block's workspace TFSTATE_LIST TFSTATE_SAVE the workspace owning its definition MANAGED_TFSTATE_LIST MANAGED_TFSTATE_SAVE the admin workspace ADM_TFSTATE_LIST ADM_TFSTATE_SAVE -The roles Workspace Owner and Workspace Manager have the rights of the second row, so a browser login -works where its workspace owns the definition of the building block. Any other row takes an API key -with these rights, meshstack login --apikey. Locking the state, and force-unlock, take the right to -write it. To delete the state, the login needs TFSTATE_DELETE, MANAGED_TFSTATE_DELETE or -ADM_TFSTATE_DELETE in the same row.` +The roles Workspace Owner and Workspace Manager have the rights of the second row. So a browser +login works if its workspace owns the definition of the building block. For the other rows, you need +an API key with these rights; log in with meshstack login --apikey. Locking the state and +force-unlock need the right to write the state. To delete the state, the login needs +TFSTATE_DELETE, MANAGED_TFSTATE_DELETE or ADM_TFSTATE_DELETE in the same row.` func New() *cobra.Command { cmd := &cobra.Command{ Use: "tfstate", - Short: "Read and write the OpenTofu state meshStack keeps for a building block", - Long: `Read and write the OpenTofu state that meshStack keeps for a building block, which its runner -reads and writes through tofu's http backend. + Short: "Read and write the OpenTofu state that meshStack stores for a building block", + Long: `Read and write the OpenTofu state that meshStack stores for a building block. The runner of the +building block reads and writes this state through tofu's http backend. ` + rights + ` -"meshstack buildingblock tfstate exec" serves the state to tofu's http backend, so the module needs -a backend "http" block. The runner adds one only while it runs, and exec with --override-backend -adds one while its command runs. Run tofu init through it once: +"meshstack buildingblock tfstate exec" passes the state to tofu's http backend, so the module needs +a backend "http" block. The runner adds this block only during its own runs. exec with +--override-backend adds it while its command runs. Run tofu init through exec once: meshstack buildingblock tfstate exec --override-backend -- tofu init`, Example: ` meshstack bb tfstate show 0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90 | jq .resources diff --git a/cmd/buildingblock/tfstate/tfstate_test.go b/cmd/buildingblock/tfstate/tfstate_test.go index c11db7de..e56b2d32 100644 --- a/cmd/buildingblock/tfstate/tfstate_test.go +++ b/cmd/buildingblock/tfstate/tfstate_test.go @@ -200,7 +200,7 @@ func TestTfstate(t *testing.T) { ran := execChild(t, "exit=0") require.NoError(t, ran.err) - assert.Contains(t, ran.log, `asked for no state, so the module likely has no backend \"http\" block`) + assert.Contains(t, ran.log, `did not request any state. The module probably has no backend \"http\" block`) assert.Contains(t, ran.log, "--override-backend -- tofu init") }) @@ -228,9 +228,9 @@ func TestTfstate(t *testing.T) { t.Run("exec in readwrite mode refuses while a run is in progress or waits for approval, unless forced", func(t *testing.T) { defer func() { meshStack.blockStatus["action"] = "NO_ACTION" }() for action, refusal := range map[string]string{ - "IN_PROGRESS": "has a run IN_PROGRESS, which writes the state as well", - "PENDING": "has a run PENDING, which writes the state as well", - "APPROVE_RUN": "has a run that waits for approval, which writes the state once approved", + "IN_PROGRESS": "has a run that is IN_PROGRESS and also writes the state", + "PENDING": "has a run that is PENDING and also writes the state", + "APPROVE_RUN": "has a run that is waiting for approval and writes the state once approved", } { meshStack.blockStatus["action"] = action @@ -288,7 +288,7 @@ func TestForceUnlock(t *testing.T) { lockOfTheRun := []byte(`{"lockInfo":{"ID":"the-lock","Operation":"OperationTypeApply","Who":"building block run ` + runUuid + `"},` + `"holder":{"runUuid":"` + runUuid + `","principal":"the runner"},"createdOn":"2026-10-05T09:00:00Z"}`) - const question = "Release the lock the-lock? Only yes releases it: " + const question = "Release the lock the-lock? Type yes to release it: " t.Run("refuses to release the lock of a run in progress, before it asks", func(t *testing.T) { lock = lockOfTheRun @@ -296,7 +296,7 @@ func TestForceUnlock(t *testing.T) { refused := runAnswering(t, "yes\n", "force-unlock", buildingBlockUuid) require.Error(t, refused.err) - assert.Contains(t, refused.err.Error(), "building block run "+runUuid+", which holds the lock, is IN_PROGRESS") + assert.Contains(t, refused.err.Error(), "building block run "+runUuid+" holds the lock, is IN_PROGRESS") assert.Contains(t, refused.log, "is locked by building block run "+runUuid) assert.NotContains(t, refused.stderr, question) assert.NotNil(t, lock) diff --git a/cmd/buildingblock/trigger_run.go b/cmd/buildingblock/trigger_run.go index 40dc05cd..f2bd5bdc 100644 --- a/cmd/buildingblock/trigger_run.go +++ b/cmd/buildingblock/trigger_run.go @@ -22,16 +22,16 @@ func newTriggerRun() *cobra.Command { cmd := &cobra.Command{ Use: "trigger-run ", Short: "Ask meshStack to run a building block", - Long: `Ask meshStack to run a building block, named by its uuid, which "meshstack buildingblock list" -reports as metadata.uuid. + Long: `Ask meshStack to run a building block. Give the building block by its uuid; "meshstack +buildingblock list" shows it as metadata.uuid. -The command waits until meshStack has started the run, and writes the building block, whose -status.latestRunUuid names the run. Where the definition asks for an approval of the run, -status.latestDryRunUuid names the preflight run, whose plan the approver sees. +The command waits until meshStack has started the run, and then prints the building block. Its +status.latestRunUuid is the run. If the definition requires an approval of the run, +status.latestDryRunUuid is the preflight run, whose plan the approver sees. "meshstack buildingblockrun logs " shows how far a run got. -With --wait, the command waits until the run has ended, the same as piping the answer of -meshStack to the request for the run into "meshstack buildingblock wait --before=-". -"meshstack api-docs --describe buildingblock.trigger-run" shows the fields of the answer.`, +With --wait, the command waits until the run has ended. This is the same as piping meshStack's +response to the run request into "meshstack buildingblock wait --before=-". +"meshstack api-docs --describe buildingblock.trigger-run" shows the fields of the response.`, Example: ` meshstack buildingblock trigger-run 0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90 meshstack bb trigger-run 0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90 -o ndjson | jq -r .status.latestRunUuid`, Args: internal.UuidArg(&buildingBlockUuid), @@ -69,7 +69,7 @@ meshStack to the request for the run into "meshstack buildingblock wait - } output.RegisterForItem(cmd.Flags()) - cmd.Flags().BoolVar(&wait, "wait", false, "wait until the run has ended, and fail when it failed or waits for someone") + cmd.Flags().BoolVar(&wait, "wait", false, "wait until the run has ended, and fail if the run failed or is waiting for a person or a dependency") return cmd } @@ -77,10 +77,10 @@ meshStack to the request for the run into "meshstack buildingblock wait - func startedRunMessage(buildingBlockUuid uuid.UUID, accepted, reacted client.MeshBuildingBlockV2Status) string { switch dryRun, run := reacted.GetLatestDryRunUuid(), reacted.GetLatestRunUuid(); { case dryRun != "" && dryRun != accepted.GetLatestDryRunUuid(): - return fmt.Sprintf("meshStack started the preflight run %s of building block %s. A run that changes something waits for approval "+ - "after it. Follow it with `meshstack buildingblockrun logs %s`", dryRun, buildingBlockUuid, dryRun) + return fmt.Sprintf("meshStack started the preflight run %s of building block %s. The run that makes the changes then waits for approval. "+ + "Follow the preflight run with 'meshstack buildingblockrun logs %s'", dryRun, buildingBlockUuid, dryRun) case run != "" && run != accepted.GetLatestRunUuid(): - return fmt.Sprintf("meshStack started run %s of building block %s. Follow it with `meshstack buildingblockrun logs %s`", + return fmt.Sprintf("meshStack started run %s of building block %s. Follow it with 'meshstack buildingblockrun logs %s'", run, buildingBlockUuid, run) default: return fmt.Sprintf("Building block %s now has the action %s.", buildingBlockUuid, reacted.Action) diff --git a/cmd/buildingblock/wait.go b/cmd/buildingblock/wait.go index bfcd17ed..487a36fd 100644 --- a/cmd/buildingblock/wait.go +++ b/cmd/buildingblock/wait.go @@ -24,18 +24,18 @@ func newWait() *cobra.Command { cmd := &cobra.Command{ Use: "wait --before ", Short: "Wait until the run of a building block has ended", - Long: `Wait until meshStack has reacted to a write of a building block, named by its uuid, and until the -run it started has ended. + Long: `Wait until meshStack has reacted to a write of a building block, and until the run that the +write started has ended. Give the building block by its uuid. -meshStack answers a write before it starts the run, so right after the write the building block -still shows the action of the run before. --before names that answer: the response of a create -(POST), an update (PUT) or a trigger-run, or a read taken before a change. The command first waits -up to a minute until meshStack has reacted, by the rules of -https://docs.meshcloud.io/concepts/building-block/#waiting-for-a-run, and then until the run has -ended. Ctrl-C ends the wait. +meshStack responds to a write before it starts the run. So right after the write, the building +block still shows the action of the previous run. Pass that response with --before: the response +of a create (POST), an update (PUT) or a trigger-run, or a read taken before the change. The +command first waits up to a minute until meshStack has reacted, following the rules of +https://docs.meshcloud.io/concepts/building-block/#waiting-for-a-run. Then it waits until the run +has ended. Press Ctrl-C to stop waiting. -The command writes the building block. It fails, and names the action, when the run failed, was -aborted or could not start, or when it waits for a person or a dependency.`, +The command prints the building block. It fails and names the action if the run failed, was aborted +or could not start, or if the run is waiting for a person or a dependency.`, Example: ` meshstack api -X PUT /api/meshobjects/meshbuildingblocks/0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90 --request-json block.json | meshstack bb wait 0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90 --before=-`, Args: internal.UuidArg(&buildingBlockUuid), @@ -67,9 +67,9 @@ func readBefore(cmd *cobra.Command, input internal.JsonInputFlag[client.MeshBuil } switch { case before.Metadata.Uuid != nil && *before.Metadata.Uuid != buildingBlockUuid.String(): - return client.MeshBuildingBlockV2Status{}, fmt.Errorf("--before %s holds building block %s, and the command waits for %s", source, *before.Metadata.Uuid, buildingBlockUuid) + return client.MeshBuildingBlockV2Status{}, fmt.Errorf("--before %s contains building block %s, but the command waits for %s", source, *before.Metadata.Uuid, buildingBlockUuid) case before.Status == nil: - return client.MeshBuildingBlockV2Status{}, fmt.Errorf("--before %s holds no status of the building block", source) + return client.MeshBuildingBlockV2Status{}, fmt.Errorf("--before %s contains no status of the building block", source) } return *before.Status, nil } diff --git a/cmd/buildingblock/wait_test.go b/cmd/buildingblock/wait_test.go index 5f16ea61..2202cb49 100644 --- a/cmd/buildingblock/wait_test.go +++ b/cmd/buildingblock/wait_test.go @@ -60,6 +60,6 @@ func TestWaitFailsOnAnOutcomeThatNeedsSomeoneToAct(t *testing.T) { t.Run("a run that waits for approval fails the wait", func(t *testing.T) { current.Store(new(buildingBlockWithRun("APPROVE_RUN", "run-2"))) _, err := run(before, "wait", waitedBuildingBlock, "--before=-") - require.ErrorContains(t, err, "waits with action APPROVE_RUN") + require.ErrorContains(t, err, "is paused at action APPROVE_RUN") }) } diff --git a/cmd/buildingblockdefinition/list.go b/cmd/buildingblockdefinition/list.go index cc585a95..8583a3b1 100644 --- a/cmd/buildingblockdefinition/list.go +++ b/cmd/buildingblockdefinition/list.go @@ -14,8 +14,8 @@ func newList() *cobra.Command { Use: "list", Short: "List building block definitions, newest first", Long: `List the building block definitions that the workspace owns, and those published to the whole -platform, newest first. A session that works in no workspace lists every definition the credential -can see.`, +platform, newest first. If no workspace is set, the command lists every definition that your +credential can access.`, Example: ` meshstack buildingblockdefinition list meshstack bbd list --workspace my-workspace`, Args: cobra.NoArgs, diff --git a/cmd/buildingblockdefinitionversion/list.go b/cmd/buildingblockdefinitionversion/list.go index acb71aaa..95fd5d7c 100644 --- a/cmd/buildingblockdefinitionversion/list.go +++ b/cmd/buildingblockdefinitionversion/list.go @@ -20,9 +20,9 @@ func newList() *cobra.Command { Short: "List the versions of a building block definition", Long: `List the versions of a building block definition. -The definition is named by its uuid, which "meshstack buildingblockdefinition list" reports as -metadata.uuid. It is required: meshStack serves the versions of one definition at a time, and -reading them takes a permission on the workspace that owns it.`, +Give the definition by its uuid; "meshstack buildingblockdefinition list" shows it as +metadata.uuid. --definition is required, because meshStack returns the versions of one definition +at a time. Reading them needs a permission in the workspace that owns the definition.`, Example: ` meshstack buildingblockdefinitionversion list --definition 3c9e1f7a-2b4d-4e6f-8a1c-5d7b9e2f4a6c meshstack bbdv list --definition 3c9e1f7a-2b4d-4e6f-8a1c-5d7b9e2f4a6c --limit 1`, Args: cobra.NoArgs, diff --git a/cmd/buildingblockrun/list.go b/cmd/buildingblockrun/list.go index cc4071cd..46cc7e2a 100644 --- a/cmd/buildingblockrun/list.go +++ b/cmd/buildingblockrun/list.go @@ -28,9 +28,9 @@ func newList() *cobra.Command { Short: "List building block runs, newest first", Long: `List building block runs, newest first. ---building-block lists that block's runs. Without it, the list holds the runs of every building -block of the workspace, or, in a session that works in no workspace, of every building block the -credential can see.`, +--building-block lists the runs of that building block. Without it, the command lists the runs of +every building block in the workspace. If no workspace is set, it lists the runs of every building +block that your credential can access.`, Example: ` meshstack buildingblockrun list --building-block 0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90 meshstack bbrun list --workspace my-workspace --limit 20`, Args: cobra.NoArgs, diff --git a/cmd/buildingblockrun/logs.go b/cmd/buildingblockrun/logs.go index bd788e6d..46091706 100644 --- a/cmd/buildingblockrun/logs.go +++ b/cmd/buildingblockrun/logs.go @@ -29,11 +29,11 @@ func newLogs() *cobra.Command { Short: "Show the logs of a building block run", Long: `Show the logs of a building block run. -The run is named by its uuid, which "meshstack buildingblockrun list" reports as metadata.uuid. -"meshstack api-docs --describe buildingblockrun.logs" shows the fields of the answer. +Give the run by its uuid; "meshstack buildingblockrun list" shows it as metadata.uuid. +"meshstack api-docs --describe buildingblockrun.logs" shows the fields of the response. ---follow writes what is new as text until the run has finished, and ends with exit status 0 when -the run succeeded and 1 otherwise, so a script can wait on a run with it.`, +--follow prints new log lines as text until the run has finished. It exits with status 0 if the run +succeeded and 1 otherwise, so a script can use it to wait for a run.`, Example: ` meshstack buildingblockrun logs 7f3a2b1c-8d4e-4f6a-9b0c-1d2e3f4a5b6c meshstack bbrun logs 7f3a2b1c-8d4e-4f6a-9b0c-1d2e3f4a5b6c -o ndjson meshstack bbrun logs 7f3a2b1c-8d4e-4f6a-9b0c-1d2e3f4a5b6c --follow`, @@ -58,7 +58,7 @@ the run succeeded and 1 otherwise, so a script can wait on a run with it.`, } output.RegisterForItem(cmd.Flags()) - cmd.Flags().BoolVarP(&follow, "follow", "f", false, "write the log as text as it grows, until the run has finished") + cmd.Flags().BoolVarP(&follow, "follow", "f", false, "keep printing new log lines until the run has finished") cmd.MarkFlagsMutuallyExclusive("follow", "output") return cmd @@ -148,7 +148,7 @@ func followRun(ctx context.Context, w io.Writer, runUuid uuid.UUID, read func(co case runFailed: return run.failure(runUuid) default: - return fmt.Errorf("building block run %s has the status %q, which the meshStack CLI does not know, so it stops following the run", runUuid, run.status) + return fmt.Errorf("stopped following building block run %s, because the meshStack CLI does not know its status %q", runUuid, run.status) } } } diff --git a/cmd/buildingblockrun/logs_test.go b/cmd/buildingblockrun/logs_test.go index 4b0fc951..e063f63c 100644 --- a/cmd/buildingblockrun/logs_test.go +++ b/cmd/buildingblockrun/logs_test.go @@ -118,7 +118,7 @@ func TestFollowEndsWithTheRun(t *testing.T) { {"failed with no steps", finished(runFailed), "building block run 7f3a2b1c-8d4e-4f6a-9b0c-1d2e3f4a5b6c failed"}, { "in a status the CLI does not know", finished("WAITING"), - `building block run 7f3a2b1c-8d4e-4f6a-9b0c-1d2e3f4a5b6c has the status "WAITING", which the meshStack CLI does not know, so it stops following the run`, + `stopped following building block run 7f3a2b1c-8d4e-4f6a-9b0c-1d2e3f4a5b6c, because the meshStack CLI does not know its status "WAITING"`, }, } for _, test := range tests { diff --git a/cmd/eventlog/list.go b/cmd/eventlog/list.go index 0faef1c4..09128060 100644 --- a/cmd/eventlog/list.go +++ b/cmd/eventlog/list.go @@ -26,8 +26,8 @@ func newList() *cobra.Command { --from and --until take a date such as 2026-07-01, which means midnight UTC, or an instant such as 2026-07-01T12:00:00Z. -The list holds the event logs of the workspace. An admin credential in a session that works in no -workspace lists those of every workspace.`, +The command lists the event logs of the workspace. With an admin credential and no workspace set, +it lists the event logs of all workspaces.`, Example: ` meshstack eventlog list --from 2026-07-01 --until 2026-07-02 meshstack elog list --title "Building Block Run" --exclude-title "Building Block Run Executed" meshstack elog list --workspace my-workspace --limit unlimited -o ndjson`, @@ -79,7 +79,7 @@ func (f *instantFlag) Set(value string) error { return nil } } - return fmt.Errorf("%q is no date or instant, write 2026-07-01 or 2026-07-01T12:00:00Z", value) + return fmt.Errorf("%q is not a valid date or instant; use 2026-07-01 or 2026-07-01T12:00:00Z", value) } func (f *instantFlag) Type() string { diff --git a/cmd/eventlog/list_test.go b/cmd/eventlog/list_test.go index 7e9a2646..2327caea 100644 --- a/cmd/eventlog/list_test.go +++ b/cmd/eventlog/list_test.go @@ -55,7 +55,7 @@ func TestTheFiltersOfAnEventLogList(t *testing.T) { t.Run("refuse a malformed date or an empty range before the list asks meshStack", func(t *testing.T) { for args, wantError := range map[string]string{ - "--from 01.07.2026": `"01.07.2026" is no date or instant`, + "--from 01.07.2026": `"01.07.2026" is not a valid date or instant`, "--from 2026-07-02 --until 2026-07-01": "--from 2026-07-02T00:00:00Z is not before --until 2026-07-01T00:00:00Z, so no event log can match", "--from 2026-07-01 --until 2026-07-01": "is not before --until", } { diff --git a/cmd/internal/buildingblock/run_wait.go b/cmd/internal/buildingblock/run_wait.go index 054f2b8e..57ec7780 100644 --- a/cmd/internal/buildingblock/run_wait.go +++ b/cmd/internal/buildingblock/run_wait.go @@ -75,7 +75,7 @@ func (w RunWait) Outcome(block *client.MeshBuildingBlockV2) error { case status.RunFailed(): err = fmt.Errorf("the run of building block %s ended with action %s", w.buildingBlockUuid, status.Action) case status.IsWaitingForInput(): - err = fmt.Errorf("the run of building block %s waits with action %s, and goes on only once someone acts on it", w.buildingBlockUuid, status.Action) + err = fmt.Errorf("the run of building block %s is paused at action %s and continues only after someone acts on it", w.buildingBlockUuid, status.Action) } return withMeshPanelLink(err, block) } diff --git a/cmd/internal/list.go b/cmd/internal/list.go index ff040d6b..e175d97e 100644 --- a/cmd/internal/list.go +++ b/cmd/internal/list.go @@ -130,7 +130,7 @@ func (f *LimitFlag) Set(value string) error { } limit, err := strconv.Atoi(value) if err != nil || limit <= 0 { - return fmt.Errorf("%q is no limit, write a number of items, or unlimited for all of them", value) + return fmt.Errorf("%q is not a valid limit; use a number of items, or unlimited for all of them", value) } *f = LimitFlag(limit) return nil diff --git a/cmd/internal/list_test.go b/cmd/internal/list_test.go index 5da97fb9..6230e260 100644 --- a/cmd/internal/list_test.go +++ b/cmd/internal/list_test.go @@ -76,7 +76,7 @@ func TestTheLimitFlagTakesACountOfItemsOrAll(t *testing.T) { err := limit.Set(value) if wantErr { - assert.ErrorContains(t, err, "is no limit") + assert.ErrorContains(t, err, "is not a valid limit") } else { require.NoError(t, err) assert.Equal(t, value, limit.String()) diff --git a/cmd/internal/markdown/credential.md.tmpl b/cmd/internal/markdown/credential.md.tmpl index 25549a05..4250f86c 100644 --- a/cmd/internal/markdown/credential.md.tmpl +++ b/cmd/internal/markdown/credential.md.tmpl @@ -18,11 +18,11 @@ | | {{template "access" .}} | {{- end}} {{- with .OidcLogin}} -| | {{if .SessionEndsAt.IsZero}}Session with no end known{{else if .SessionEnded}}💤 Session ended {{at .SessionEndsAt}}, run `meshstack login -p {{$.Profile}}`{{else}}Session ends at the latest {{at .SessionEndsAt}}, then run `meshstack login -p {{$.Profile}}`{{end}} | +| | {{if .SessionEndsAt.IsZero}}Session with no end known{{else if .SessionEnded}}💤 Session ended {{at .SessionEndsAt}}, run `meshstack login --profile {{$.Profile}}`{{else}}Session ends at the latest {{at .SessionEndsAt}}, then run `meshstack login --profile {{$.Profile}}`{{end}} | {{- end}} -| | {{with $token}}{{if .Expired}}Token expired {{at .ExpiresAt}}, {{if eq $.Kind "manual"}}cannot be renewed, run `meshstack login -p {{$.Profile}} --apitoken`{{else if and $.OidcLogin $.OidcLogin.SessionEnded}}cannot be renewed{{else}}renewed on next use{{end}} +| | {{with $token}}{{if .Expired}}Token expired {{at .ExpiresAt}}, {{if eq $.Kind "manual"}}cannot be renewed, run `meshstack login --profile {{$.Profile}} --apitoken`{{else if and $.OidcLogin $.OidcLogin.SessionEnded}}cannot be renewed{{else}}renewed on next use{{end}} {{- else}}Token expires {{at .ExpiresAt}}{{end}}{{with .Workspace}}, for workspace {{cell .}}{{end}} -{{- else}}No token cached yet{{if ne .Kind "manual"}}, minted on next use{{end}}{{end}} | +{{- else}}No token cached yet{{if ne .Kind "manual"}}; one is created on next use{{end}}{{end}} | {{- range $i, $unused := .Unused}} | {{if not $i}}Unused credentials{{end}} | • {{.Kind.Label}} {{- with .ClientId}} {{cell .}}{{end}} diff --git a/cmd/internal/markdown/markdown_test.go b/cmd/internal/markdown/markdown_test.go index e76bc982..a5545632 100644 --- a/cmd/internal/markdown/markdown_test.go +++ b/cmd/internal/markdown/markdown_test.go @@ -41,7 +41,7 @@ func TestABrowserLoginShowsItsUserAccessAndSessionEnd(t *testing.T) { "| Workspace | ops |\n"+ "| Credential | Browser login, from file credentials/dev.json |\n"+ "| | User jane (jane@example.com) with Full access, but the token grants Read-only |\n"+ - "| | Session ends at the latest 2000-01-01 03:00 (in 3h), then run `meshstack login -p dev` |\n"+ + "| | Session ends at the latest 2000-01-01 03:00 (in 3h), then run `meshstack login --profile dev` |\n"+ "| | Token expired 1999-12-31 23:59 (1m ago), renewed on next use |", shown) }) } @@ -68,7 +68,7 @@ func TestAnApiKeyShowsItsClientIdAndNoCachedTokenYet(t *testing.T) { "| Endpoint | https://meshstack.example.com |\n"+ "| Workspace | none |\n"+ "| Credential | API key 11111111-45bf-42ba-a965-2097b9d0d181, from env MESHSTACK_API_KEY, env MESHSTACK_API_SECRET |\n"+ - "| | No token cached yet, minted on next use |\n"+ + "| | No token cached yet; one is created on next use |\n"+ "| Unused credentials | • API key 22222222-45bf-42ba-a965-2097b9d0d181 |\n"+ "| | • Browser login jane (jane@example.com), selected by the profile |\n"+ "| | • API token, expired 1999-12-31 23:00 (1h ago) |", shown) @@ -85,7 +85,7 @@ func TestAnExpiredApiTokenSaysItCannotBeRenewed(t *testing.T) { shown, err := Execute(credentialOnly, status) require.NoError(t, err) - assert.Contains(t, shown, "cannot be renewed, run `meshstack login -p run --apitoken`, for workspace ops |") + assert.Contains(t, shown, "cannot be renewed, run `meshstack login --profile run --apitoken`, for workspace ops |") assert.Contains(t, shown, "| Credential | API token of API key 11111111-45bf-42ba-a965-2097b9d0d181, from env MESHSTACK_API_TOKEN |") assert.NotContains(t, shown, "| Workspace |", "the token carries its workspace") } diff --git a/cmd/internal/output.go b/cmd/internal/output.go index f27e099d..28454415 100644 --- a/cmd/internal/output.go +++ b/cmd/internal/output.go @@ -27,13 +27,13 @@ type OutputFlag struct{ Format OutputFormat } func (f *OutputFlag) Register(flags *pflag.FlagSet) { f.Format = OutputJson - flags.VarP(f, "output", "o", fmt.Sprintf("output format: %s for one array, %s for one item per line", OutputJson, OutputNdjson)) + flags.VarP(f, "output", "o", fmt.Sprintf("output format: %s for one array, or %s for one item per line", OutputJson, OutputNdjson)) } // RegisterForItem is Register for a command that writes one object through [OutputFormat.WriteItem]. func (f *OutputFlag) RegisterForItem(flags *pflag.FlagSet) { f.Format = OutputJson - flags.VarP(f, "output", "o", fmt.Sprintf("output format: %s, or %s for one line", OutputJson, OutputNdjson)) + flags.VarP(f, "output", "o", fmt.Sprintf("output format: %s for indented JSON, or %s for JSON on one line", OutputJson, OutputNdjson)) } func (f *OutputFlag) String() string { @@ -47,7 +47,7 @@ func (f *OutputFlag) Set(value string) error { f.Format = format return nil default: - return fmt.Errorf("%q is no output format, write %s or %s", value, OutputJson, OutputNdjson) + return fmt.Errorf("%q is not a valid output format; use %s or %s", value, OutputJson, OutputNdjson) } } @@ -133,7 +133,7 @@ type ShowFlag struct{ Format OutputFormat } func (f *ShowFlag) Register(flags *pflag.FlagSet) { f.Format = OutputMarkdown - flags.VarP(f, "output", "o", fmt.Sprintf("output format: %s, or %s for scripts", OutputMarkdown, OutputJson)) + flags.VarP(f, "output", "o", fmt.Sprintf("output format: %s for people, or %s for scripts", OutputMarkdown, OutputJson)) } func (f *ShowFlag) String() string { @@ -147,7 +147,7 @@ func (f *ShowFlag) Set(value string) error { f.Format = format return nil default: - return fmt.Errorf("%q is no output format, write %s or %s", value, OutputMarkdown, OutputJson) + return fmt.Errorf("%q is not a valid output format; use %s or %s", value, OutputMarkdown, OutputJson) } } @@ -173,5 +173,5 @@ func (f *ShowFlag) Show(w io.Writer, markdownTemplate *template.Template, data a } func (format OutputFormat) unknown() error { - return fmt.Errorf("%q is no output format, write %s or %s", format, OutputJson, OutputNdjson) + return fmt.Errorf("%q is not a valid output format; use %s or %s", format, OutputJson, OutputNdjson) } diff --git a/cmd/internal/prompt/model.go b/cmd/internal/prompt/model.go index 0839f79e..02a30263 100644 --- a/cmd/internal/prompt/model.go +++ b/cmd/internal/prompt/model.go @@ -145,7 +145,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m model) View() tea.View { status := cmp.Or(m.problem, m.warned.String()) if m.number != "" { - status = fmt.Sprintf("Number: %s (Enter takes it, Esc clears it)", m.number) + status = fmt.Sprintf("Number: %s (Enter to select, Esc to clear)", m.number) } more := "" if _, end := m.list.Paginator.GetSliceBounds(len(m.list.VisibleItems())); end < len(m.list.VisibleItems()) { diff --git a/cmd/internal/prompt/prompt.go b/cmd/internal/prompt/prompt.go index 87ebcc45..7f689677 100644 --- a/cmd/internal/prompt/prompt.go +++ b/cmd/internal/prompt/prompt.go @@ -20,7 +20,7 @@ import ( // ErrEndOfInput is what Next returns once the input has ended, which a caller can accept as no // answer at all where one is optional. -var ErrEndOfInput = errors.New("the prompt reached the end of its input") +var ErrEndOfInput = errors.New("no more input to read from stdin") type Prompt struct { input *input diff --git a/cmd/internal/prompt/prompt_test.go b/cmd/internal/prompt/prompt_test.go index d26b03ed..1296f86c 100644 --- a/cmd/internal/prompt/prompt_test.go +++ b/cmd/internal/prompt/prompt_test.go @@ -97,11 +97,11 @@ func TestAskTakesTheDefaultForAnEmptyAnswerAndAsksAgainUntilValid(t *testing.T) {name: "an input that ends without default is empty", wantAsked: "Name: "}, { name: "an input that ends fails where validate refuses the default", validate: notEmpty, - wantErr: "nothing was entered for the name: the prompt reached the end of its input; an answer is needed", wantAsked: "Name: ", + wantErr: "nothing was entered for the name: no more input to read from stdin; an answer is needed", wantAsked: "Name: ", }, { name: "an input that ends after a refused answer fails", validate: notEmpty, answers: "\n", - wantErr: "nothing was entered for the name: the prompt reached the end of its input; an answer is needed", wantAsked: "Name: an answer is needed\nName: ", + wantErr: "nothing was entered for the name: no more input to read from stdin; an answer is needed", wantAsked: "Name: an answer is needed\nName: ", }, } for _, tt := range tests { diff --git a/cmd/internal/read.go b/cmd/internal/read.go index a70ca4d8..7c61c5cc 100644 --- a/cmd/internal/read.go +++ b/cmd/internal/read.go @@ -46,10 +46,10 @@ func (f *JsonInputFlag[T]) Read(cmd *cobra.Command) (value T, err error) { case err != nil: return value, err case !read.IsValid(): - return value, fmt.Errorf("--%s %s is no JSON", f.name, f.Source) + return value, fmt.Errorf("--%s %s is not valid JSON", f.name, f.Source) } if err = json.Unmarshal(read, &value); err != nil { - return value, fmt.Errorf("--%s %s holds JSON of another shape: %w", f.name, f.Source, err) + return value, fmt.Errorf("--%s %s contains JSON of an unexpected shape: %w", f.name, f.Source, err) } return value, nil } diff --git a/cmd/internal/testacc/profile_test.go b/cmd/internal/testacc/profile_test.go index 2628b0f4..b79729cd 100644 --- a/cmd/internal/testacc/profile_test.go +++ b/cmd/internal/testacc/profile_test.go @@ -22,7 +22,7 @@ func TestAccProfiles(t *testing.T) { output, err := c.run("1\n", "login", "--apikey") require.Errorf(t, err, "the login ran while meshstack profile held the profiles:\n%s", output) - assert.Contains(t, err.Error(), "cannot log in while "+profilesInUse+", such as meshstack profile") + assert.Contains(t, err.Error(), "cannot log in, because "+profilesInUse+", such as 'meshstack login'") assert.NoFileExists(t, c.credentialsJson()) release() @@ -59,7 +59,7 @@ func browserLoginHoldsTheProfiles(endpoint string, login devLogin) func(*testing added, err := c.run("", "profile", "add") require.Errorf(t, err, "meshstack profile add ran while the login waited for the browser:\n%s", added) - assert.Contains(t, err.Error(), profilesInUse+", such as a login") + assert.Contains(t, err.Error(), profilesInUse+", such as 'meshstack login'") listed, err := c.run("", "profile", "list") require.NoErrorf(t, err, "meshstack profile list only reads, and runs alongside a login:\n%s", listed) diff --git a/cmd/profile/add.go b/cmd/profile/add.go index e7340ebc..68fcf7a1 100644 --- a/cmd/profile/add.go +++ b/cmd/profile/add.go @@ -13,8 +13,8 @@ func newAdd() *cobra.Command { cmd := &cobra.Command{ Use: "add", Short: "Add a profile", - Long: `Add a profile, in a form on a terminal and line by line otherwise. --profile, --endpoint and ---workspace give the default answers.`, + Long: `Add a profile. In a terminal, a form opens; otherwise, the command asks one question per line. +--` + internal.ProfileFlag.Name.String() + `, --` + internal.EndpointFlag.Name.String() + ` and --` + internal.WorkspaceFlag.Name.String() + ` pre-fill the answers.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { ctx := cmd.Context() diff --git a/cmd/profile/delete.go b/cmd/profile/delete.go index e361cb99..3e60d059 100644 --- a/cmd/profile/delete.go +++ b/cmd/profile/delete.go @@ -13,20 +13,21 @@ import ( ) func newDelete() *cobra.Command { - yesFlag := internal.Flag[bool]{Name: "yes", Help: "delete the profile --profile or --endpoint names without asking"} + yesFlag := internal.Flag[bool]{Name: "yes", Help: "delete the profile selected by --profile or --endpoint without asking for confirmation"} cmd := &cobra.Command{ Use: "delete", Short: "Delete a profile and its stored credentials", Long: `Delete a profile and its stored credentials. For a browser login, this also ends the login's session at meshStack, as meshstack auth logout does. Your browser stays signed in to meshStack. -Without --profile, it asks which profile to delete, among those for the endpoint. It asks before it -deletes, unless --yes goes with --profile, or with an --endpoint that only one profile is for.`, +Without --` + internal.ProfileFlag.Name.String() + `, it asks which of the profiles for the endpoint to delete. It asks for confirmation +before it deletes. Use --yes to skip the confirmation; --yes needs --` + internal.ProfileFlag.Name.String() + `, or an --` + internal.EndpointFlag.Name.String() + ` +that matches exactly one profile.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { ctx := cmd.Context() if yesFlag.Value && !cmd.Flags().Changed(internal.ProfileFlag.Name.String()) && !cmd.Flags().Changed(internal.EndpointFlag.Name.String()) { - return fmt.Errorf("--%s deletes only the profile --%s or --%s names", yesFlag.Name, internal.ProfileFlag.Name, internal.EndpointFlag.Name) + return fmt.Errorf("--%s needs --%s, or an --%s that matches exactly one profile", yesFlag.Name, internal.ProfileFlag.Name, internal.EndpointFlag.Name) } p := prompt.New(cmd.InOrStdin(), cmd.ErrOrStderr()) return withLockedProfiles(ctx, func(profiles profile.Profiles) error { diff --git a/cmd/profile/edit.go b/cmd/profile/edit.go index dc724d4e..557f2288 100644 --- a/cmd/profile/edit.go +++ b/cmd/profile/edit.go @@ -6,6 +6,7 @@ import ( "github.com/spf13/cobra" + "github.com/meshcloud/meshstack-cli/cmd/internal" "github.com/meshcloud/meshstack-cli/cmd/internal/markdown" "github.com/meshcloud/meshstack-cli/cmd/internal/prompt" "github.com/meshcloud/meshstack-cli/internal/profile" @@ -16,11 +17,11 @@ func newEdit() *cobra.Command { cmd := &cobra.Command{ Use: "edit", Short: "Edit a profile", - Long: `Edit a profile, in a form on a terminal and line by line otherwise, where an empty answer keeps -the value. Without --profile, it asks which profile to edit, among those for the endpoint. ---workspace gives the default answer for the default workspace. + Long: `Edit a profile. In a terminal, a form opens; otherwise, the command asks one question per line. +Leave an answer empty to keep the current value. Without --` + internal.ProfileFlag.Name.String() + `, it asks which of the profiles +for the endpoint to edit. --` + internal.WorkspaceFlag.Name.String() + ` pre-fills the default workspace. -A new endpoint removes the stored credentials.`, +Changing the endpoint deletes the stored credentials.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { ctx := cmd.Context() diff --git a/cmd/profile/form.go b/cmd/profile/form.go index ab2c9ad0..b2533e96 100644 --- a/cmd/profile/form.go +++ b/cmd/profile/form.go @@ -52,7 +52,7 @@ type question struct { func (d *draft) questions(profiles profile.Profiles) []question { endpointHint := "The meshStack to log in to." if d.original != nil { - endpointHint += " A new endpoint logs the profile out." + endpointHint += " Changing the endpoint logs the profile out." } // localEndpoint comes last, so that a known https endpoint wins over it for a prefix of both, // such as "http". @@ -68,7 +68,7 @@ func (d *draft) questions(profiles profile.Profiles) []question { }, }, { - title: "Default workspace", description: "Optional. The workspace a command works in unless --workspace names one.", + title: "Default workspace", description: fmt.Sprintf("Default workspace is used for commands if --%s flag is not overriding it", internal.WorkspaceFlag.Name), value: &d.workspace, suggestedLater: true, }, { diff --git a/cmd/profile/model.go b/cmd/profile/model.go index 7c73345d..59082092 100644 --- a/cmd/profile/model.go +++ b/cmd/profile/model.go @@ -377,7 +377,7 @@ func (l login) Run() error { return l.RunE(l.Command, nil) } func (m model) withDeletion(p *profile.Profile) (model, tea.Cmd) { d := &deletion{profile: p} confirm := huh.NewConfirm().Title(fmt.Sprintf("❌ Delete profile '%s'?", p.Name)). - Description("Its stored credentials go as well.").Affirmative("Delete").Negative("Keep").Value(&d.confirmed) + Description("Its stored credentials are deleted too.").Affirmative("Delete").Negative("Keep").Value(&d.confirmed) d.Form = huh.NewForm(huh.NewGroup(confirm)).WithKeyMap(formKeyMap()).WithWidth(50) d.SubmitCmd = func() tea.Msg { return formSubmitted{} } d.CancelCmd = func() tea.Msg { return formCancelled{} } @@ -406,7 +406,7 @@ func (m model) replayBackground() tea.Cmd { // errFormCancelled fails add and edit as the line prompts fail at the end of their input, so that // a script does not go on as though the profile was stored. -var errFormCancelled = errors.New("the form was cancelled, nothing was stored") +var errFormCancelled = errors.New("the form was cancelled, and nothing was saved") func (m model) finish(highlight profile.Name, done string, err error) (model, tea.Cmd) { m.form, m.detailsMarkdown, m.renderedDetails = nil, "", "" diff --git a/cmd/profile/model_test.go b/cmd/profile/model_test.go index 950f9e72..98ef02b1 100644 --- a/cmd/profile/model_test.go +++ b/cmd/profile/model_test.go @@ -305,8 +305,8 @@ func TestAWarningWhileTheProgramRunsShowsInTheViewAndIsLoggedInOrderOnceItEnds(t assert.Contains(t, m.View().Content, "1 warning was logged, read it once you quit: No profile is current now.") assert.Equal(t, []string{ "Profile 'prod' is the current one.", - "No profile is current now. Make one the current one in meshstack profile, " + - "or log in to it with meshstack login --profile .", + "No profile is current now. Choose the current profile in 'meshstack profile', " + + "or log in to a profile with 'meshstack login --profile '.", "Deleted profile 'prod'.", }, logged(captured)) }) diff --git a/cmd/profile/profile.go b/cmd/profile/profile.go index 674faea7..ed6b47b8 100644 --- a/cmd/profile/profile.go +++ b/cmd/profile/profile.go @@ -14,11 +14,12 @@ func New() *cobra.Command { cmd := &cobra.Command{ Use: "profile", Short: "Manage the stored profiles", - Long: `Manage the profiles that meshstack login stores, each an endpoint with its credential and -default workspace. + Long: `Manage your profiles. A profile stores an endpoint, a credential and a default workspace. +meshstack login creates profiles as well. -On a terminal, this lists the profiles to add, edit, delete, or make the current one. Elsewhere it -shows this help: list and show print the profiles, and add, edit and delete ask line by line.`, +In a terminal, this command opens an interactive list where you can add, edit and delete profiles, +and choose the current one. Outside a terminal, it shows this help. Use the subcommands there: list +and show print the profiles, and add, edit and delete ask one question per line.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { ctx := cmd.Context() @@ -60,7 +61,8 @@ type workspaceRefused struct{} func (workspaceRefused) String() string { return "" } func (workspaceRefused) Type() string { return "string" } func (workspaceRefused) Set(string) error { - return fmt.Errorf("has no effect here; set the default workspace of a profile with 'meshstack profile edit --%s'", internal.WorkspaceFlag.Name) + return fmt.Errorf("this command does not use --%s; to set the default workspace of a profile, run 'meshstack profile edit --%s '", + internal.WorkspaceFlag.Name, internal.WorkspaceFlag.Name) } func defaultWorkspaceFlag() internal.Flag[string] { diff --git a/cmd/profile/profile_test.go b/cmd/profile/profile_test.go index 825f0c18..8826ce0b 100644 --- a/cmd/profile/profile_test.go +++ b/cmd/profile/profile_test.go @@ -102,13 +102,13 @@ func TestAddAsksLineByLineWithTheFlagsAsDefaults(t *testing.T) { }, { name: "an input that ends where a question has no valid default adds nothing", - wantErr: "nothing was entered for the endpoint: the prompt reached the end of its input; a profile needs an endpoint", + wantErr: "nothing was entered for the endpoint: no more input to read from stdin; a profile needs an endpoint", wantAsked: "Endpoint: ", }, { name: "an input that ends at a name that is taken adds nothing", args: []string{"--profile", "prod", "--endpoint", "https://c.example.io"}, - wantErr: "nothing was entered for the name: the prompt reached the end of its input; a profile named 'prod' exists already", + wantErr: "nothing was entered for the name: no more input to read from stdin; a profile named 'prod' exists already", wantAsked: "Endpoint [https://c.example.io]: Default workspace: Name [prod]: ", }, } @@ -140,7 +140,7 @@ func TestAddAsksLineByLineWithTheFlagsAsDefaults(t *testing.T) { func TestEditAsksLineByLineWhereAnEmptyAnswerKeepsTheValue(t *testing.T) { const askedForProd = "| Profile | prod |\n| --- | --- |\n| Endpoint | https://b.example.io |\n| Default workspace | ops |\n" + - "| Credential | none, run `meshstack login -p prod` |\n\n" + + "| Credential | none, run `meshstack login --profile prod` |\n\n" + "Endpoint [https://b.example.io]: Default workspace [ops]: Name [prod]: " tests := []struct { name string @@ -227,7 +227,7 @@ func TestDeleteAsksUnlessYesGoesWithAFlagThatNamesTheProfile(t *testing.T) { { name: "--yes deletes nothing without --profile or --endpoint", args: []string{"--yes"}, - wantErr: "--yes deletes only the profile --profile or --endpoint names", + wantErr: "--yes needs --profile, or an --endpoint that matches exactly one profile", wantNames: []profile.Name{"dev", "prod"}, }, { @@ -251,7 +251,7 @@ func TestDeleteAsksUnlessYesGoesWithAFlagThatNamesTheProfile(t *testing.T) { { name: "an endpoint no profile is for is an error", args: []string{"--endpoint", "https://c.example.io", "--yes"}, - wantErr: "no profile is for endpoint 'https://c.example.io'", + wantErr: "no profile has the endpoint 'https://c.example.io'", wantNames: []profile.Name{"dev", "prod"}, }, { @@ -296,7 +296,7 @@ func TestCommandsWithoutADefaultWorkspaceToStoreRefuseWorkspace(t *testing.T) { _, err := execute(t, "", append(args, "-w", "ops")...) - require.ErrorContains(t, err, `invalid argument "ops" for "-w, --workspace" flag: has no effect here`) + require.ErrorContains(t, err, `invalid argument "ops" for "-w, --workspace" flag: this command does not use --workspace`) }) } } @@ -317,7 +317,7 @@ func TestTheCommandsThatChangeTheProfilesFailWhileALoginHoldsThem(t *testing.T) require.ErrorIs(t, err, profile.ErrInUse) require.EqualError(t, err, "another meshstack command is changing the profiles, "+ - "such as a login or another meshstack profile; let it finish and try again") + "such as 'meshstack login' or 'meshstack profile'; wait for it to finish, then try again") }) requireStoredNames(t, "dev", "prod") }) diff --git a/cmd/profile/select_profile.go b/cmd/profile/select_profile.go index cfbb2f65..0d9c9f98 100644 --- a/cmd/profile/select_profile.go +++ b/cmd/profile/select_profile.go @@ -24,7 +24,7 @@ func selectProfile(ctx context.Context, cmd *cobra.Command, p prompt.Prompt, pro } candidates := selection.Candidates() if selection.Endpoint != nil && len(candidates) == 0 { - return nil, false, fmt.Errorf("no profile is for endpoint '%s'", selection.Endpoint) + return nil, false, fmt.Errorf("no profile has the endpoint '%s'", selection.Endpoint) } selected, err = prompt.Select(ctx, p, "profile", candidates, func(candidate *profile.Profile) bool { return candidate.Name == selection.Current diff --git a/cmd/profile/show.go b/cmd/profile/show.go index 9891cee8..59fe72c1 100644 --- a/cmd/profile/show.go +++ b/cmd/profile/show.go @@ -63,10 +63,11 @@ func newShow() *cobra.Command { cmd := &cobra.Command{ Use: "show", Short: "Show a profile and the status of its stored credential", - Long: `Show the profile another command would use, and the status of the credential it stores, which -a flag or the environment would replace there. + Long: `Show the profile that other commands would use, and the status of its stored credential. +Flags and environment variables can override that credential. -A profile that is not stored yet is shown as a first login would create it, and nothing is stored.`, +If the profile does not exist yet, the command shows what the first login would create, without +saving anything.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { ctx := cmd.Context() @@ -80,7 +81,7 @@ A profile that is not stored yet is shown as a first login would create it, and return err } if endpoint, source, err := opts.ResolveSettingWithSource(ctx, meshstack.EndpointSetting); err == nil && !endpoint.Equal(shownProfile.Endpoint) { - slog.WarnContext(ctx, fmt.Sprintf("Profile '%s' is for endpoint '%s', so a command for endpoint '%s' (from %s) fails with it", + slog.WarnContext(ctx, fmt.Sprintf("Profile '%s' has the endpoint '%s', which does not match the endpoint '%s' from %s, so commands fail with this profile", shownProfile.Name, shownProfile.Endpoint, endpoint, source.Describe(meshstack.EndpointSetting.EnvKey()))) } s := showOf(ctx, stored, shownProfile) diff --git a/cmd/profile/show.md.tmpl b/cmd/profile/show.md.tmpl index 3101f905..f3b2e372 100644 --- a/cmd/profile/show.md.tmpl +++ b/cmd/profile/show.md.tmpl @@ -6,11 +6,11 @@ | --- | --- | | Endpoint | {{with .Endpoint.URL}}{{cell .}}{{else}}none{{end}} | | Default workspace | {{or .DefaultWorkspace "none"}} | -| Credential | none, run `meshstack login -p {{.Name}}` | +| Credential | none, run `meshstack login --profile {{.Name}}` | {{- end}} {{- if .New}} -This profile is not stored yet, a first login stores it. +This profile is not saved yet. It is saved on the first login. {{- else if .Current}} This is the current profile. diff --git a/cmd/profile/show_test.go b/cmd/profile/show_test.go index 6d02cc75..8925dfe0 100644 --- a/cmd/profile/show_test.go +++ b/cmd/profile/show_test.go @@ -98,7 +98,7 @@ func TestShow(t *testing.T) { require.NoError(t, err) assert.Contains(t, output, "| Profile | dev |\n") - assert.Contains(t, captured.String(), "Profile 'dev' is for endpoint 'https://a.example.io', so a command for endpoint 'https://b.example.io' (from flag --endpoint) fails with it") + assert.Contains(t, captured.String(), "Profile 'dev' has the endpoint 'https://a.example.io', which does not match the endpoint 'https://b.example.io' from flag --endpoint, so commands fail with this profile") }) t.Run("shows the profile a first command would create, without storing it, where no profile is current", func(t *testing.T) { @@ -109,7 +109,7 @@ func TestShow(t *testing.T) { require.NoError(t, err) assert.Contains(t, output, "| Profile | default |\n| --- | --- |\n| Endpoint | https://b.example.io |\n") - assert.Contains(t, output, "This profile is not stored yet, a first login stores it.") + assert.Contains(t, output, "This profile is not saved yet. It is saved on the first login.") requireStoredNames(t, "dev", "prod", "staging") }) @@ -120,7 +120,7 @@ func TestShow(t *testing.T) { require.NoError(t, err) assert.Contains(t, output, "| Profile | default |\n| --- | --- |\n| Endpoint | none |\n| Default workspace | none |\n") - assert.Contains(t, output, "This profile is not stored yet, a first login stores it.") + assert.Contains(t, output, "This profile is not saved yet. It is saved on the first login.") requireStoredNames(t) }) } diff --git a/cmd/profile/store.go b/cmd/profile/store.go index 7b906232..d2e04fe0 100644 --- a/cmd/profile/store.go +++ b/cmd/profile/store.go @@ -28,7 +28,7 @@ func loadLockedProfiles(ctx context.Context) (profile.Profiles, error) { profiles, err := profile.LoadProfiles(ctx, profile.LoadProfilesOptions{SettingSources: internal.SettingSources(), ExclusiveLock: true}) if errors.Is(err, profile.ErrInUse) { // Only the cause, as the setting a lookup failed for adds nothing to do about it. - return profiles, fmt.Errorf("%w, such as a login or another meshstack profile; let it finish and try again", profile.ErrInUse) + return profiles, fmt.Errorf("%w, such as 'meshstack login' or 'meshstack profile'; wait for it to finish, then try again", profile.ErrInUse) } return profiles, err } @@ -45,8 +45,8 @@ func remove(ctx context.Context, profiles *profile.Profiles, name profile.Name) case profiles.CurrentProfile != "": slog.WarnContext(ctx, fmt.Sprintf("Profile '%s' is the current one now, as it is the only one left.", profiles.CurrentProfile)) case len(profiles.Profiles) > 0: - slog.WarnContext(ctx, "No profile is current now. Make one the current one in meshstack profile, "+ - "or log in to it with meshstack login --profile .") + slog.WarnContext(ctx, "No profile is current now. Choose the current profile in 'meshstack profile', "+ + "or log in to a profile with 'meshstack login --"+internal.ProfileFlag.Name.String()+" '.") } return nil } diff --git a/cmd/profile/store_test.go b/cmd/profile/store_test.go index e7268431..37a3e812 100644 --- a/cmd/profile/store_test.go +++ b/cmd/profile/store_test.go @@ -128,8 +128,8 @@ func TestTheStoredProfiles(t *testing.T) { require.NoError(t, profiles.Put(t.Context(), nil, profile.Profile{Name: "staging", Endpoint: endpointC})) require.NoError(t, profiles.Put(t.Context(), nil, profile.Profile{Name: "dev", Endpoint: endpointA})) - assert.Contains(t, removeWarning(t, "prod"), "No profile is current now. Make one the current one in meshstack profile, "+ - "or log in to it with meshstack login --profile .") + assert.Contains(t, removeWarning(t, "prod"), "No profile is current now. Choose the current profile in 'meshstack profile', "+ + "or log in to a profile with 'meshstack login --profile '.") assert.Empty(t, reloadedCurrent(t)) }) diff --git a/cmd/workspace/list.go b/cmd/workspace/list.go index 926e2070..42b68f09 100644 --- a/cmd/workspace/list.go +++ b/cmd/workspace/list.go @@ -15,8 +15,8 @@ func newList() *cobra.Command { Short: "List the workspaces visible from the current workspace, newest first", Long: `List the workspaces visible from the current workspace, newest first. -From the workspace behind meshPanel's admin area, a role that may list every workspace, such as -Organization Admin, lists them all. From any other workspace the list holds that workspace alone.`, +If you work in the admin workspace and have a role that can list all workspaces, such as +Organization Admin, the command lists all workspaces. Otherwise it lists only the current workspace.`, Example: ` meshstack workspace list meshstack ws list --workspace admin-workspace --limit unlimited -o ndjson`, Args: cobra.NoArgs, diff --git a/internal/auth/cache.go b/internal/auth/cache.go index 9475ab5c..70a83756 100644 --- a/internal/auth/cache.go +++ b/internal/auth/cache.go @@ -133,7 +133,7 @@ func (c Credential) read(ctx context.Context) (matched bool, err error) { switch { case file.Version != cacheVersion: // Not an error: the cache only saves minting a token, and a login writes a compatible one. - slog.WarnContext(ctx, fmt.Sprintf("Ignoring cache %s of version %d, as this meshstack reads version %d; run 'meshstack login -p %s' to update it", + slog.WarnContext(ctx, fmt.Sprintf("Ignoring cache %s of version %d, as this meshstack reads version %d; run 'meshstack login --profile %s' to update it", c.cachePath, file.Version, cacheVersion, c.profile)) case file.Identity != c.Identity().Hash: // Debug, not Warn: a credential from the environment next to the profile's own is normal. diff --git a/internal/auth/cache_test.go b/internal/auth/cache_test.go index 683fb297..80b13843 100644 --- a/internal/auth/cache_test.go +++ b/internal/auth/cache_test.go @@ -76,7 +76,7 @@ func TestCache(t *testing.T) { require.NoError(t, auth.CacheFor(p, loggedIn).Load(t.Context())) assert.Nil(t, loggedIn.Cache) assert.Contains(t, captured.String(), "level=WARN") - assert.Contains(t, captured.String(), "run 'meshstack login -p cache-test' to update it") + assert.Contains(t, captured.String(), "run 'meshstack login --profile cache-test' to update it") loggedIn.Cache = withToken(token) require.NoError(t, auth.CacheFor(p, loggedIn).Write(t.Context())) diff --git a/internal/auth/credential/oidclogin.go b/internal/auth/credential/oidclogin.go index ccf2173c..bf177aff 100644 --- a/internal/auth/credential/oidclogin.go +++ b/internal/auth/credential/oidclogin.go @@ -80,7 +80,7 @@ func (oidcLogin *OidcLogin) RefreshCachedToken(ctx context.Context, client http. } else if notNamed, ok := errors.AsType[meshstack.WorkspaceNotNamedError](err); ok { return notNamed } else if err != nil { - return fmt.Errorf("a browser login needs a workspace; name one, or give the profile a default workspace with 'meshstack profile edit' or 'meshstack login --endpoint %s': %w", oidcLogin.Endpoint, err) + return fmt.Errorf("a browser login needs a workspace; use --workspace to choose one, or set the profile's default workspace with 'meshstack profile edit' or 'meshstack login --endpoint %s': %w", oidcLogin.Endpoint, err) } oidcClient, err := oidc.NewClient(ctx, client, oidcLogin.Issuer, oidcLogin.ClientId) if err != nil { diff --git a/internal/auth/credential_manual.go b/internal/auth/credential_manual.go index 70f7ec5d..453a593b 100644 --- a/internal/auth/credential_manual.go +++ b/internal/auth/credential_manual.go @@ -17,10 +17,9 @@ var ApiTokenSetting = setting.Setting[jwt.JWT]{ }, Long: func(envKey string) string { return fmt.Sprintf("A meshStack access token to send as it is, also read from `%s`.\n\n"+ - "The token is minted elsewhere, so nothing renews it and nothing asks for a workspace: it "+ - "already carries the one it was minted for. A building block runner's token is the usual "+ - "one to bring here, to see what a run sees, and such a token may not be allowed to list "+ - "workspaces at all.", envKey) + "The meshStack CLI does not renew this token and does not ask for a workspace, because the "+ + "token already belongs to one. A typical use is the token of a building block runner, to see "+ + "what a run sees. Such a token may not be allowed to list workspaces.", envKey) }, Parse: setting.ParseTextUnmarshaler[jwt.JWT], } diff --git a/internal/auth/credentials.go b/internal/auth/credentials.go index ab138dff..d34be7be 100644 --- a/internal/auth/credentials.go +++ b/internal/auth/credentials.go @@ -38,7 +38,7 @@ func (s Session) resolveCredentials(ctx context.Context, opts ResolveSessionOpti case 1: slog.DebugContext(ctx, fmt.Sprintf("Using uniquely resolved credential %s from environment MESHSTACK_* and/or explicit config", resolved[0].Name())) if stored := s.CurrentProfile.Credential; stored != "" { - slog.WarnContext(ctx, fmt.Sprintf("%s set a credential over '%s' stored in profile '%s'", + slog.WarnContext(ctx, fmt.Sprintf("%s overrides the credential '%s' stored in profile '%s'", strings.Join(resolved[0].Sources, " and "), stored, s.CurrentProfile)) } return resolved[0], nil @@ -55,7 +55,7 @@ func (s Session) storedCredential(ctx context.Context, noSourceErrs []error) (Cr currentProfile := s.CurrentProfile if currentProfile.Credential == "" { return Credential{}, errors.Join(append([]error{ - fmt.Errorf("profile '%s' selects no credential; run 'meshstack login -p %s'", currentProfile, currentProfile), + fmt.Errorf("profile '%s' selects no credential; run 'meshstack login --profile %s'", currentProfile, currentProfile), }, noSourceErrs...)...) } creds, err := currentProfile.Credentials(ctx) @@ -64,7 +64,7 @@ func (s Session) storedCredential(ctx context.Context, noSourceErrs []error) (Cr } current := creds.ByName(currentProfile.Credential) if current == nil { - return Credential{}, fmt.Errorf("profile '%s' selects credential '%s', but %s holds none; run 'meshstack login -p %s'", + return Credential{}, fmt.Errorf("profile '%s' selects credential '%s', but %s holds none; run 'meshstack login --profile %s'", currentProfile, currentProfile.Credential, creds.FilePath, currentProfile) } slog.DebugContext(ctx, fmt.Sprintf("Using credential %s of profile %s", currentProfile.Credential, currentProfile)) diff --git a/internal/auth/session.go b/internal/auth/session.go index e61108c2..4f0d5507 100644 --- a/internal/auth/session.go +++ b/internal/auth/session.go @@ -108,8 +108,9 @@ func newSession(ctx context.Context, opts ResolveSessionOptions, exclusiveLock b return Session{}, profile.Profiles{}, err } else if !endpoint.Equal(currentProfile.Endpoint) { // this prevents accidentally sending credentials to the wrong endpoint - return Session{}, profile.Profiles{}, fmt.Errorf("profile '%s' is for endpoint '%s', not for endpoint '%s' of this run; "+ - "select a profile for that endpoint, or log in with a new profile name to create one", currentProfile, currentProfile.Endpoint, endpoint) + return Session{}, profile.Profiles{}, fmt.Errorf("profile '%s' has the endpoint '%s', which does not match the endpoint '%s' of this command; "+ + "use --profile to choose a profile for that endpoint, or create one with 'meshstack login --endpoint %s --profile '", + currentProfile, currentProfile.Endpoint, endpoint, endpoint) } session, err := newSessionFor(ctx, currentProfile, opts) diff --git a/internal/auth/session_test.go b/internal/auth/session_test.go index 5144f760..0b9408e6 100644 --- a/internal/auth/session_test.go +++ b/internal/auth/session_test.go @@ -304,7 +304,7 @@ func TestSessionWarnsWhenTheEnvironmentOverridesTheStoredLogin(t *testing.T) { require.NoError(t, err) lines := captured.Lines(slog.LevelWarn) require.Len(t, lines, 1) - assert.Contains(t, lines[0], "'dev' over 'default' from the current profile") + assert.Contains(t, lines[0], "'dev', which overrides 'default' from the current profile") }) t.Run("a workspace in the environment overrides the default workspace", func(t *testing.T) { @@ -319,6 +319,6 @@ func TestSessionWarnsWhenTheEnvironmentOverridesTheStoredLogin(t *testing.T) { require.NoError(t, err) lines := captured.Lines(slog.LevelWarn) require.Len(t, lines, 1) - assert.Contains(t, lines[0], "'env-workspace' over 'stored-workspace' from the default workspace in profile default") + assert.Contains(t, lines[0], "'env-workspace', which overrides 'stored-workspace' from the default workspace in profile default") }) } diff --git a/internal/auth/status_test.go b/internal/auth/status_test.go index e30f3cb5..1986a487 100644 --- a/internal/auth/status_test.go +++ b/internal/auth/status_test.go @@ -133,7 +133,7 @@ func TestStatusOfAnApiToken(t *testing.T) { assert.Equal(t, testApiKey1.ClientId, status.Token.ClientId) warnings := captured.Lines(slog.LevelWarn) require.Len(t, warnings, 1) - assert.Contains(t, warnings[0], "set a credential over 'manual' stored in profile 'default'") + assert.Contains(t, warnings[0], "overrides the credential 'manual' stored in profile 'default'") }) } diff --git a/internal/meshstack/settings.go b/internal/meshstack/settings.go index 4a7c3c82..81b27869 100644 --- a/internal/meshstack/settings.go +++ b/internal/meshstack/settings.go @@ -24,7 +24,7 @@ var EndpointSetting = setting.Setting[xurl.URL]{ var SkipVersionCheckSetting = setting.Setting[bool]{ Env: "MESHSTACK_SKIP_VERSION_CHECK", Short: func(envKey string) string { - return fmt.Sprintf("Skip the meshStack version and release checks, so a run sends only the requests it needs. Also read from %s.", envKey) + return fmt.Sprintf("Skip the meshStack version check and the check for a newer meshStack CLI release. Also read from %s.", envKey) }, Parse: setting.ParseBool, // An explicit "0", because ParseBool reads every string but a spelling of "no" as true. diff --git a/internal/meshstack/workspace.go b/internal/meshstack/workspace.go index 88d340ea..64951168 100644 --- a/internal/meshstack/workspace.go +++ b/internal/meshstack/workspace.go @@ -36,7 +36,7 @@ type NoWorkspaceToWorkInError struct { func (e NoWorkspaceToWorkInError) Error() string { if e.MayNotList { - return "this login has no workspace to work in, as it may not list workspaces; ask for access to one in meshPanel, or name one with --workspace" + return "this login has no workspace to work in, as it may not list workspaces; ask for access to one in meshPanel, or use --workspace to choose a workspace" } return "this login has no workspace to work in; ask for access to one in meshPanel" } @@ -46,7 +46,7 @@ type WorkspaceNotNamedError struct { } func (e WorkspaceNotNamedError) Error() string { - return "a browser login needs an explicitly set workspace, it is not taken from the profile: " + e.Resolution.Error() + return "a browser login needs a workspace, and the profile's default workspace is not used here: " + e.Resolution.Error() } func (e WorkspaceNotNamedError) Unwrap() error { @@ -69,7 +69,8 @@ const ( ) func (e NoRoleInWorkspaceError) Error() string { - const roleNeeded = "A token for a workspace takes a role in it, also for an Organization Admin, whose login can still read the workspace without --workspace" + const roleNeeded = "To get a token for a workspace, you need a role in that workspace, even as an Organization Admin. " + + "An Organization Admin can still read the workspace without --workspace" switch e.Existence { case DoesNotExist: return fmt.Sprintf("workspace '%s' does not exist; check its identifier", e.Workspace) diff --git a/internal/meshstack/workspaces.go b/internal/meshstack/workspaces.go index aabe5640..b07a2c0d 100644 --- a/internal/meshstack/workspaces.go +++ b/internal/meshstack/workspaces.go @@ -64,7 +64,7 @@ func (ws Workspaces) ProfileDefault(ctx context.Context) (r *MeshWorkspace) { return &item } } - slog.WarnContext(ctx, fmt.Sprintf("Your profile carries the default workspace '%s', which you have no access to currently", ws.ProfileDefaultWorkspace)) + slog.WarnContext(ctx, fmt.Sprintf("You currently have no access to '%s', the default workspace of your profile", ws.ProfileDefaultWorkspace)) return nil } diff --git a/internal/oidc/browser/browser.go b/internal/oidc/browser/browser.go index 20fb5801..de62eede 100644 --- a/internal/oidc/browser/browser.go +++ b/internal/oidc/browser/browser.go @@ -75,8 +75,8 @@ func Login(ctx context.Context, client oidc.Client, previous Previous) (oidc.Tok return token, chosen, nil } if chosen != meshstack.AccessFull { - return oidc.Token{}, "", fmt.Errorf("%s did not grant the access level %q, so this meshStack does not support access levels yet "+ - "and the login would have full access; log in again and choose %q", client.Issuer, chosen.Label(), meshstack.AccessFull.Label()) + return oidc.Token{}, "", fmt.Errorf("this meshStack does not support access levels yet: %s did not grant the access level %q, "+ + "so the login would have full access; log in again and choose %q", client.Issuer, chosen.Label(), meshstack.AccessFull.Label()) } return token, "", nil } diff --git a/internal/profile/name.go b/internal/profile/name.go index 51edc5fb..65df9457 100644 --- a/internal/profile/name.go +++ b/internal/profile/name.go @@ -18,8 +18,8 @@ var NameSetting = setting.Setting[Name]{ Long: func(envKey string) string { return fmt.Sprintf("The profile whose credentials and defaults this run uses, also read from `%s`.\n\n"+ "A profile is a named bundle of endpoint and credential, written by "+ - "`meshstack login` into the meshStack CLI's configuration directory. It supplies each of those "+ - "only where nothing above it did, so it is never an override.\n\n"+ + "`meshstack login` into the meshStack CLI's configuration directory. Flags and environment "+ + "variables take precedence over the values in a profile.\n\n"+ "With no name given, the profile is the one whose endpoint matches the endpoint in use, else the one "+ "the last `meshstack login` selected, else `default`.", envKey) }, diff --git a/internal/profile/select.go b/internal/profile/select.go index 7549c238..cf5fac6f 100644 --- a/internal/profile/select.go +++ b/internal/profile/select.go @@ -98,7 +98,7 @@ func (ps *Profiles) ForEndpoint(endpoint xurl.URL, requestURL *url.URL) (p Profi for _, p := range matching { names = append(names, "'"+string(p.Name)+"'") } - return Profile{}, false, fmt.Errorf("profiles %s are all for endpoint %s of %s, and none is the current profile; name one with --profile or %s", + return Profile{}, false, fmt.Errorf("profiles %s all have the endpoint %s of %s, and none of them is the current profile; use --profile or %s to choose one", strings.Join(names, ", "), endpoint, requestURL.Redacted(), NameSetting.EnvKey()) } diff --git a/internal/setting/resolve.go b/internal/setting/resolve.go index 80ccccb4..9387a28e 100644 --- a/internal/setting/resolve.go +++ b/internal/setting/resolve.go @@ -92,7 +92,7 @@ func (sources Sources) warnOfOverriddenStoredValues(ctx context.Context, key, de if fallback, ok := source.(FallbackSource); ok && fallback.Stored { stored, err := fallback.Lookup(ctx, key) if stored = strings.TrimSpace(stored); err == nil && stored != "" && stored != text { - slog.WarnContext(ctx, fmt.Sprintf("%s sets '%s' over '%s' from the %s", description, text, stored, fallback.Describe(key))) + slog.WarnContext(ctx, fmt.Sprintf("%s sets '%s', which overrides '%s' from the %s", description, text, stored, fallback.Describe(key))) } } } diff --git a/internal/setting/resolve_test.go b/internal/setting/resolve_test.go index 0878ae53..1a3420d2 100644 --- a/internal/setting/resolve_test.go +++ b/internal/setting/resolve_test.go @@ -173,7 +173,7 @@ func TestResolveSettingWarnsWhenTheEnvironmentOverridesAStoredValue(t *testing.T t.Setenv(stored.EnvKey(), "dev") lines := warnings(t, sources) require.Len(t, lines, 1) - assert.Contains(t, lines[0], "env MESHSTACK_TEST_STORED sets 'dev' over 'default' from the current profile") + assert.Contains(t, lines[0], "env MESHSTACK_TEST_STORED sets 'dev', which overrides 'default' from the current profile") }) t.Run("a front end source over the environment is no reason to look up the stored value", func(t *testing.T) { diff --git a/internal/tfstate/buildingblock.go b/internal/tfstate/buildingblock.go index 6b4b2174..8d49ef6b 100644 --- a/internal/tfstate/buildingblock.go +++ b/internal/tfstate/buildingblock.go @@ -43,9 +43,9 @@ func NoRunOf(ctx context.Context, raw *client.RawClient, buildingBlockUuid uuid. switch { case block.Status == nil: case block.Status.IsRunning(): - return fmt.Errorf("building block %s has a run %s, which writes the state as well", buildingBlockUuid, block.Status.Action) + return fmt.Errorf("building block %s has a run that is %s and also writes the state", buildingBlockUuid, block.Status.Action) case block.Status.Action == client.BuildingBlockActionApproveRun: - return fmt.Errorf("building block %s has a run that waits for approval, which writes the state once approved", buildingBlockUuid) + return fmt.Errorf("building block %s has a run that is waiting for approval and writes the state once approved", buildingBlockUuid) } return nil } diff --git a/internal/tfstate/lock.go b/internal/tfstate/lock.go index 2cbd234f..9b5bd114 100644 --- a/internal/tfstate/lock.go +++ b/internal/tfstate/lock.go @@ -47,7 +47,7 @@ func (l Lock) NoUnfinishedRun(ctx context.Context, raw *client.RawClient) error } runUuid, err := uuid.Parse(l.Holder.RunUuid) if err != nil { - return fmt.Errorf("the lock names the run %q, which is no uuid: %w", l.Holder.RunUuid, err) + return fmt.Errorf("the lock belongs to run %q, which is not a valid uuid: %w", l.Holder.RunUuid, err) } read, err := raw.Get[client.MeshBuildingBlockRun](ctx, runUuid) if err != nil { @@ -58,12 +58,12 @@ func (l Lock) NoUnfinishedRun(ctx context.Context, raw *client.RawClient) error return err } if run.IsRunning() { - return fmt.Errorf("building block run %s, which holds the lock, is %s and still writes the state", runUuid, run.Status) + return fmt.Errorf("building block run %s holds the lock, is %s and is still writing the state", runUuid, run.Status) } return nil } -var ErrLockReplaced = errors.New("the state has another lock now, so the lock to release is released already") +var ErrLockReplaced = errors.New("the lock was already released, and the state now has a different lock") // Release releases lock by the ID it was read with, so that a lock taken since stays. func (s Store) Release(ctx context.Context, lock Lock) error { diff --git a/internal/tfstate/proxy.go b/internal/tfstate/proxy.go index 9673ff8b..6a35ed81 100644 --- a/internal/tfstate/proxy.go +++ b/internal/tfstate/proxy.go @@ -208,7 +208,7 @@ func (p *Proxy) lock(w gohttp.ResponseWriter, r *gohttp.Request) { p.stopLockTimer() warnCtx := context.WithoutCancel(ctx) p.lockTimer = time.AfterFunc(p.LockWarning, func() { - slog.WarnContext(warnCtx, fmt.Sprintf("The command has held the lock on the state of building block %s for %s, and every run of the building block waits until it is released", + slog.WarnContext(warnCtx, fmt.Sprintf("The command has held the lock on the state of building block %s for %s. Every run of the building block waits until the lock is released", p.Store.BuildingBlock, p.LockWarning)) }) } @@ -307,7 +307,7 @@ func (p *Proxy) failOrPassLock(w gohttp.ResponseWriter, r *gohttp.Request, err e } var holder LockInfo _ = json.Unmarshal(httpErr.ResponseBody, &holder) - slog.WarnContext(r.Context(), fmt.Sprintf("meshStack refused to %s the state of building block %s, as %q holds the lock %s on it", + slog.WarnContext(r.Context(), fmt.Sprintf("meshStack refused to %s the state of building block %s, because %q holds the lock %s", action, p.Store.BuildingBlock, holder.Who, holder.ID)) passLock(w, httpErr) } @@ -333,27 +333,27 @@ func (next version) follows(previous *version) error { return nil } if next.Lineage != previous.Lineage { - return fmt.Errorf("the state written has the lineage %s, but the stored state %s, so it is another state", next.Lineage, previous.Lineage) + return fmt.Errorf("the new state has the lineage %s, but the stored state has the lineage %s, so they are different states", next.Lineage, previous.Lineage) } if next.Serial <= previous.Serial { - return fmt.Errorf("the state written has the serial %d, which is not above the serial %d of the stored state, so it misses a write", next.Serial, previous.Serial) + return fmt.Errorf("the new state has the serial %d, which is not higher than the serial %d of the stored state, so it misses a write", next.Serial, previous.Serial) } return nil } func versionsOf(written, stored []byte) (next version, previous *version, err error) { if err = json.Unmarshal(written, &next); err != nil { - return next, nil, fmt.Errorf("the state written is no state of tofu: %w", err) + return next, nil, fmt.Errorf("the new state is not a valid tofu state: %w", err) } if next.Lineage == "" { - return next, nil, errors.New("the state written is no state of tofu, as it has no lineage") + return next, nil, errors.New("the new state is not a valid tofu state, because it has no lineage") } if stored == nil { return next, nil, nil } previous = new(version) if err = json.Unmarshal(stored, previous); err != nil { - return next, nil, fmt.Errorf("the stored state is no state of tofu: %w", err) + return next, nil, fmt.Errorf("the stored state is not a valid tofu state: %w", err) } return next, previous, nil } diff --git a/internal/tfstate/proxy_test.go b/internal/tfstate/proxy_test.go index 3a52a03b..072bc81a 100644 --- a/internal/tfstate/proxy_test.go +++ b/internal/tfstate/proxy_test.go @@ -164,10 +164,10 @@ func TestAWritableProxy(t *testing.T) { t.Run("refuses a state that does not follow the stored one", func(t *testing.T) { for written, wantProblem := range map[string]string{ - `{"version":4,"serial":2,"lineage":"lineage-2"}`: "the state written has the lineage lineage-2, but the stored state lineage-1, so it is another state", - firstState: "the state written has the serial 1, which is not above the serial 1 of the stored state, so it misses a write", - `{"version":4,"serial":0,"lineage":"lineage-1"}`: "the state written has the serial 0, which is not above the serial 1 of the stored state, so it misses a write", - `{"version":4,"serial":2}`: "the state written is no state of tofu, as it has no lineage", + `{"version":4,"serial":2,"lineage":"lineage-2"}`: "the new state has the lineage lineage-2, but the stored state has the lineage lineage-1, so they are different states", + firstState: "the new state has the serial 1, which is not higher than the serial 1 of the stored state, so it misses a write", + `{"version":4,"serial":0,"lineage":"lineage-1"}`: "the new state has the serial 0, which is not higher than the serial 1 of the stored state, so it misses a write", + `{"version":4,"serial":2}`: "the new state is not a valid tofu state, because it has no lineage", } { p.problems = nil assert.Equal(t, gohttp.StatusConflict, p.send(gohttp.MethodPost, written).Code, written) @@ -464,7 +464,7 @@ func TestAWritableProxyPassesTheLockOn(t *testing.T) { assert.Equal(t, gohttp.StatusLocked, w.Code) assert.JSONEq(t, lockInfo("theirs"), w.Body.String()) assert.Empty(t, p.problems, "the client reports the refusal itself") - assert.Contains(t, log.String(), `as \"someone@somewhere\" holds the lock theirs`) + assert.Contains(t, log.String(), `because \"someone@somewhere\" holds the lock theirs`) }) t.Run("with Force, stores a state that does not follow the stored one", func(t *testing.T) { @@ -497,7 +497,7 @@ func TestAWritableProxyPassesTheLockOn(t *testing.T) { t.Run("warns where tofu holds the lock longer than LockWarning, and not after it released it", func(t *testing.T) { p.LockWarning = 20 * time.Millisecond defer func() { p.LockWarning = 0 }() - const warning = "every run of the building block waits until it is released" + const warning = "Every run of the building block waits until the lock is released" p.sendTo(gohttp.MethodPost, lockPath, lockInfo("released")) p.sendTo(gohttp.MethodDelete, lockPath, lockInfo("released")) diff --git a/skills/meshstack-cli/SKILL.md b/skills/meshstack-cli/SKILL.md index 8fdf40fa..50b87648 100644 --- a/skills/meshstack-cli/SKILL.md +++ b/skills/meshstack-cli/SKILL.md @@ -106,7 +106,7 @@ workspace identifier as meshPanel shows it (`metadata.name`, for example - A login works only in a workspace where it holds a role, **also for an Organization Admin**: ``` - Error: this login has no role in workspace 'x'. A token for a workspace takes a role in it, also for an Organization Admin ... + Error: this login has no role in workspace 'x'. To get a token for a workspace, you need a role in that workspace, even as an Organization Admin ... ``` An admin can still read across workspaces from a session in the admin workspace. From fce3278f0fa7f8fcd9ffd3cfd24e720b1fb3e505 Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Fri, 6 Nov 2026 11:19:52 +0100 Subject: [PATCH 2/4] test: check key values only in the acceptance tests The tests check the exit status, the error value and key values such as UUIDs and profile names, so that a reworded message does not break them. The lock tests check profile.ErrInUse, and the browser login requires HTTP 200 from the callback. Co-Authored-By: Claude Opus 5.5 --- cmd/internal/testacc/api_test.go | 6 ++-- cmd/internal/testacc/buildingblockrun_test.go | 2 +- cmd/internal/testacc/login_test.go | 15 ++++------ cmd/internal/testacc/profile_test.go | 13 ++++---- cmd/internal/testacc/tfstate_test.go | 30 ++++++++++--------- 5 files changed, 31 insertions(+), 35 deletions(-) diff --git a/cmd/internal/testacc/api_test.go b/cmd/internal/testacc/api_test.go index 7e285340..f8591162 100644 --- a/cmd/internal/testacc/api_test.go +++ b/cmd/internal/testacc/api_test.go @@ -15,13 +15,13 @@ import ( func apiAsksForJson(c *cli) func(*testing.T) { return func(t *testing.T) { output, err := c.run("", "api", "/api/meshobjects/meshworkspaces", "-H", "Accept: application/json") - require.ErrorContainsf(t, err, "meshStack answered HTTP 406: value in 'Accept' header not supported. (errorCode HttpMediaTypeNotAcceptable, errorId ", + require.ErrorContainsf(t, err, "HttpMediaTypeNotAcceptable", "a meshObject endpoint refuses application/json, output:\n%s", output) - require.ErrorContains(t, err, "). Run meshstack api-docs /api/meshobjects/meshworkspaces -X GET -H 'Accept: application/json' to see what the API takes") + require.ErrorContains(t, err, "meshstack api-docs /api/meshobjects/meshworkspaces -X GET -H 'Accept: application/json'") // A body of no metadata, which meshStack refuses before it creates anything. output, err = c.run(`{}`, "api", "-X", "POST", "/api/meshobjects/meshworkspaces", "--request-json", "-") - require.ErrorContainsf(t, err, "meshStack answered HTTP 400", "output:\n%s", output) + require.ErrorContainsf(t, err, "400", "output:\n%s", output) assert.Contains(t, output, "HttpMessageNotReadable", "--request-json reaches a meshObject endpoint in its media type, which meshStack took to read the body") } } diff --git a/cmd/internal/testacc/buildingblockrun_test.go b/cmd/internal/testacc/buildingblockrun_test.go index c5922297..c0e3eecd 100644 --- a/cmd/internal/testacc/buildingblockrun_test.go +++ b/cmd/internal/testacc/buildingblockrun_test.go @@ -27,7 +27,7 @@ func followOfAFinishedRunWritesItsLogsAndEnds(c *cli) func(*testing.T) { require.NoErrorf(t, err, "following a succeeded run failed:\n%s", followed) default: require.Errorf(t, err, "following a %s run ended in exit status 0:\n%s", status, followed) - assert.Regexp(t, "building block run "+runUuid+" (failed|was aborted)", err.Error()) + assert.Contains(t, err.Error(), runUuid) } for _, step := range stepsOf(t, logs) { assert.Contains(t, followed, step.DisplayName+": "+step.Status+"\n") diff --git a/cmd/internal/testacc/login_test.go b/cmd/internal/testacc/login_test.go index 71bcd35d..c1fc1652 100644 --- a/cmd/internal/testacc/login_test.go +++ b/cmd/internal/testacc/login_test.go @@ -39,9 +39,6 @@ func devLogins(t *testing.T) []devLogin { return logins } -// keycloak lets a login with no workspace in, and the workspace list then answers 403. -const refusedWithoutAWorkspace = "this login has no workspace to work in" - // TestAccBrowserLogin drives the authorization code flow with no browser and no terminal, which is // the shape CI has: the CLI prints the URL of its access level page to stderr and waits on a loopback // listener, so anything that can read stderr and speak HTTP can finish the login. @@ -73,7 +70,6 @@ func TestAccBrowserLogin(t *testing.T) { if len(login.Workspaces) == 0 { require.Errorf(t, run.wait(), "the login stored a credential it cannot use:\n%s", run.output.String()) - assert.Contains(t, run.output.String(), refusedWithoutAWorkspace) assert.NoFileExists(t, c.credentialsJson()) assert.NoFileExists(t, c.credentialsCacheJson("oidcLogin"), "the failed login left its tokens behind") return @@ -126,7 +122,6 @@ func TestAccBrowserLogin(t *testing.T) { output, err := c.run("", "auth", "logout") require.NoErrorf(t, err, "the logout failed:\n%s", output) - assert.NotContains(t, output, "Cannot end the session") refresh := url.Values{"client_id": {"meshstack-cli"}, "grant_type": {"refresh_token"}, "refresh_token": {refreshToken}} req, err := gohttp.NewRequestWithContext(t.Context(), gohttp.MethodPost, issuer+"/protocol/openid-connect/token", strings.NewReader(refresh.Encode())) @@ -354,7 +349,8 @@ func finishKeycloakLogin(t *testing.T, browser *gohttp.Client, page htmlPage, us "accept": {"Yes"}, }) } - require.Containsf(t, page.body, "You are logged in", "the login did not reach the CLI's result page at %s", page.url) + require.Equalf(t, "127.0.0.1", page.url.Hostname(), "the login did not reach the CLI's result page, but %s", page.url) + require.Equalf(t, gohttp.StatusOK, page.status, "the CLI refused the login redirect:\n%s", page.body) return } @@ -407,8 +403,9 @@ func keycloakFeedback(body string) string { type htmlPage struct { // url is where the page was finally served from, which a relative form action resolves against. - url *url.URL - body string + url *url.URL + status int + body string } func fetch(t *testing.T, browser *gohttp.Client, target string) htmlPage { @@ -460,7 +457,7 @@ func read(t *testing.T, browser *gohttp.Client, req *gohttp.Request) htmlPage { body, err := io.ReadAll(resp.Body) require.NoError(t, err) // resp.Request is the last request in the redirect chain, so its URL is the page's own. - return htmlPage{url: resp.Request.URL, body: string(body)} + return htmlPage{url: resp.Request.URL, status: resp.StatusCode, body: string(body)} } func firstSubmatch(t *testing.T, body, pattern string) string { diff --git a/cmd/internal/testacc/profile_test.go b/cmd/internal/testacc/profile_test.go index b79729cd..8bcb7d70 100644 --- a/cmd/internal/testacc/profile_test.go +++ b/cmd/internal/testacc/profile_test.go @@ -7,11 +7,10 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/meshcloud/meshstack-cli/internal/profile" "github.com/meshcloud/meshstack-cli/internal/testutil/testlogin" ) -const profilesInUse = "another meshstack command is changing the profiles" - func TestAccProfiles(t *testing.T) { c := newCLI(t, requireLocalStack(t)).withApiKey() @@ -21,8 +20,7 @@ func TestAccProfiles(t *testing.T) { release := testlogin.HoldProfiles(t) output, err := c.run("1\n", "login", "--apikey") - require.Errorf(t, err, "the login ran while meshstack profile held the profiles:\n%s", output) - assert.Contains(t, err.Error(), "cannot log in, because "+profilesInUse+", such as 'meshstack login'") + require.ErrorIsf(t, err, profile.ErrInUse, "the login ran while meshstack profile held the profiles:\n%s", output) assert.NoFileExists(t, c.credentialsJson()) release() @@ -43,7 +41,7 @@ func TestAccProfiles(t *testing.T) { require.NoErrorf(t, err, "the API key login did not take the current profile:\n%s", output) output, err = c.run("", "auth", "logout") require.NoErrorf(t, err, "the logout did not take the current profile:\n%s", output) - assert.Contains(t, output, "Logged out of profile 'current'") + assert.Contains(t, output, "current") assert.NoFileExists(t, c.credentialsJsonOf("current")) assert.FileExists(t, c.credentialsJsonOf("other")) @@ -58,8 +56,7 @@ func browserLoginHoldsTheProfiles(endpoint string, login devLogin) func(*testing startURL := run.awaitStartURL(t) added, err := c.run("", "profile", "add") - require.Errorf(t, err, "meshstack profile add ran while the login waited for the browser:\n%s", added) - assert.Contains(t, err.Error(), profilesInUse+", such as 'meshstack login'") + require.ErrorIsf(t, err, profile.ErrInUse, "meshstack profile add ran while the login waited for the browser:\n%s", added) listed, err := c.run("", "profile", "list") require.NoErrorf(t, err, "meshstack profile list only reads, and runs alongside a login:\n%s", listed) @@ -67,7 +64,7 @@ func browserLoginHoldsTheProfiles(endpoint string, login devLogin) func(*testing require.NoErrorf(t, run.wait(), "the browser login did not finish:\n%s", run.output.String()) shown, err := c.run("", "profile", "show") require.NoErrorf(t, err, "meshstack profile show failed:\n%s", shown) - assert.Contains(t, shown, "| Credential | Browser login, from file ", "show reads the status of the stored login") + assert.Contains(t, shown, "| Credential | Browser login", "show reads the status of the stored login") } } diff --git a/cmd/internal/testacc/tfstate_test.go b/cmd/internal/testacc/tfstate_test.go index a56c8b63..c702a1d9 100644 --- a/cmd/internal/testacc/tfstate_test.go +++ b/cmd/internal/testacc/tfstate_test.go @@ -20,8 +20,6 @@ import ( "github.com/meshcloud/meshstack-cli/internal/tfstate" ) -const noState = "has no state in workspace" - // TestAccTfstate runs real tofu against the state of a building block that it bootstraps itself, // because the building blocks of the local stack are manual ones and never reach the tf-block-runner. func TestAccTfstate(t *testing.T) { @@ -62,13 +60,13 @@ func TestAccTfstate(t *testing.T) { output, err := c.run("", "buildingblock", "tfstate", "exec", block.uuid, "--", "sh", "-ec", `curl --silent --show-error --fail --request POST --header "Authorization: Bearer $MESHSTACK_API_TOKEN" "$MESHSTACK_ENDPOINT/api/meshobjects/meshworkspaces"`) require.Errorf(t, err, "the exec passed a POST on in read mode:\n%s", output) - assert.Contains(t, output, "run again with --mode readwrite to let the command change meshStack") + assert.Contains(t, output, "--mode readwrite") }) t.Run("exec --mode readwrite applies with tofu, and leaves no lock behind", func(t *testing.T) { output := c.tofuExec(t, block, "readwrite", module, "apply", "-auto-approve", "-input=false") assert.Contains(t, output, "Apply complete!") - assert.Contains(t, c.forceUnlock(t, block), "has no lock") + c.requireNoLock(t, block) }) t.Run("a second exec fails on the lock that the first one holds, and names it", func(t *testing.T) { @@ -91,13 +89,11 @@ func TestAccTfstate(t *testing.T) { output, err = c.run("no\n", "buildingblock", "tfstate", "force-unlock", block.uuid) require.Errorf(t, err, "the force-unlock released the lock without a yes:\n%s", output) - assert.Contains(t, output, "Release the lock left-behind?") - assert.Contains(t, err.Error(), "kept the lock left-behind") + assert.Contains(t, output, "left-behind") + assert.Contains(t, err.Error(), "left-behind") - output = c.forceUnlock(t, block) - assert.Contains(t, output, "lock ID left-behind") - assert.Contains(t, output, "Released the lock left-behind") - assert.Contains(t, c.forceUnlock(t, block), "has no lock") + assert.Contains(t, c.forceUnlock(t, block), "left-behind") + c.requireNoLock(t, block) }) t.Run("concurrent execs take turns on the lock, and none of their writes is lost", func(t *testing.T) { @@ -118,7 +114,7 @@ func TestAccTfstate(t *testing.T) { after := c.storedVersion(t, block) assert.Equal(t, before.Lineage, after.Lineage) assert.Equal(t, before.Serial+execs, after.Serial, "every apply stores one state, on top of the one the apply before stored") - assert.Contains(t, c.forceUnlock(t, block), "has no lock") + c.requireNoLock(t, block) }) t.Run("a run waits for the lock that an exec holds, and succeeds once the exec releases it", func(t *testing.T) { @@ -165,15 +161,14 @@ state --request DELETE --data '` + `LOCK_INFO` + `' "$TF_HTTP_UNLOCK_ADDRESS"` require.NoErrorf(t, err, "the exec failed:\n%s", output) assert.NotContains(t, output, "stored without the lock ID") assert.Contains(t, output, "read "+minimalState, "the GET reads the state the POST stored") - assert.Contains(t, output, "Saved the stored state of building block "+block.uuid, "the POST backs the state up first") backups, err := filepath.Glob(filepath.Join(backupDir, block.uuid+"-*.json")) require.NoError(t, err) assert.NotEmpty(t, backups) output, err = c.run("", "buildingblock", "tfstate", "show", block.uuid) require.NoErrorf(t, err, "the show failed:\n%s", output) - assert.Contains(t, output, noState, "the DELETE through the proxy removed the state") - assert.Contains(t, c.forceUnlock(t, block), "has no lock") + assert.NotContains(t, output, "meshstack-cli-testacc", "the DELETE through the proxy removed the state") + c.requireNoLock(t, block) }) } @@ -318,6 +313,13 @@ func (c *cli) forceUnlock(t *testing.T, block bootstrappedBlock) string { return output } +// requireNoLock answers no question, so force-unlock fails where it finds a lock to release. +func (c *cli) requireNoLock(t *testing.T, block bootstrappedBlock) { + t.Helper() + output, err := c.run("", "buildingblock", "tfstate", "force-unlock", block.uuid) + require.NoErrorf(t, err, "the state is still locked:\n%s", output) +} + func tofuLockInfo(id string) string { return `{"ID":"` + id + `","Operation":"OperationTypeApply","Info":"","Who":"testacc","Version":"1.12.0","Created":"2026-01-01T00:00:00Z","Path":""}` } From 3d90b4bae8c036aec0db89b2232fe33361f2b3de Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Fri, 6 Nov 2026 13:21:19 +0100 Subject: [PATCH 3/4] feat: wrap command help to the terminal width Help wraps to the terminal, up to 100 columns, and to 80 in a pipe. The help texts have no line breaks inside a paragraph any more. Co-Authored-By: Claude Opus 5.5 --- cmd/api/api.go | 11 +- cmd/api/docs.go | 11 +- cmd/auth/login.go | 8 +- cmd/auth/logout.go | 3 +- cmd/auth/status.go | 6 +- cmd/auth/token.go | 8 +- cmd/buildingblock/abort_run.go | 3 +- cmd/buildingblock/approve_run.go | 7 +- cmd/buildingblock/list.go | 3 +- cmd/buildingblock/tfstate/exec.go | 44 +++---- cmd/buildingblock/tfstate/force_unlock.go | 12 +- cmd/buildingblock/tfstate/show.go | 6 +- cmd/buildingblock/tfstate/tfstate.go | 7 +- cmd/buildingblock/trigger_run.go | 13 +-- cmd/buildingblock/wait.go | 13 +-- cmd/buildingblockdefinition/list.go | 4 +- cmd/buildingblockdefinitionversion/list.go | 4 +- cmd/buildingblockrun/list.go | 4 +- cmd/buildingblockrun/logs.go | 6 +- cmd/eventlog/list.go | 6 +- cmd/internal/help.go | 129 +++++++++++++++++++++ cmd/internal/help_test.go | 17 +++ cmd/meshstack/meshstack.go | 13 +-- cmd/profile/add.go | 5 +- cmd/profile/delete.go | 7 +- cmd/profile/edit.go | 4 +- cmd/profile/profile.go | 7 +- cmd/profile/show.go | 6 +- cmd/workspace/list.go | 3 +- 29 files changed, 214 insertions(+), 156 deletions(-) create mode 100644 cmd/internal/help.go create mode 100644 cmd/internal/help_test.go diff --git a/cmd/api/api.go b/cmd/api/api.go index bfcf43b9..f6caabb3 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -28,16 +28,11 @@ func New() *cobra.Command { cmd := &cobra.Command{ Use: "api ", Short: "Send an authorized request to the meshStack API", - Long: `Send an authorized request to a path of the meshStack API, and print the response (typically -JSON). The path is relative to the endpoint. It can also be a full URL, as long as a stored profile -with credentials matches its endpoint. + Long: `Send an authorized request to a path of the meshStack API, and print the response (typically JSON). The path is relative to the endpoint. It can also be a full URL, as long as a stored profile with credentials matches its endpoint. -Use this command for everything that the other commands do not cover. It sets the API version -headers of meshStack's public API for you, unless you set them yourself. +Use this command for everything that the other commands do not cover. It sets the API version headers of meshStack's public API for you, unless you set them yourself. ---request-json sends a JSON file, or stdin for '-'. For a path that has no version in the API docs, -the Content-Type and Accept headers default to application/json. Without the API docs, only the -Content-Type header does. +--request-json sends a JSON file, or stdin for '-'. For a path that has no version in the API docs, the Content-Type and Accept headers default to application/json. Without the API docs, only the Content-Type header does. If the response status is not 2xx, the command still prints the response body, and then fails. diff --git a/cmd/api/docs.go b/cmd/api/docs.go index d7bbeaa4..6a23ef21 100644 --- a/cmd/api/docs.go +++ b/cmd/api/docs.go @@ -35,16 +35,11 @@ func NewDocs() *cobra.Command { cmd := &cobra.Command{ Use: "api-docs []", Short: "Show what the meshStack API docs say about a request or a meshObject kind", - Long: `Show what the meshStack API docs say about a request, given in the same form that 'meshstack api' -takes, or about the operations of a meshObject kind. + Long: `Show what the meshStack API docs say about a request, given in the same form that 'meshstack api' takes, or about the operations of a meshObject kind. -Without a path, it lists every operation of the API, filtered by --method and --api-version if -given. +Without a path, it lists every operation of the API, filtered by --method and --api-version if given. ---describe takes a kind by its command name, such as buildingblock or the alias bb, optionally -followed by one of its actions after a dot, as in bb.list. It describes each operation of the kind, -in the latest version that the kind offers. It cannot be combined with a path or with the request -flags. +--describe takes a kind by its command name, such as buildingblock or the alias bb, optionally followed by one of its actions after a dot, as in bb.list. It describes each operation of the kind, in the latest version that the kind offers. It cannot be combined with a path or with the request flags. --output json prints that part of the OpenAPI document. diff --git a/cmd/auth/login.go b/cmd/auth/login.go index f3fbb76a..909e0de5 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -41,13 +41,9 @@ func newLogin(profileSources setting.Sources) *cobra.Command { cmd := &cobra.Command{ Use: "login", Short: "Log in to meshStack", - Long: `Log in to meshStack and store the credential in a profile. The command creates the profile if -it does not exist yet. + Long: `Log in to meshStack and store the credential in a profile. The command creates the profile if it does not exist yet. -Without --` + internal.ProfileFlag.Name.String() + `, it asks which of the stored profiles for the endpoint to log in to. Without ---apikey or --apitoken, it opens a browser login and asks which workspace to work in. Use ---` + internal.WorkspaceFlag.Name.String() + ` to choose the workspace without the question. If you choose a different access level -than in the profile's previous browser login, the command ends that previous login first. +Without --` + internal.ProfileFlag.Name.String() + `, it asks which of the stored profiles for the endpoint to log in to. Without --apikey or --apitoken, it opens a browser login and asks which workspace to work in. Use --` + internal.WorkspaceFlag.Name.String() + ` to choose the workspace without the question. If you choose a different access level than in the profile's previous browser login, the command ends that previous login first. Afterwards it prints the same output as meshstack auth status for the new login.`, Args: func(cmd *cobra.Command, args []string) error { diff --git a/cmd/auth/logout.go b/cmd/auth/logout.go index 44f51529..5d405c4e 100644 --- a/cmd/auth/logout.go +++ b/cmd/auth/logout.go @@ -17,8 +17,7 @@ func newLogout() *cobra.Command { cmd := &cobra.Command{ Use: "logout", Short: "Remove this profile's stored credentials", - Long: `Remove this profile's stored credentials. For a browser login, this also ends the login's -session at meshStack, so its tokens stop working. Your browser stays signed in to meshStack. + Long: `Remove this profile's stored credentials. For a browser login, this also ends the login's session at meshStack, so its tokens stop working. Your browser stays signed in to meshStack. Without --` + internal.ProfileFlag.Name.String() + `, it asks which of the stored profiles for the endpoint to log out of.`, Args: cobra.NoArgs, diff --git a/cmd/auth/status.go b/cmd/auth/status.go index 2e895fb3..7833f0c2 100644 --- a/cmd/auth/status.go +++ b/cmd/auth/status.go @@ -40,11 +40,9 @@ func newStatus() *cobra.Command { cmd := &cobra.Command{ Use: "status", Short: "Show which credential commands authenticate with", - Long: `Show which credential other commands with the same flags and environment would authenticate -with: where it comes from, who it is for, and how long it is valid. It never shows a secret. + Long: `Show which credential other commands with the same flags and environment would authenticate with: where it comes from, who it is for, and how long it is valid. It never shows a secret. -This command does not renew the token of a browser login, so the token may show as expired. The -next command that needs the token renews it.`, +This command does not renew the token of a browser login, so the token may show as expired. The next command that needs the token renews it.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { session, err := auth.ResolveSession(cmd.Context(), internal.ResolveClientOptions()) diff --git a/cmd/auth/token.go b/cmd/auth/token.go index 28dfa724..fc2f8c4e 100644 --- a/cmd/auth/token.go +++ b/cmd/auth/token.go @@ -13,13 +13,9 @@ func newToken() *cobra.Command { return &cobra.Command{ Use: "token", Short: "Print an access token for the meshStack API", - Long: `Print the access token that other commands with the same flags and environment would send. -The command renews the token first if it is about to expire. It prints nothing else to stdout, so -$(meshstack auth token) works. + Long: `Print the access token that other commands with the same flags and environment would send. The command renews the token first if it is about to expire. It prints nothing else to stdout, so $(meshstack auth token) works. -Paste the token as the Bearer token in the API docs to try a request against your meshStack. The -token is short-lived: run this command again once the API returns 401. In scripts, use meshstack -api, which renews the token by itself. For a browser login, the token is for the current workspace.`, +Paste the token as the Bearer token in the API docs to try a request against your meshStack. The token is short-lived: run this command again once the API returns 401. In scripts, use meshstack api, which renews the token by itself. For a browser login, the token is for the current workspace.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { session, err := auth.ResolveSession(cmd.Context(), internal.ResolveClientOptions()) diff --git a/cmd/buildingblock/abort_run.go b/cmd/buildingblock/abort_run.go index 24872824..4798b138 100644 --- a/cmd/buildingblock/abort_run.go +++ b/cmd/buildingblock/abort_run.go @@ -26,8 +26,7 @@ func newAbortRun() *cobra.Command { Short: "Abort the latest run of a building block", Long: `Abort the latest run of a building block, or its dry run if the dry run is newer. -The command shows the run and asks you to confirm, so it needs a terminal. After the abort, it -prints the building block.`, +The command shows the run and asks you to confirm, so it needs a terminal. After the abort, it prints the building block.`, Example: ` meshstack buildingblock abort-run 0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90 meshstack bb abort-run 0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90`, Args: internal.UuidArg(&buildingBlockUuid), diff --git a/cmd/buildingblock/approve_run.go b/cmd/buildingblock/approve_run.go index 7c4277cc..7dac0444 100644 --- a/cmd/buildingblock/approve_run.go +++ b/cmd/buildingblock/approve_run.go @@ -27,12 +27,9 @@ func newApproveRun() *cobra.Command { Short: "Approve the plan of a building block run that is waiting for approval", Long: `Approve the plan of the latest run of a building block, if the run is waiting for approval. -The command shows the plan and asks you to approve it, so it needs a terminal. It approves -exactly the plan it showed. If meshStack planned the run again in the meantime, the approval fails, -and you can run the command again to review the new plan. +The command shows the plan and asks you to approve it, so it needs a terminal. It approves exactly the plan it showed. If meshStack planned the run again in the meantime, the approval fails, and you can run the command again to review the new plan. -A building block whose status.action is APPROVE_RUN in "meshstack buildingblock list" has a run -that is waiting for approval. After the approval, the command prints the building block.`, +A building block whose status.action is APPROVE_RUN in "meshstack buildingblock list" has a run that is waiting for approval. After the approval, the command prints the building block.`, Example: ` meshstack buildingblock approve-run 0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90 meshstack bb approve-run 0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90`, Args: internal.UuidArg(&buildingBlockUuid), diff --git a/cmd/buildingblock/list.go b/cmd/buildingblock/list.go index 3937c2c4..8f8973e4 100644 --- a/cmd/buildingblock/list.go +++ b/cmd/buildingblock/list.go @@ -13,8 +13,7 @@ func newList() *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "List building blocks, newest first", - Long: `List the building blocks of the workspace, newest first. If no workspace is set, the command -lists every building block that your credential can access.`, + Long: `List the building blocks of the workspace, newest first. If no workspace is set, the command lists every building block that your credential can access.`, Example: ` meshstack buildingblock list --workspace my-workspace meshstack bb list --limit unlimited -o ndjson`, Args: cobra.NoArgs, diff --git a/cmd/buildingblock/tfstate/exec.go b/cmd/buildingblock/tfstate/exec.go index 729cd1ca..2c91fa74 100644 --- a/cmd/buildingblock/tfstate/exec.go +++ b/cmd/buildingblock/tfstate/exec.go @@ -61,37 +61,23 @@ func newExec() *cobra.Command { cmd := &cobra.Command{ Use: "exec -- [args...]", Short: "Run tofu against the state of a building block", - Long: `Run a command, such as tofu plan, against the state of a building block. Give the building -block by its uuid. - -exec passes the state to tofu's http backend through the TF_HTTP_* variables. It exits with the exit -code of the command. By default, it uses the state stored in the building block's own workspace, -metadata.ownedByWorkspace. Use --` + internal.WorkspaceFlag.Name.String() + ` to choose another workspace. - -The module needs a backend "http" block. The runner adds this block only during its own runs. With ---override-backend, exec adds the block while the command runs: it writes the file -` + tfstate.BackendOverrideFile + ` into the directory given by tofu -chdir, or into the working -directory if there is no -chdir. In that directory, the file also replaces any backend that the -module defines. exec removes the file when the command ends. If the command is a wrapper that runs -tofu, run it in the module's directory. Run tofu init through exec with --override-backend once. - -tofu's meshstack provider also connects to meshStack through exec, with the login of the meshStack -CLI. This works with any version of the provider, as long as its provider "meshstack" block sets no -endpoint and no credentials. Settings in that block take precedence over the ones exec provides. - ---mode read is the default. In this mode, exec refuses to store a state, and the provider can only -read. exec takes no lock, so a run of the building block never has to wait for it. A plan may read -a state that a run is changing at the same time. - ---mode readwrite stores what the command writes, and locks the state in meshStack whenever tofu asks -for a lock. A run of the building block waits up to 5 minutes for that lock, and then fails. Without ---force, --mode readwrite fails in these cases: + Long: `Run a command, such as tofu plan, against the state of a building block. Give the building block by its uuid. + +exec passes the state to tofu's http backend through the TF_HTTP_* variables. It exits with the exit code of the command. By default, it uses the state stored in the building block's own workspace, metadata.ownedByWorkspace. Use --` + internal.WorkspaceFlag.Name.String() + ` to choose another workspace. + +The module needs a backend "http" block. The runner adds this block only during its own runs. With --override-backend, exec adds the block while the command runs: it writes the file ` + tfstate.BackendOverrideFile + ` into the directory given by tofu -chdir, or into the working directory if there is no -chdir. In that directory, the file also replaces any backend that the module defines. exec removes the file when the command ends. If the command is a wrapper that runs tofu, run it in the module's directory. Run tofu init through exec with --override-backend once. + +tofu's meshstack provider also connects to meshStack through exec, with the login of the meshStack CLI. This works with any version of the provider, as long as its provider "meshstack" block sets no endpoint and no credentials. Settings in that block take precedence over the ones exec provides. + +--mode read is the default. In this mode, exec refuses to store a state, and the provider can only read. exec takes no lock, so a run of the building block never has to wait for it. A plan may read a state that a run is changing at the same time. + +--mode readwrite stores what the command writes, and locks the state in meshStack whenever tofu asks for a lock. A run of the building block waits up to 5 minutes for that lock, and then fails. Without --force, --mode readwrite fails in these cases: - a run of the building block is pending, in progress or waiting for approval; - the new state has another lineage than the stored state, or its serial is not higher. -With --force, you can stop a destructive run that someone approved by mistake: while tofu holds the -lock, the run waits for it and then fails. -With --backup-dir, exec copies the stored state into that directory before it replaces or deletes -it.`, + +With --force, you can stop a destructive run that someone approved by mistake: while tofu holds the lock, the run waits for it and then fails. + +With --backup-dir, exec copies the stored state into that directory before it replaces or deletes it.`, Example: ` meshstack bb tfstate exec 0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90 --mode readwrite -- tofu apply`, Args: func(cmd *cobra.Command, args []string) error { if cmd.ArgsLenAtDash() != 1 || len(args) < 2 { diff --git a/cmd/buildingblock/tfstate/force_unlock.go b/cmd/buildingblock/tfstate/force_unlock.go index 6a9d6ed0..b9e8c781 100644 --- a/cmd/buildingblock/tfstate/force_unlock.go +++ b/cmd/buildingblock/tfstate/force_unlock.go @@ -22,17 +22,11 @@ func newForceUnlock() *cobra.Command { cmd := &cobra.Command{ Use: "force-unlock ", Short: "Release a stale lock on the state of a building block", - Long: `Release a lock on the state of a building block that its holder left behind, for example -because a tofu process was killed. Give the building block by its uuid. Unlike tofu force-unlock, -this command needs no module directory and no lock ID. It shows who holds the lock, and asks before -it releases the lock. Only the answer yes releases the lock; a script can pipe yes to stdin. + Long: `Release a lock on the state of a building block that its holder left behind, for example because a tofu process was killed. Give the building block by its uuid. Unlike tofu force-unlock, this command needs no module directory and no lock ID. It shows who holds the lock, and asks before it releases the lock. Only the answer yes releases the lock; a script can pipe yes to stdin. -If the lock belongs to a run of the building block that is pending or in progress, the command -refuses to release it, because that run is still writing the state. Use --force to release it -anyway. --force does not skip the question. +If the lock belongs to a run of the building block that is pending or in progress, the command refuses to release it, because that run is still writing the state. Use --force to release it anyway. --force does not skip the question. -By default, the command uses the state stored in the building block's own workspace, -metadata.ownedByWorkspace. Use --` + internal.WorkspaceFlag.Name.String() + ` to choose another workspace.`, +By default, the command uses the state stored in the building block's own workspace, metadata.ownedByWorkspace. Use --` + internal.WorkspaceFlag.Name.String() + ` to choose another workspace.`, Example: ` meshstack bb tfstate force-unlock 0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90 echo yes | meshstack bb tfstate force-unlock 0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90 --force`, Args: internal.UuidArg(&buildingBlockUuid), diff --git a/cmd/buildingblock/tfstate/show.go b/cmd/buildingblock/tfstate/show.go index e942553a..998dd29a 100644 --- a/cmd/buildingblock/tfstate/show.go +++ b/cmd/buildingblock/tfstate/show.go @@ -17,11 +17,9 @@ func newShow() *cobra.Command { return &cobra.Command{ Use: "show ", Short: "Show the state of a building block", - Long: `Show the state of a building block as meshStack stores it. Give the building block by its uuid. -If the building block has no state yet, the command prints nothing and succeeds. + Long: `Show the state of a building block as meshStack stores it. Give the building block by its uuid. If the building block has no state yet, the command prints nothing and succeeds. -By default, the command uses the state stored in the building block's own workspace, -metadata.ownedByWorkspace. Use --` + internal.WorkspaceFlag.Name.String() + ` to choose another workspace.`, +By default, the command uses the state stored in the building block's own workspace, metadata.ownedByWorkspace. Use --` + internal.WorkspaceFlag.Name.String() + ` to choose another workspace.`, Example: ` meshstack bb tfstate show 0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90 | jq .resources`, Args: internal.UuidArg(&buildingBlockUuid), RunE: func(cmd *cobra.Command, _ []string) error { diff --git a/cmd/buildingblock/tfstate/tfstate.go b/cmd/buildingblock/tfstate/tfstate.go index be7151bc..b254d28e 100644 --- a/cmd/buildingblock/tfstate/tfstate.go +++ b/cmd/buildingblock/tfstate/tfstate.go @@ -32,14 +32,11 @@ func New() *cobra.Command { cmd := &cobra.Command{ Use: "tfstate", Short: "Read and write the OpenTofu state that meshStack stores for a building block", - Long: `Read and write the OpenTofu state that meshStack stores for a building block. The runner of the -building block reads and writes this state through tofu's http backend. + Long: `Read and write the OpenTofu state that meshStack stores for a building block. The runner of the building block reads and writes this state through tofu's http backend. ` + rights + ` -"meshstack buildingblock tfstate exec" passes the state to tofu's http backend, so the module needs -a backend "http" block. The runner adds this block only during its own runs. exec with ---override-backend adds it while its command runs. Run tofu init through exec once: +"meshstack buildingblock tfstate exec" passes the state to tofu's http backend, so the module needs a backend "http" block. The runner adds this block only during its own runs. exec with --override-backend adds it while its command runs. Run tofu init through exec once: meshstack buildingblock tfstate exec --override-backend -- tofu init`, Example: ` meshstack bb tfstate show 0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90 | jq .resources diff --git a/cmd/buildingblock/trigger_run.go b/cmd/buildingblock/trigger_run.go index f2bd5bdc..131a3d18 100644 --- a/cmd/buildingblock/trigger_run.go +++ b/cmd/buildingblock/trigger_run.go @@ -22,15 +22,12 @@ func newTriggerRun() *cobra.Command { cmd := &cobra.Command{ Use: "trigger-run ", Short: "Ask meshStack to run a building block", - Long: `Ask meshStack to run a building block. Give the building block by its uuid; "meshstack -buildingblock list" shows it as metadata.uuid. + Long: `Ask meshStack to run a building block. Give the building block by its uuid; "meshstack buildingblock list" shows it as metadata.uuid. + +The command waits until meshStack has started the run, and then prints the building block. Its status.latestRunUuid is the run. If the definition requires an approval of the run, status.latestDryRunUuid is the preflight run, whose plan the approver sees. "meshstack buildingblockrun logs " shows how far a run got. + +With --wait, the command waits until the run has ended. This is the same as piping meshStack's response to the run request into "meshstack buildingblock wait --before=-". -The command waits until meshStack has started the run, and then prints the building block. Its -status.latestRunUuid is the run. If the definition requires an approval of the run, -status.latestDryRunUuid is the preflight run, whose plan the approver sees. -"meshstack buildingblockrun logs " shows how far a run got. -With --wait, the command waits until the run has ended. This is the same as piping meshStack's -response to the run request into "meshstack buildingblock wait --before=-". "meshstack api-docs --describe buildingblock.trigger-run" shows the fields of the response.`, Example: ` meshstack buildingblock trigger-run 0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90 meshstack bb trigger-run 0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90 -o ndjson | jq -r .status.latestRunUuid`, diff --git a/cmd/buildingblock/wait.go b/cmd/buildingblock/wait.go index 487a36fd..69eab0dd 100644 --- a/cmd/buildingblock/wait.go +++ b/cmd/buildingblock/wait.go @@ -24,18 +24,11 @@ func newWait() *cobra.Command { cmd := &cobra.Command{ Use: "wait --before ", Short: "Wait until the run of a building block has ended", - Long: `Wait until meshStack has reacted to a write of a building block, and until the run that the -write started has ended. Give the building block by its uuid. + Long: `Wait until meshStack has reacted to a write of a building block, and until the run that the write started has ended. Give the building block by its uuid. -meshStack responds to a write before it starts the run. So right after the write, the building -block still shows the action of the previous run. Pass that response with --before: the response -of a create (POST), an update (PUT) or a trigger-run, or a read taken before the change. The -command first waits up to a minute until meshStack has reacted, following the rules of -https://docs.meshcloud.io/concepts/building-block/#waiting-for-a-run. Then it waits until the run -has ended. Press Ctrl-C to stop waiting. +meshStack responds to a write before it starts the run. So right after the write, the building block still shows the action of the previous run. Pass that response with --before: the response of a create (POST), an update (PUT) or a trigger-run, or a read taken before the change. The command first waits up to a minute until meshStack has reacted, following the rules of https://docs.meshcloud.io/concepts/building-block/#waiting-for-a-run. Then it waits until the run has ended. Press Ctrl-C to stop waiting. -The command prints the building block. It fails and names the action if the run failed, was aborted -or could not start, or if the run is waiting for a person or a dependency.`, +The command prints the building block. It fails and names the action if the run failed, was aborted or could not start, or if the run is waiting for a person or a dependency.`, Example: ` meshstack api -X PUT /api/meshobjects/meshbuildingblocks/0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90 --request-json block.json | meshstack bb wait 0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90 --before=-`, Args: internal.UuidArg(&buildingBlockUuid), diff --git a/cmd/buildingblockdefinition/list.go b/cmd/buildingblockdefinition/list.go index 8583a3b1..c6896d08 100644 --- a/cmd/buildingblockdefinition/list.go +++ b/cmd/buildingblockdefinition/list.go @@ -13,9 +13,7 @@ func newList() *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "List building block definitions, newest first", - Long: `List the building block definitions that the workspace owns, and those published to the whole -platform, newest first. If no workspace is set, the command lists every definition that your -credential can access.`, + Long: `List the building block definitions that the workspace owns, and those published to the whole platform, newest first. If no workspace is set, the command lists every definition that your credential can access.`, Example: ` meshstack buildingblockdefinition list meshstack bbd list --workspace my-workspace`, Args: cobra.NoArgs, diff --git a/cmd/buildingblockdefinitionversion/list.go b/cmd/buildingblockdefinitionversion/list.go index 95fd5d7c..b07bfa6d 100644 --- a/cmd/buildingblockdefinitionversion/list.go +++ b/cmd/buildingblockdefinitionversion/list.go @@ -20,9 +20,7 @@ func newList() *cobra.Command { Short: "List the versions of a building block definition", Long: `List the versions of a building block definition. -Give the definition by its uuid; "meshstack buildingblockdefinition list" shows it as -metadata.uuid. --definition is required, because meshStack returns the versions of one definition -at a time. Reading them needs a permission in the workspace that owns the definition.`, +Give the definition by its uuid; "meshstack buildingblockdefinition list" shows it as metadata.uuid. --definition is required, because meshStack returns the versions of one definition at a time. Reading them needs a permission in the workspace that owns the definition.`, Example: ` meshstack buildingblockdefinitionversion list --definition 3c9e1f7a-2b4d-4e6f-8a1c-5d7b9e2f4a6c meshstack bbdv list --definition 3c9e1f7a-2b4d-4e6f-8a1c-5d7b9e2f4a6c --limit 1`, Args: cobra.NoArgs, diff --git a/cmd/buildingblockrun/list.go b/cmd/buildingblockrun/list.go index 46cc7e2a..4d85ab69 100644 --- a/cmd/buildingblockrun/list.go +++ b/cmd/buildingblockrun/list.go @@ -28,9 +28,7 @@ func newList() *cobra.Command { Short: "List building block runs, newest first", Long: `List building block runs, newest first. ---building-block lists the runs of that building block. Without it, the command lists the runs of -every building block in the workspace. If no workspace is set, it lists the runs of every building -block that your credential can access.`, +--building-block lists the runs of that building block. Without it, the command lists the runs of every building block in the workspace. If no workspace is set, it lists the runs of every building block that your credential can access.`, Example: ` meshstack buildingblockrun list --building-block 0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90 meshstack bbrun list --workspace my-workspace --limit 20`, Args: cobra.NoArgs, diff --git a/cmd/buildingblockrun/logs.go b/cmd/buildingblockrun/logs.go index 46091706..0edf1ced 100644 --- a/cmd/buildingblockrun/logs.go +++ b/cmd/buildingblockrun/logs.go @@ -29,11 +29,9 @@ func newLogs() *cobra.Command { Short: "Show the logs of a building block run", Long: `Show the logs of a building block run. -Give the run by its uuid; "meshstack buildingblockrun list" shows it as metadata.uuid. -"meshstack api-docs --describe buildingblockrun.logs" shows the fields of the response. +Give the run by its uuid; "meshstack buildingblockrun list" shows it as metadata.uuid. "meshstack api-docs --describe buildingblockrun.logs" shows the fields of the response. ---follow prints new log lines as text until the run has finished. It exits with status 0 if the run -succeeded and 1 otherwise, so a script can use it to wait for a run.`, +--follow prints new log lines as text until the run has finished. It exits with status 0 if the run succeeded and 1 otherwise, so a script can use it to wait for a run.`, Example: ` meshstack buildingblockrun logs 7f3a2b1c-8d4e-4f6a-9b0c-1d2e3f4a5b6c meshstack bbrun logs 7f3a2b1c-8d4e-4f6a-9b0c-1d2e3f4a5b6c -o ndjson meshstack bbrun logs 7f3a2b1c-8d4e-4f6a-9b0c-1d2e3f4a5b6c --follow`, diff --git a/cmd/eventlog/list.go b/cmd/eventlog/list.go index 09128060..00bdc0e4 100644 --- a/cmd/eventlog/list.go +++ b/cmd/eventlog/list.go @@ -23,11 +23,9 @@ func newList() *cobra.Command { Short: "List event logs, newest first", Long: `List event logs, newest first. ---from and --until take a date such as 2026-07-01, which means midnight UTC, or an instant such as -2026-07-01T12:00:00Z. +--from and --until take a date such as 2026-07-01, which means midnight UTC, or an instant such as 2026-07-01T12:00:00Z. -The command lists the event logs of the workspace. With an admin credential and no workspace set, -it lists the event logs of all workspaces.`, +The command lists the event logs of the workspace. With an admin credential and no workspace set, it lists the event logs of all workspaces.`, Example: ` meshstack eventlog list --from 2026-07-01 --until 2026-07-02 meshstack elog list --title "Building Block Run" --exclude-title "Building Block Run Executed" meshstack elog list --workspace my-workspace --limit unlimited -o ndjson`, diff --git a/cmd/internal/help.go b/cmd/internal/help.go new file mode 100644 index 00000000..fbe1a353 --- /dev/null +++ b/cmd/internal/help.go @@ -0,0 +1,129 @@ +package internal + +import ( + "io" + "os" + "regexp" + "strings" + "text/template" + "unicode" + + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/term" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +const ( + // helpWidthWithoutTerminal keeps help in a pipe or a test the same on every machine. + helpWidthWithoutTerminal = 80 + // maxHelpWidth keeps prose readable on a wide terminal. + maxHelpWidth = 100 +) + +// The templates are cobra's defaults, with the long description and the flags wrapped to a width +// that comes from where the help is written. Cobra's template functions cannot know that writer. +var helpTemplates = template.Must(template.New("help").Funcs(template.FuncMap{ + "wrap": wrapHelp, + "flagUsages": func(flags *pflag.FlagSet, width int) string { + return strings.TrimRightFunc(flags.FlagUsagesWrapped(width), unicode.IsSpace) + }, + "rpad": func(s string, padding int) string { + return s + strings.Repeat(" ", max(padding-len(s), 0)) + }, +}).Parse(`{{with or .Long .Short}}{{wrap . $.Width}} + +{{end}}{{if or .Runnable .HasSubCommands}}{{template "usage" .}}{{end}} +{{- define "usage"}}Usage:{{if .Runnable}} + {{.UseLine}}{{end}}{{if .HasAvailableSubCommands}} + {{.CommandPath}} [command]{{end}}{{if gt (len .Aliases) 0}} + +Aliases: + {{.NameAndAliases}}{{end}}{{if .HasExample}} + +Examples: +{{.Example}}{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}} + +Available Commands:{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}} + {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}} + +{{.Title}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}} + {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}} + +Additional Commands:{{range $cmds}}{{if (and (eq .GroupID "") (or .IsAvailableCommand (eq .Name "help")))}} + {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}} + +Flags: +{{flagUsages .LocalFlags .Width}}{{end}}{{if .HasAvailableInheritedFlags}} + +Global Flags: +{{flagUsages .InheritedFlags .Width}}{{end}}{{if .HasHelpSubCommands}} + +Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}} + {{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}} + +Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}} +{{end}}`)) + +type helpData struct { + *cobra.Command + + Width int +} + +// WrapHelpToTerminal makes the help and the usage of root and its subcommands fit the terminal +// they are written to. +func WrapHelpToTerminal(root *cobra.Command) { + root.SetHelpFunc(func(cmd *cobra.Command, _ []string) { + if err := writeHelp(cmd.OutOrStdout(), "help", cmd); err != nil { + cmd.PrintErrln(err) + } + }) + root.SetUsageFunc(func(cmd *cobra.Command) error { + return writeHelp(cmd.OutOrStderr(), "usage", cmd) + }) +} + +func writeHelp(w io.Writer, name string, cmd *cobra.Command) error { + width := helpWidthWithoutTerminal + if file, ok := w.(*os.File); ok { + if terminalWidth, _, err := term.GetSize(file.Fd()); err == nil { + width = min(terminalWidth, maxHelpWidth) + } + } + return helpTemplates.ExecuteTemplate(w, name, helpData{cmd, width}) +} + +var listItem = regexp.MustCompile(`^\s*(?:[-*]|\d+\.)\s+`) + +// wrapHelp wraps each line of text on its own, so a line break in the text always stays one. A list +// item continues under its own text, and any other indented line, such as code, is left as it is. +// It breaks only at spaces, because ansi.Wordwrap also breaks a flag such as --mode at its hyphens. +func wrapHelp(text string, width int) string { + lines := strings.Split(strings.TrimRightFunc(text, unicode.IsSpace), "\n") + for i, line := range lines { + marker := listItem.FindString(line) + if marker == "" && strings.TrimLeftFunc(line, unicode.IsSpace) != line { + continue + } + var wrapped strings.Builder + wrapped.WriteString(marker) + lineWidth := len(marker) + for j, word := range strings.Fields(line[len(marker):]) { + wordWidth := ansi.StringWidth(word) + switch { + case j == 0: + case lineWidth+1+wordWidth > width: + wrapped.WriteString("\n" + strings.Repeat(" ", len(marker))) + lineWidth = len(marker) + default: + wrapped.WriteByte(' ') + lineWidth++ + } + wrapped.WriteString(word) + lineWidth += wordWidth + } + lines[i] = wrapped.String() + } + return strings.Join(lines, "\n") +} diff --git a/cmd/internal/help_test.go b/cmd/internal/help_test.go new file mode 100644 index 00000000..4547a4d2 --- /dev/null +++ b/cmd/internal/help_test.go @@ -0,0 +1,17 @@ +package internal + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestWrapHelp(t *testing.T) { + t.Run("a paragraph wraps at spaces only, and a blank line stays", func(t *testing.T) { + assert.Equal(t, "use one\n--override-backend\n\nnext", wrapHelp("use one --override-backend\n\nnext\n", 12)) + }) + + t.Run("a list item continues under its text, and an indented line stays as it is", func(t *testing.T) { + assert.Equal(t, " - one two\n three\n code that stays long", wrapHelp(" - one two three\n code that stays long", 12)) + }) +} diff --git a/cmd/meshstack/meshstack.go b/cmd/meshstack/meshstack.go index 76bfe9e3..60f314dc 100644 --- a/cmd/meshstack/meshstack.go +++ b/cmd/meshstack/meshstack.go @@ -49,17 +49,11 @@ func newRootCommand() *cobra.Command { Start with meshstack login. It logs you in and keeps the login in a profile. -AI agents: read https://docs.meshcloud.io/agent-context/index.md before you act, starting with its -guardrails. The skill for this CLI is -https://github.com/meshcloud/meshstack-cli/blob/main/skills/meshstack-cli/SKILL.md. +AI agents: read https://docs.meshcloud.io/agent-context/index.md before you act, starting with its guardrails. The skill for this CLI is https://github.com/meshcloud/meshstack-cli/blob/main/skills/meshstack-cli/SKILL.md. -The endpoint, the profile and the workspace come from a flag, else from the environment variable -that the flag's help names, else from the profile, which holds an endpoint and a default workspace. -Without a profile named, the one whose endpoint matches is used, else the current profile, which -meshstack login sets. +The endpoint, the profile and the workspace come from a flag, else from the environment variable that the flag's help names, else from the profile, which holds an endpoint and a default workspace. Without a profile named, the one whose endpoint matches is used, else the current profile, which meshstack login sets. -AI agents and scripts: add -o json to a list or show command to read JSON. meshstack api sends a -request to any path of the meshStack API, and meshstack api-docs describes that path. +AI agents and scripts: add -o json to a list or show command to read JSON. meshstack api sends a request to any path of the meshStack API, and meshstack api-docs describes that path. Colors follow NO_COLOR and FORCE_COLOR, and stay off in a pipe.`, Example: ` meshstack login @@ -101,6 +95,7 @@ Colors follow NO_COLOR and FORCE_COLOR, and stay off in a pipe.`, cmd.AddCommand(profile.New()) cmd.AddCommand(workspace.New()) + internal.WrapHelpToTerminal(cmd) showUsageOnMisuse(cmd) return cmd } diff --git a/cmd/profile/add.go b/cmd/profile/add.go index 68fcf7a1..d1fb3303 100644 --- a/cmd/profile/add.go +++ b/cmd/profile/add.go @@ -13,9 +13,8 @@ func newAdd() *cobra.Command { cmd := &cobra.Command{ Use: "add", Short: "Add a profile", - Long: `Add a profile. In a terminal, a form opens; otherwise, the command asks one question per line. ---` + internal.ProfileFlag.Name.String() + `, --` + internal.EndpointFlag.Name.String() + ` and --` + internal.WorkspaceFlag.Name.String() + ` pre-fill the answers.`, - Args: cobra.NoArgs, + Long: `Add a profile. In a terminal, a form opens; otherwise, the command asks one question per line. --` + internal.ProfileFlag.Name.String() + `, --` + internal.EndpointFlag.Name.String() + ` and --` + internal.WorkspaceFlag.Name.String() + ` pre-fill the answers.`, + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { ctx := cmd.Context() p := prompt.New(cmd.InOrStdin(), cmd.ErrOrStderr()) diff --git a/cmd/profile/delete.go b/cmd/profile/delete.go index 3e60d059..e7185908 100644 --- a/cmd/profile/delete.go +++ b/cmd/profile/delete.go @@ -17,12 +17,9 @@ func newDelete() *cobra.Command { cmd := &cobra.Command{ Use: "delete", Short: "Delete a profile and its stored credentials", - Long: `Delete a profile and its stored credentials. For a browser login, this also ends the login's -session at meshStack, as meshstack auth logout does. Your browser stays signed in to meshStack. + Long: `Delete a profile and its stored credentials. For a browser login, this also ends the login's session at meshStack, as meshstack auth logout does. Your browser stays signed in to meshStack. -Without --` + internal.ProfileFlag.Name.String() + `, it asks which of the profiles for the endpoint to delete. It asks for confirmation -before it deletes. Use --yes to skip the confirmation; --yes needs --` + internal.ProfileFlag.Name.String() + `, or an --` + internal.EndpointFlag.Name.String() + ` -that matches exactly one profile.`, +Without --` + internal.ProfileFlag.Name.String() + `, it asks which of the profiles for the endpoint to delete. It asks for confirmation before it deletes. Use --yes to skip the confirmation; --yes needs --` + internal.ProfileFlag.Name.String() + `, or an --` + internal.EndpointFlag.Name.String() + ` that matches exactly one profile.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { ctx := cmd.Context() diff --git a/cmd/profile/edit.go b/cmd/profile/edit.go index 557f2288..2329bfb6 100644 --- a/cmd/profile/edit.go +++ b/cmd/profile/edit.go @@ -17,9 +17,7 @@ func newEdit() *cobra.Command { cmd := &cobra.Command{ Use: "edit", Short: "Edit a profile", - Long: `Edit a profile. In a terminal, a form opens; otherwise, the command asks one question per line. -Leave an answer empty to keep the current value. Without --` + internal.ProfileFlag.Name.String() + `, it asks which of the profiles -for the endpoint to edit. --` + internal.WorkspaceFlag.Name.String() + ` pre-fills the default workspace. + Long: `Edit a profile. In a terminal, a form opens; otherwise, the command asks one question per line. Leave an answer empty to keep the current value. Without --` + internal.ProfileFlag.Name.String() + `, it asks which of the profiles for the endpoint to edit. --` + internal.WorkspaceFlag.Name.String() + ` pre-fills the default workspace. Changing the endpoint deletes the stored credentials.`, Args: cobra.NoArgs, diff --git a/cmd/profile/profile.go b/cmd/profile/profile.go index ed6b47b8..5f59545f 100644 --- a/cmd/profile/profile.go +++ b/cmd/profile/profile.go @@ -14,12 +14,9 @@ func New() *cobra.Command { cmd := &cobra.Command{ Use: "profile", Short: "Manage the stored profiles", - Long: `Manage your profiles. A profile stores an endpoint, a credential and a default workspace. -meshstack login creates profiles as well. + Long: `Manage your profiles. A profile stores an endpoint, a credential and a default workspace. meshstack login creates profiles as well. -In a terminal, this command opens an interactive list where you can add, edit and delete profiles, -and choose the current one. Outside a terminal, it shows this help. Use the subcommands there: list -and show print the profiles, and add, edit and delete ask one question per line.`, +In a terminal, this command opens an interactive list where you can add, edit and delete profiles, and choose the current one. Outside a terminal, it shows this help. Use the subcommands there: list and show print the profiles, and add, edit and delete ask one question per line.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { ctx := cmd.Context() diff --git a/cmd/profile/show.go b/cmd/profile/show.go index 59fe72c1..7e54282b 100644 --- a/cmd/profile/show.go +++ b/cmd/profile/show.go @@ -63,11 +63,9 @@ func newShow() *cobra.Command { cmd := &cobra.Command{ Use: "show", Short: "Show a profile and the status of its stored credential", - Long: `Show the profile that other commands would use, and the status of its stored credential. -Flags and environment variables can override that credential. + Long: `Show the profile that other commands would use, and the status of its stored credential. Flags and environment variables can override that credential. -If the profile does not exist yet, the command shows what the first login would create, without -saving anything.`, +If the profile does not exist yet, the command shows what the first login would create, without saving anything.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { ctx := cmd.Context() diff --git a/cmd/workspace/list.go b/cmd/workspace/list.go index 42b68f09..99546fce 100644 --- a/cmd/workspace/list.go +++ b/cmd/workspace/list.go @@ -15,8 +15,7 @@ func newList() *cobra.Command { Short: "List the workspaces visible from the current workspace, newest first", Long: `List the workspaces visible from the current workspace, newest first. -If you work in the admin workspace and have a role that can list all workspaces, such as -Organization Admin, the command lists all workspaces. Otherwise it lists only the current workspace.`, +If you work in the admin workspace and have a role that can list all workspaces, such as Organization Admin, the command lists all workspaces. Otherwise it lists only the current workspace.`, Example: ` meshstack workspace list meshstack ws list --workspace admin-workspace --limit unlimited -o ndjson`, Args: cobra.NoArgs, From c3dbd30c00a19b986eef9a736b13c934ed00301c Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Fri, 6 Nov 2026 13:46:36 +0100 Subject: [PATCH 4/4] fix: add a style guide for help text and apply it to every command The development skill holds the help-text rules. Help and messages build flag names from the flag definitions. Co-Authored-By: Claude Opus 5.5 --- .claude/skills/development/SKILL.md | 29 +++++++++++++ .claude/skills/meshobject-command/SKILL.md | 3 +- cmd/api/api.go | 10 ++--- cmd/api/api_test.go | 6 +-- cmd/api/docs.go | 22 +++++----- cmd/api/profile.go | 6 +-- cmd/api/request.go | 24 +++++++---- cmd/auth/login.go | 10 +++-- cmd/auth/logout.go | 6 +-- cmd/auth/status.go | 4 +- cmd/auth/token.go | 4 +- cmd/buildingblock/approve_run.go | 2 +- cmd/buildingblock/tfstate/exec.go | 50 +++++++++++++--------- cmd/buildingblock/tfstate/force_unlock.go | 6 +-- cmd/buildingblock/tfstate/tfstate.go | 15 +++---- cmd/buildingblock/trigger_run.go | 12 +++--- cmd/buildingblock/wait.go | 12 +++--- cmd/buildingblockdefinitionversion/list.go | 6 ++- cmd/buildingblockrun/list.go | 6 ++- cmd/buildingblockrun/logs.go | 10 +++-- cmd/eventlog/list.go | 13 ++++-- cmd/internal/kind.go | 4 +- cmd/internal/output.go | 8 ++-- cmd/meshstack/meshstack.go | 10 ++--- cmd/profile/delete.go | 6 +-- cmd/profile/profile.go | 4 +- internal/auth/credentials.go | 2 +- internal/config/directory.go | 4 +- internal/meshstack/settings.go | 6 +-- internal/meshstack/workspace.go | 4 +- 30 files changed, 184 insertions(+), 120 deletions(-) diff --git a/.claude/skills/development/SKILL.md b/.claude/skills/development/SKILL.md index cabb256e..f02bc69d 100644 --- a/.claude/skills/development/SKILL.md +++ b/.claude/skills/development/SKILL.md @@ -52,6 +52,35 @@ command. For a command over a meshObject kind, the `meshobject-command` skill ha - A **parent command sets `RunE` as well as `Args`**. +## Help text and messages + + +These rules cover `Short`, `Long`, `Example` and flag help, and every error and log message a user +reads. + +- **Open with what the command does.** The first sentence of `Long` says it in plain words. Details, + caveats and pointers to other commands come after it. + Bad: "Show what the API docs say about a request, given in the same form that 'meshstack api' + takes, …". Good: "Show what the meshStack API docs say about a request or a meshObject kind." +- **Plain English.** Use common words, natural word order and complete sentences that name who + acts: "the command prints …", "you". Write literally, without metaphors such as "names one", + "takes", "holds" or "carries". Every sentence must make sense to a user who does not know the + CLI's code. Bad: "Without the API docs, only the Content-Type header does." Good: "If the command + cannot download the API docs, it sets only the Content-Type header." +- **No line breaks inside a paragraph.** The root command wraps help to the terminal width + (`cmd/internal/help.go`). Break lines only between paragraphs and list items, and around indented + examples. +- **Build flag names from the flag's definition**: `"--" + internal.WorkspaceFlag.Name.String()`, + and for a command's own flag, the const or variable that registers it, so that a rename leaves no + stale text. Write a flag name literally only where its package cannot import the definition, as + in `internal/`, and in an `Example`, which is a command line as the user types it. +- **Quote a command** with single quotes: 'meshstack login'. A Markdown template uses backticks. +- **Use the same phrase for the same thing** in every command: "Use --workspace to choose another + workspace", "prints" for output, "response" for what meshStack sends back. +- **An error says what went wrong and what the user can do next**: "no stored profile has the + endpoint X; run 'meshstack login --endpoint X' to create one". + + ## Values, pointers and receivers diff --git a/.claude/skills/meshobject-command/SKILL.md b/.claude/skills/meshobject-command/SKILL.md index 509cf40c..87650d06 100644 --- a/.claude/skills/meshobject-command/SKILL.md +++ b/.claude/skills/meshobject-command/SKILL.md @@ -39,7 +39,8 @@ The help says how to use a command, never what its kind is or does: the parent's such as that a uuid parses, because meshStack answers a malformed one with an empty list or a 404. The root help says once where the endpoint, the profile and the workspace come from, so a leaf names them only where it deviates, as tfstate does, which takes the building block's own workspace. -The `Long` stays a few lines that get a user started; the errors say the rest. +The `Long` stays a few lines that get a user started; the errors say the rest. The wording follows +the `help-text` rule of the `development` skill. ## Leaf diff --git a/cmd/api/api.go b/cmd/api/api.go index f6caabb3..541c710c 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -28,15 +28,15 @@ func New() *cobra.Command { cmd := &cobra.Command{ Use: "api ", Short: "Send an authorized request to the meshStack API", - Long: `Send an authorized request to a path of the meshStack API, and print the response (typically JSON). The path is relative to the endpoint. It can also be a full URL, as long as a stored profile with credentials matches its endpoint. + Long: fmt.Sprintf(`Send an authorized request to a path of the meshStack API, and print the response, which is typically JSON. Give the path relative to the endpoint, or give a full URL whose endpoint matches a stored profile with credentials. -Use this command for everything that the other commands do not cover. It sets the API version headers of meshStack's public API for you, unless you set them yourself. +Use this command for everything that the other commands do not cover. For a path of meshStack's public API, the command sets the Accept and Content-Type headers to the latest API version that the API docs list, unless you set them yourself. ---request-json sends a JSON file, or stdin for '-'. For a path that has no version in the API docs, the Content-Type and Accept headers default to application/json. Without the API docs, only the Content-Type header does. +--%[1]s sends a JSON file as the request body, or stdin for '-'. If the API docs list no version for the path, the command sets the Accept and Content-Type headers to application/json. If the command cannot download the API docs, it sets only the Content-Type header to application/json. If the response status is not 2xx, the command still prints the response body, and then fails. -To describe the request, run the same command line with 'api' replaced by 'api-docs'.`, +To see what the API docs say about the request, run the same command line with 'api' replaced by 'api-docs'.`, requestJsonFlagName), Example: ` meshstack api '/api/meshobjects/meshtenants?workspaceIdentifier=my-workspace' meshstack api -X DELETE /api/meshobjects/meshbuildingblocks//purge meshstack api -X POST /api/meshobjects/meshworkspaces --request-json workspace.json @@ -91,7 +91,7 @@ To describe the request, run the same command line with 'api' replaced by 'api-d } // The body is on stdout already, so the error says only what it adds. if r.docsHelpWith(httpErr) { - return withHint(fmt.Sprintf("%s. Run %s to see what the API takes", + return withHint(fmt.Sprintf("%s. Run %s to see what the API expects", httpErr.Summary(), docsCommand(cmd, args)), httpErr) } // What wraps the HTTP error, such as the auth scope of a 403, stays. diff --git a/cmd/api/api_test.go b/cmd/api/api_test.go index e02a2cb8..ee7e7612 100644 --- a/cmd/api/api_test.go +++ b/cmd/api/api_test.go @@ -128,7 +128,7 @@ func TestApi(t *testing.T) { t.Run("writes the body of a failed answer, and fails with a hint to api-docs on a client error", func(t *testing.T) { for status, wantError := range map[int]string{ - gohttp.StatusConflict: "meshStack answered HTTP 409: still in use. Run meshstack api-docs /api/x -H 'Accept: application/json' -X DELETE to see what the API takes", + gohttp.StatusConflict: "meshStack answered HTTP 409: still in use. Run meshstack api-docs /api/x -H 'Accept: application/json' -X DELETE to see what the API expects", gohttp.StatusBadGateway: "meshStack answered HTTP 502: still in use", gohttp.StatusForbidden: "auth scope profile default: meshStack answered HTTP 403: still in use", } { @@ -143,7 +143,7 @@ func TestApi(t *testing.T) { t.Run("points to api-docs on a 404 only for a path the API docs do not list", func(t *testing.T) { for path, wantError := range map[string]string{ - "/api/x": "meshStack answered HTTP 404: gone (errorCode NotFound). Run meshstack api-docs /api/x -X GET -H 'Accept: application/json' to see what the API takes", + "/api/x": "meshStack answered HTTP 404: gone (errorCode NotFound). Run meshstack api-docs /api/x -X GET -H 'Accept: application/json' to see what the API expects", "/api/meshobjects/meshtenants/0c9ef6d1-5c5d-4b0c-9a4b-1e0e3c1f2a3b": "meshStack answered HTTP 404: gone (errorCode NotFound)", } { meshStack.answer(gohttp.StatusNotFound, `{"errorCode":"NotFound","message":"gone"}`) @@ -295,7 +295,7 @@ func TestApi(t *testing.T) { require.EqualError(t, err, `the API docs have no action "list" for meshTenant; use one of show, delete`) _, err = meshStack.run(t, api.NewDocs(), "", "--describe", "bb", "/api/meshobjects/meshbuildingblocks") - require.EqualError(t, err, "--describe takes no path, see --help") + require.EqualError(t, err, "--describe cannot be combined with a path; see --help") _, err = meshStack.run(t, api.NewDocs(), "", "--describe", "bb", "--api-version", "v1") require.ErrorContains(t, err, "[api-version describe] were all set") diff --git a/cmd/api/docs.go b/cmd/api/docs.go index 6a23ef21..81c7729b 100644 --- a/cmd/api/docs.go +++ b/cmd/api/docs.go @@ -35,15 +35,17 @@ func NewDocs() *cobra.Command { cmd := &cobra.Command{ Use: "api-docs []", Short: "Show what the meshStack API docs say about a request or a meshObject kind", - Long: `Show what the meshStack API docs say about a request, given in the same form that 'meshstack api' takes, or about the operations of a meshObject kind. + Long: fmt.Sprintf(`Show what the meshStack API docs say about a request or about a meshObject kind. -Without a path, it lists every operation of the API, filtered by --method and --api-version if given. +Give the request as you would give it to 'meshstack api': a path or a URL, with --%[1]s, --%[2]s, --%[3]s and --%[4]s if needed. Without a path, the command lists every operation of the API. With --%[1]s or --%[4]s, it lists only the operations of that method or version. ---describe takes a kind by its command name, such as buildingblock or the alias bb, optionally followed by one of its actions after a dot, as in bb.list. It describes each operation of the kind, in the latest version that the kind offers. It cannot be combined with a path or with the request flags. +--%[5]s describes every operation of a meshObject kind, in the latest version of the kind. Give the kind by its command name, such as buildingblock, or by its alias, such as bb. To describe one action of the kind, add the action after a dot, as in bb.list. --%[5]s cannot be combined with a path or with the flags of a request. ---output json prints that part of the OpenAPI document. +--%[6]s %[7]s prints the matching part of the OpenAPI document as JSON. ---dev uses the development API docs.`, +--%[8]s uses the API docs of meshStack's development version.`, + methodFlagName, headerFlagName, requestJsonFlagName, apiVersionFlagName, describeFlagName, + internal.OutputFlagName, internal.OutputJson, devFlagName), Example: ` meshstack api-docs meshstack api-docs -X DELETE meshstack api-docs /api/meshobjects/meshtenants/ @@ -59,7 +61,7 @@ Without a path, it lists every operation of the API, filtered by --method and -- return err } if describe != "" && len(args) > 0 { - return fmt.Errorf("--%s takes no path, see --help", describeFlagName) + return fmt.Errorf("--%s cannot be combined with a path; see --help", describeFlagName) } if r.target.IsAbs() { endpoint, _, profileErr := profileFor(cmd.Context(), r.target) @@ -104,10 +106,10 @@ Without a path, it lists every operation of the API, filtered by --method and -- flags.register(cmd, "", "show only the operations of this HTTP method") cmd.Flags().StringVar(&describe, describeFlagName, "", "describe the operations of a meshObject kind, such as buildingblock, or one of them, such as bb.list") - cmd.MarkFlagsMutuallyExclusive(describeFlagName, "method") - cmd.MarkFlagsMutuallyExclusive(describeFlagName, "header") - cmd.MarkFlagsMutuallyExclusive(describeFlagName, "request-json") - cmd.MarkFlagsMutuallyExclusive(describeFlagName, "api-version") + cmd.MarkFlagsMutuallyExclusive(describeFlagName, methodFlagName) + cmd.MarkFlagsMutuallyExclusive(describeFlagName, headerFlagName) + cmd.MarkFlagsMutuallyExclusive(describeFlagName, requestJsonFlagName) + cmd.MarkFlagsMutuallyExclusive(describeFlagName, apiVersionFlagName) output.Register(cmd.Flags()) return cmd diff --git a/cmd/api/profile.go b/cmd/api/profile.go index dd1a775d..aa847917 100644 --- a/cmd/api/profile.go +++ b/cmd/api/profile.go @@ -71,14 +71,14 @@ func profileFor(ctx context.Context, requestURL *url.URL) (endpoint xurl.URL, so return xurl.URL{}, nil, err } if !current { - slog.WarnContext(ctx, fmt.Sprintf("Using profile '%s' for %s, because its endpoint %s matches the URL; the current profile is '%s'", - selected, requestURL.Redacted(), selected.Endpoint, profiles.CurrentProfile)) + slog.WarnContext(ctx, fmt.Sprintf("Using profile '%s' for '%s' instead of the current profile ('%s') because its endpoint matches the given URL.", + selected, requestURL.Redacted(), profiles.CurrentProfile)) } endpoint = selected.Endpoint } return endpoint, setting.Sources{setting.FallbackSource{Source: setting.LookupSource{ MatchingKey: profile.NameSetting.EnvKey(), - Description: "profile whose endpoint holds " + requestURL.Redacted(), + Description: "profile whose endpoint matches " + requestURL.Redacted(), Func: func(context.Context) (string, error) { return string(selected.Name), nil }, }}}, nil } diff --git a/cmd/api/request.go b/cmd/api/request.go index b00fe59f..ff2c4f59 100644 --- a/cmd/api/request.go +++ b/cmd/api/request.go @@ -19,6 +19,14 @@ import ( "github.com/meshcloud/meshstack-cli/internal/apidocs" ) +const ( + methodFlagName = "method" + headerFlagName = "header" + requestJsonFlagName = "request-json" + apiVersionFlagName = "api-version" + devFlagName = "dev" +) + type requestFlags struct { method string headers []string @@ -28,11 +36,11 @@ type requestFlags struct { } func (f *requestFlags) register(cmd *cobra.Command, defaultMethod, methodUsage string) { - cmd.Flags().StringVarP(&f.method, "method", "X", defaultMethod, methodUsage) - cmd.Flags().StringArrayVarP(&f.headers, "header", "H", nil, "add a request header, as 'key: value'") - f.requestJson.Register(cmd.Flags(), "request-json", "JSON file to send as the request body, or - for stdin") - cmd.Flags().StringVar(&f.apiVersion, "api-version", "", "version of the meshObject API, such as v1 or v2-preview, instead of the latest") - cmd.Flags().BoolVar(&f.dev, "dev", false, "use the API docs of meshStack's development version") + cmd.Flags().StringVarP(&f.method, methodFlagName, "X", defaultMethod, methodUsage) + cmd.Flags().StringArrayVarP(&f.headers, headerFlagName, "H", nil, "add a request header, as 'key: value'") + f.requestJson.Register(cmd.Flags(), requestJsonFlagName, "JSON file to send as the request body, or - for stdin") + cmd.Flags().StringVar(&f.apiVersion, apiVersionFlagName, "", "version of the meshObject API, such as v1 or v2-preview, instead of the latest") + cmd.Flags().BoolVar(&f.dev, devFlagName, false, "use the API docs of meshStack's development version") } type request struct { @@ -105,7 +113,7 @@ func (f *requestFlags) selector(r request) (openapi.Selector, error) { } } if len(slices.Compact(slices.Clone(given))) > 1 { - return selector, fmt.Errorf("the Accept and Content-Type headers, --api-version and the body's apiVersion request different versions: %s", strings.Join(given, ", ")) + return selector, fmt.Errorf("the Accept and Content-Type headers, --%s and the body's apiVersion request different versions: %s", apiVersionFlagName, strings.Join(given, ", ")) } if len(given) > 0 { var err error @@ -134,11 +142,11 @@ func (f *requestFlags) loadApiDocs(cmd *cobra.Command) (openapi.Spec, func(), er func docsCommand(cmd *cobra.Command, args []string) string { words := []string{"meshstack", "api-docs"} words = append(words, args...) - if method := cmd.Flags().Lookup("method"); !method.Changed { + if method := cmd.Flags().Lookup(methodFlagName); !method.Changed { words = append(words, "-X", method.Value.String()) } cmd.Flags().Visit(func(flag *pflag.Flag) { - if flag.Name == "request-json" && flag.Value.String() == "-" { + if flag.Name == requestJsonFlagName && flag.Value.String() == "-" { return } name := "--" + flag.Name diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 909e0de5..7fa0a2a1 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -33,7 +33,7 @@ func NewLoginTo(name profile.Name) *cobra.Command { func newLogin(profileSources setting.Sources) *cobra.Command { var ( openStdinFlag = newStdinFlag() - apiKeyFlag = internal.NewFlagForSetting[uuid.UUID]("apikey", setting.ApiKeyClientId) + apiKeyFlag = internal.NewFlagForSetting[uuid.UUID](apiKeyFlagName, setting.ApiKeyClientId) apiTokenFlag = internal.NewFlagForSetting[bool]("apitoken", setting.ApiToken) output internal.ShowFlag ) @@ -43,9 +43,9 @@ func newLogin(profileSources setting.Sources) *cobra.Command { Short: "Log in to meshStack", Long: `Log in to meshStack and store the credential in a profile. The command creates the profile if it does not exist yet. -Without --` + internal.ProfileFlag.Name.String() + `, it asks which of the stored profiles for the endpoint to log in to. Without --apikey or --apitoken, it opens a browser login and asks which workspace to work in. Use --` + internal.WorkspaceFlag.Name.String() + ` to choose the workspace without the question. If you choose a different access level than in the profile's previous browser login, the command ends that previous login first. +Without --` + internal.ProfileFlag.Name.String() + `, the command asks which of the stored profiles for the endpoint to log in to. Without --` + apiKeyFlag.Name.String() + ` or --` + apiTokenFlag.Name.String() + `, it opens a browser login and asks which workspace to work in. Use --` + internal.WorkspaceFlag.Name.String() + ` to choose the workspace without the question. If you choose a different access level than in the profile's previous browser login, the command ends that previous login first. -Afterwards it prints the same output as meshstack auth status for the new login.`, +Afterwards, the command prints the same output as 'meshstack auth status' for the new login.`, Args: func(cmd *cobra.Command, args []string) error { if len(args) == 0 { return nil @@ -129,6 +129,8 @@ Afterwards it prints the same output as meshstack auth status for the new login. return cmd } +const apiKeyFlagName internal.FlagName = "apikey" + const apiKeyIdFromEnvironment = "" type bareApiKeyFlag struct{ pflag.Value } @@ -138,7 +140,7 @@ func (f bareApiKeyFlag) Set(id string) error { case apiKeyIdFromEnvironment: return nil case "": - return errors.New("the API key id is empty; give a bare --apikey to read it from the environment") + return fmt.Errorf("the API key id is empty; give a bare --%s to read it from the environment", apiKeyFlagName) } return f.Value.Set(id) } diff --git a/cmd/auth/logout.go b/cmd/auth/logout.go index 5d405c4e..df7c8c4b 100644 --- a/cmd/auth/logout.go +++ b/cmd/auth/logout.go @@ -16,10 +16,10 @@ import ( func newLogout() *cobra.Command { cmd := &cobra.Command{ Use: "logout", - Short: "Remove this profile's stored credentials", - Long: `Remove this profile's stored credentials. For a browser login, this also ends the login's session at meshStack, so its tokens stop working. Your browser stays signed in to meshStack. + Short: "Log out by removing the stored credentials of a profile", + Long: `Remove the stored credentials of a profile. For a browser login, this also ends the login's session at meshStack, so its tokens stop working. Your browser stays signed in to meshStack. -Without --` + internal.ProfileFlag.Name.String() + `, it asks which of the stored profiles for the endpoint to log out of.`, +Without --` + internal.ProfileFlag.Name.String() + `, the command asks which of the stored profiles for the endpoint to log out of.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { ctx := cmd.Context() diff --git a/cmd/auth/status.go b/cmd/auth/status.go index 7833f0c2..8a65669e 100644 --- a/cmd/auth/status.go +++ b/cmd/auth/status.go @@ -39,8 +39,8 @@ func newStatus() *cobra.Command { var output internal.ShowFlag cmd := &cobra.Command{ Use: "status", - Short: "Show which credential commands authenticate with", - Long: `Show which credential other commands with the same flags and environment would authenticate with: where it comes from, who it is for, and how long it is valid. It never shows a secret. + Short: "Show the credential that commands authenticate with", + Long: `Show the credential that other commands would authenticate with, given the same flags and environment: where it comes from, whom it belongs to, and how long it is valid. The command never shows a secret. This command does not renew the token of a browser login, so the token may show as expired. The next command that needs the token renews it.`, Args: cobra.NoArgs, diff --git a/cmd/auth/token.go b/cmd/auth/token.go index fc2f8c4e..188bac61 100644 --- a/cmd/auth/token.go +++ b/cmd/auth/token.go @@ -13,9 +13,9 @@ func newToken() *cobra.Command { return &cobra.Command{ Use: "token", Short: "Print an access token for the meshStack API", - Long: `Print the access token that other commands with the same flags and environment would send. The command renews the token first if it is about to expire. It prints nothing else to stdout, so $(meshstack auth token) works. + Long: `Print the access token that other commands with the same flags and environment would send. The command renews the token first if it is about to expire. The command prints nothing else to stdout, so $(meshstack auth token) works. -Paste the token as the Bearer token in the API docs to try a request against your meshStack. The token is short-lived: run this command again once the API returns 401. In scripts, use meshstack api, which renews the token by itself. For a browser login, the token is for the current workspace.`, +Paste the token as the Bearer token in the API docs to try a request against your meshStack. The token is short-lived: run this command again once the API returns 401. In scripts, use 'meshstack api', which renews the token by itself. For a browser login, the token is for the current workspace.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { session, err := auth.ResolveSession(cmd.Context(), internal.ResolveClientOptions()) diff --git a/cmd/buildingblock/approve_run.go b/cmd/buildingblock/approve_run.go index 7dac0444..27fe7385 100644 --- a/cmd/buildingblock/approve_run.go +++ b/cmd/buildingblock/approve_run.go @@ -29,7 +29,7 @@ func newApproveRun() *cobra.Command { The command shows the plan and asks you to approve it, so it needs a terminal. It approves exactly the plan it showed. If meshStack planned the run again in the meantime, the approval fails, and you can run the command again to review the new plan. -A building block whose status.action is APPROVE_RUN in "meshstack buildingblock list" has a run that is waiting for approval. After the approval, the command prints the building block.`, +A building block whose status.action is APPROVE_RUN in 'meshstack buildingblock list' has a run that is waiting for approval. After the approval, the command prints the building block.`, Example: ` meshstack buildingblock approve-run 0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90 meshstack bb approve-run 0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90`, Args: internal.UuidArg(&buildingBlockUuid), diff --git a/cmd/buildingblock/tfstate/exec.go b/cmd/buildingblock/tfstate/exec.go index 2c91fa74..9fd6f0c4 100644 --- a/cmd/buildingblock/tfstate/exec.go +++ b/cmd/buildingblock/tfstate/exec.go @@ -31,6 +31,15 @@ const ( modeReadWrite mode = "readwrite" ) +const ( + modeFlagName = "mode" + forceFlagName = "force" + overrideBackendFlagName = "override-backend" + backupDirFlagName = "backup-dir" +) + +var readWriteMode = fmt.Sprintf("--%s %s", modeFlagName, modeReadWrite) + func (m *mode) String() string { return string(*m) } @@ -61,27 +70,28 @@ func newExec() *cobra.Command { cmd := &cobra.Command{ Use: "exec -- [args...]", Short: "Run tofu against the state of a building block", - Long: `Run a command, such as tofu plan, against the state of a building block. Give the building block by its uuid. + Long: fmt.Sprintf(`Run a command, such as tofu plan, against the state of a building block. Give the building block by its uuid. -exec passes the state to tofu's http backend through the TF_HTTP_* variables. It exits with the exit code of the command. By default, it uses the state stored in the building block's own workspace, metadata.ownedByWorkspace. Use --` + internal.WorkspaceFlag.Name.String() + ` to choose another workspace. +exec passes the state to tofu's http backend through the TF_HTTP_* variables. It exits with the exit code of the command. By default, it uses the state stored in the building block's own workspace, metadata.ownedByWorkspace. Use --%[1]s to choose another workspace. -The module needs a backend "http" block. The runner adds this block only during its own runs. With --override-backend, exec adds the block while the command runs: it writes the file ` + tfstate.BackendOverrideFile + ` into the directory given by tofu -chdir, or into the working directory if there is no -chdir. In that directory, the file also replaces any backend that the module defines. exec removes the file when the command ends. If the command is a wrapper that runs tofu, run it in the module's directory. Run tofu init through exec with --override-backend once. +The module needs a backend "http" block. The runner adds this block only during its own runs. With --%[2]s, exec adds the block while the command runs: it writes the file %[3]s into the directory given by tofu -chdir, or into the working directory if there is no -chdir. In that directory, the file also replaces any backend that the module defines. exec removes the file when the command ends. If the command is a wrapper that runs tofu, run it in the module's directory. Run tofu init through exec with --%[2]s once. tofu's meshstack provider also connects to meshStack through exec, with the login of the meshStack CLI. This works with any version of the provider, as long as its provider "meshstack" block sets no endpoint and no credentials. Settings in that block take precedence over the ones exec provides. ---mode read is the default. In this mode, exec refuses to store a state, and the provider can only read. exec takes no lock, so a run of the building block never has to wait for it. A plan may read a state that a run is changing at the same time. +--%[4]s %[5]s is the default. In this mode, exec refuses to store a state, and the provider can only read. exec takes no lock, so a run of the building block never has to wait for it. A plan may read a state that a run is changing at the same time. ---mode readwrite stores what the command writes, and locks the state in meshStack whenever tofu asks for a lock. A run of the building block waits up to 5 minutes for that lock, and then fails. Without --force, --mode readwrite fails in these cases: +%[6]s stores what the command writes, and locks the state in meshStack whenever tofu asks for a lock. A run of the building block waits up to 5 minutes for that lock, and then fails. Without --%[7]s, %[6]s fails in these cases: - a run of the building block is pending, in progress or waiting for approval; - the new state has another lineage than the stored state, or its serial is not higher. -With --force, you can stop a destructive run that someone approved by mistake: while tofu holds the lock, the run waits for it and then fails. +With --%[7]s, you can stop a destructive run that someone approved by mistake: while tofu holds the lock, the run waits for it and then fails. -With --backup-dir, exec copies the stored state into that directory before it replaces or deletes it.`, +With --%[8]s, exec copies the stored state into that directory before it replaces or deletes it.`, + internal.WorkspaceFlag.Name, overrideBackendFlagName, tfstate.BackendOverrideFile, modeFlagName, modeRead, readWriteMode, forceFlagName, backupDirFlagName), Example: ` meshstack bb tfstate exec 0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90 --mode readwrite -- tofu apply`, Args: func(cmd *cobra.Command, args []string) error { if cmd.ArgsLenAtDash() != 1 || len(args) < 2 { - return fmt.Errorf("%s takes the building block uuid, then -- and the command to run, such as '%s -- tofu plan'", + return fmt.Errorf("%s needs the building block uuid, then --, then the command to run, such as '%s -- tofu plan'", cmd.CommandPath(), cmd.CommandPath()) } command = args[1:] @@ -114,9 +124,9 @@ With --backup-dir, exec copies the stored state into that directory before it re OnProblem: func(ctx context.Context, err error) { switch { case errors.Is(err, tfstate.ErrReadOnly): - err = fmt.Errorf("%w, run again with --mode readwrite to let the command store it", err) + err = fmt.Errorf("%w, run again with %s to let the command store it", err, readWriteMode) case errors.Is(err, tfstate.ErrReadOnlyApi): - err = fmt.Errorf("%w, run again with --mode readwrite to let the command change meshStack", err) + err = fmt.Errorf("%w, run again with %s to let the command change meshStack", err, readWriteMode) } if !slices.ContainsFunc(problems, func(problem error) bool { return problem.Error() == err.Error() }) { slog.WarnContext(ctx, err.Error()) @@ -130,7 +140,7 @@ With --backup-dir, exec copies the stored state into that directory before it re if !force { proxy.BeforeWrite = func(ctx context.Context) error { if running := tfstate.NoRunOf(ctx, meshStack.Raw, buildingBlockUuid); running != nil { - return fmt.Errorf("%w: wait for it to finish, or run again with --force", running) + return fmt.Errorf("%w: wait for it to finish, or run again with --%s", running, forceFlagName) } return nil } @@ -144,7 +154,7 @@ With --backup-dir, exec copies the stored state into that directory before it re if overrideBackend { remove, overrideErr := tfstate.WriteBackendOverride(tfstate.ModuleDir(command)) if overrideErr != nil { - return fmt.Errorf("%w: remove it, or run without --override-backend", overrideErr) + return fmt.Errorf("%w: remove it, or run without --%s", overrideErr, overrideBackendFlagName) } defer func() { if removeErr := remove(); removeErr != nil { @@ -161,22 +171,22 @@ With --backup-dir, exec copies the stored state into that directory before it re switch { case requests > 0: case overrideBackend: - slog.WarnContext(ctx, fmt.Sprintf("%s did not request any state, although --override-backend added the backend in %s. If the command is a wrapper that runs tofu, run it in the module's directory", - command[0], tfstate.ModuleDir(command))) + slog.WarnContext(ctx, fmt.Sprintf("%s did not request any state, although --%s added the backend in %s. If the command is a wrapper that runs tofu, run it in the module's directory", + command[0], overrideBackendFlagName, tfstate.ModuleDir(command))) default: slog.WarnContext(ctx, fmt.Sprintf("%s did not request any state. The module probably has no backend \"http\" block, because the runner adds one only during its own runs. "+ - "Run again with --override-backend, and run '%s %s --override-backend -- tofu init' once", - command[0], cmd.CommandPath(), buildingBlockUuid)) + "Run again with --%[4]s, and run '%[2]s %[3]s --%[4]s -- tofu init' once", + command[0], cmd.CommandPath(), buildingBlockUuid, overrideBackendFlagName)) } return exitWith(cmd, ran, withRightsHint(errors.Join(problems...))) }, } flags := cmd.Flags() - flags.Var(&access, "mode", "read: only read the state (default); readwrite: also store what the command writes") - flags.BoolVar(&force, "force", false, "with --mode readwrite, store the state even while a run of the building block is pending, in progress or waiting for approval, or when the new state does not follow the stored state") - flags.BoolVar(&overrideBackend, "override-backend", false, "while the command runs, add a backend \"http\" block to the module in tofu's -chdir directory or in the working directory") - flags.StringVar(&backupDir, "backup-dir", "", "with --mode readwrite, copy the stored state into this directory before each write; only you can read the copies, because a state can hold secrets") + flags.Var(&access, modeFlagName, fmt.Sprintf("%s: only read the state (default); %s: also store what the command writes", modeRead, modeReadWrite)) + flags.BoolVar(&force, forceFlagName, false, "with "+readWriteMode+", store the state even while a run of the building block is pending, in progress or waiting for approval, or when the new state does not follow the stored state") + flags.BoolVar(&overrideBackend, overrideBackendFlagName, false, "while the command runs, add a backend \"http\" block to the module in tofu's -chdir directory or in the working directory") + flags.StringVar(&backupDir, backupDirFlagName, "", "with "+readWriteMode+", copy the stored state into this directory before each write; only you can read the copies, because a state can hold secrets") return cmd } diff --git a/cmd/buildingblock/tfstate/force_unlock.go b/cmd/buildingblock/tfstate/force_unlock.go index b9e8c781..a4869407 100644 --- a/cmd/buildingblock/tfstate/force_unlock.go +++ b/cmd/buildingblock/tfstate/force_unlock.go @@ -24,7 +24,7 @@ func newForceUnlock() *cobra.Command { Short: "Release a stale lock on the state of a building block", Long: `Release a lock on the state of a building block that its holder left behind, for example because a tofu process was killed. Give the building block by its uuid. Unlike tofu force-unlock, this command needs no module directory and no lock ID. It shows who holds the lock, and asks before it releases the lock. Only the answer yes releases the lock; a script can pipe yes to stdin. -If the lock belongs to a run of the building block that is pending or in progress, the command refuses to release it, because that run is still writing the state. Use --force to release it anyway. --force does not skip the question. +If the lock belongs to a run of the building block that is pending or in progress, the command refuses to release it, because that run is still writing the state. Use --` + forceFlagName + ` to release it anyway. The command asks even with --` + forceFlagName + `. By default, the command uses the state stored in the building block's own workspace, metadata.ownedByWorkspace. Use --` + internal.WorkspaceFlag.Name.String() + ` to choose another workspace.`, Example: ` meshstack bb tfstate force-unlock 0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90 @@ -51,7 +51,7 @@ By default, the command uses the state stored in the building block's own worksp if !force { if running := lock.NoUnfinishedRun(ctx, meshStack.Raw); running != nil { - return fmt.Errorf("%w: wait for it to finish, or run again with --force", running) + return fmt.Errorf("%w: wait for it to finish, or run again with --%s", running, forceFlagName) } } p := prompt.New(cmd.InOrStdin(), cmd.ErrOrStderr()) @@ -74,7 +74,7 @@ By default, the command uses the state stored in the building block's own worksp }, } - cmd.Flags().BoolVar(&force, "force", false, "release the lock even if it belongs to a run that is pending or in progress") + cmd.Flags().BoolVar(&force, forceFlagName, false, "release the lock even if it belongs to a run that is pending or in progress") return cmd } diff --git a/cmd/buildingblock/tfstate/tfstate.go b/cmd/buildingblock/tfstate/tfstate.go index b254d28e..383f6556 100644 --- a/cmd/buildingblock/tfstate/tfstate.go +++ b/cmd/buildingblock/tfstate/tfstate.go @@ -14,19 +14,14 @@ import ( "github.com/meshcloud/meshstack-cli/pkg/setting" ) -const rights = `meshStack gives the state to a login that has the rights of one of these rows in the -workspace that the login works in: +var rights = `meshStack gives the state only to a login that has the rights of one of these rows, in the workspace that the login works in: - Workspace the login works in To read To write (--mode readwrite) + Workspace the login works in To read To write (` + readWriteMode + `) the building block's workspace TFSTATE_LIST TFSTATE_SAVE the workspace owning its definition MANAGED_TFSTATE_LIST MANAGED_TFSTATE_SAVE the admin workspace ADM_TFSTATE_LIST ADM_TFSTATE_SAVE -The roles Workspace Owner and Workspace Manager have the rights of the second row. So a browser -login works if its workspace owns the definition of the building block. For the other rows, you need -an API key with these rights; log in with meshstack login --apikey. Locking the state and -force-unlock need the right to write the state. To delete the state, the login needs -TFSTATE_DELETE, MANAGED_TFSTATE_DELETE or ADM_TFSTATE_DELETE in the same row.` +The roles Workspace Owner and Workspace Manager have the rights of the second row. So a browser login works if its workspace owns the definition of the building block. For the other rows, you need an API key with these rights; log in with 'meshstack login --apikey'. To lock the state or to run force-unlock, the login needs the right to write the state. To delete the state, the login needs TFSTATE_DELETE, MANAGED_TFSTATE_DELETE or ADM_TFSTATE_DELETE in the same row.` func New() *cobra.Command { cmd := &cobra.Command{ @@ -36,9 +31,9 @@ func New() *cobra.Command { ` + rights + ` -"meshstack buildingblock tfstate exec" passes the state to tofu's http backend, so the module needs a backend "http" block. The runner adds this block only during its own runs. exec with --override-backend adds it while its command runs. Run tofu init through exec once: +` + fmt.Sprintf(`'meshstack buildingblock tfstate exec' passes the state to tofu's http backend, so the module needs a backend "http" block. The runner adds this block only during its own runs. With --%[1]s, exec adds the block while its command runs. Run tofu init through exec once: - meshstack buildingblock tfstate exec --override-backend -- tofu init`, + meshstack buildingblock tfstate exec --%[1]s -- tofu init`, overrideBackendFlagName), Example: ` meshstack bb tfstate show 0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90 | jq .resources meshstack bb tfstate exec 0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90 --override-backend -- tofu plan meshstack bb tfstate exec 0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90 --mode readwrite -- tofu apply diff --git a/cmd/buildingblock/trigger_run.go b/cmd/buildingblock/trigger_run.go index 131a3d18..bf48d674 100644 --- a/cmd/buildingblock/trigger_run.go +++ b/cmd/buildingblock/trigger_run.go @@ -22,13 +22,13 @@ func newTriggerRun() *cobra.Command { cmd := &cobra.Command{ Use: "trigger-run ", Short: "Ask meshStack to run a building block", - Long: `Ask meshStack to run a building block. Give the building block by its uuid; "meshstack buildingblock list" shows it as metadata.uuid. + Long: `Ask meshStack to run a building block. Give the building block by its uuid; 'meshstack buildingblock list' shows it as metadata.uuid. -The command waits until meshStack has started the run, and then prints the building block. Its status.latestRunUuid is the run. If the definition requires an approval of the run, status.latestDryRunUuid is the preflight run, whose plan the approver sees. "meshstack buildingblockrun logs " shows how far a run got. +The command waits until meshStack has started the run, and then prints the building block. Its status.latestRunUuid is the run. If the definition requires an approval of the run, status.latestDryRunUuid is the preflight run, whose plan the approver sees. 'meshstack buildingblockrun logs ' shows how far a run got. -With --wait, the command waits until the run has ended. This is the same as piping meshStack's response to the run request into "meshstack buildingblock wait --before=-". +With --` + waitFlagName + `, the command waits until the run has ended. This is the same as piping meshStack's response to the run request into 'meshstack buildingblock wait --` + beforeFlagName + `=-'. -"meshstack api-docs --describe buildingblock.trigger-run" shows the fields of the response.`, +'meshstack api-docs --describe buildingblock.trigger-run' shows the fields of the response.`, Example: ` meshstack buildingblock trigger-run 0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90 meshstack bb trigger-run 0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90 -o ndjson | jq -r .status.latestRunUuid`, Args: internal.UuidArg(&buildingBlockUuid), @@ -66,11 +66,13 @@ With --wait, the command waits until the run has ended. This is the same as pipi } output.RegisterForItem(cmd.Flags()) - cmd.Flags().BoolVar(&wait, "wait", false, "wait until the run has ended, and fail if the run failed or is waiting for a person or a dependency") + cmd.Flags().BoolVar(&wait, waitFlagName, false, "wait until the run has ended, and fail if the run failed or is waiting for a person or a dependency") return cmd } +const waitFlagName = "wait" + func startedRunMessage(buildingBlockUuid uuid.UUID, accepted, reacted client.MeshBuildingBlockV2Status) string { switch dryRun, run := reacted.GetLatestDryRunUuid(), reacted.GetLatestRunUuid(); { case dryRun != "" && dryRun != accepted.GetLatestDryRunUuid(): diff --git a/cmd/buildingblock/wait.go b/cmd/buildingblock/wait.go index 69eab0dd..c420e28f 100644 --- a/cmd/buildingblock/wait.go +++ b/cmd/buildingblock/wait.go @@ -26,7 +26,7 @@ func newWait() *cobra.Command { Short: "Wait until the run of a building block has ended", Long: `Wait until meshStack has reacted to a write of a building block, and until the run that the write started has ended. Give the building block by its uuid. -meshStack responds to a write before it starts the run. So right after the write, the building block still shows the action of the previous run. Pass that response with --before: the response of a create (POST), an update (PUT) or a trigger-run, or a read taken before the change. The command first waits up to a minute until meshStack has reacted, following the rules of https://docs.meshcloud.io/concepts/building-block/#waiting-for-a-run. Then it waits until the run has ended. Press Ctrl-C to stop waiting. +meshStack responds to a write before it starts the run. So right after the write, the building block still shows the action of the previous run. Pass that response with --` + beforeFlagName + `: the response of a create (POST), an update (PUT) or a trigger-run, or a read taken before the change. The command first waits up to a minute until meshStack has reacted, following the rules of https://docs.meshcloud.io/concepts/building-block/#waiting-for-a-run. Then it waits until the run has ended. Press Ctrl-C to stop waiting. The command prints the building block. It fails and names the action if the run failed, was aborted or could not start, or if the run is waiting for a person or a dependency.`, Example: ` meshstack api -X PUT /api/meshobjects/meshbuildingblocks/0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90 --request-json block.json | @@ -46,12 +46,14 @@ The command prints the building block. It fails and names the action if the run } output.RegisterForItem(cmd.Flags()) - beforeInput.Register(cmd.Flags(), "before", "file with the building block from before the write, or - to read it from stdin") - _ = cmd.MarkFlagRequired("before") + beforeInput.Register(cmd.Flags(), beforeFlagName, "file with the building block from before the write, or - to read it from stdin") + _ = cmd.MarkFlagRequired(beforeFlagName) return cmd } +const beforeFlagName = "before" + func readBefore(cmd *cobra.Command, input internal.JsonInputFlag[client.MeshBuildingBlockV2], buildingBlockUuid uuid.UUID) (client.MeshBuildingBlockV2Status, error) { source := input.Source before, err := input.Read(cmd) @@ -60,9 +62,9 @@ func readBefore(cmd *cobra.Command, input internal.JsonInputFlag[client.MeshBuil } switch { case before.Metadata.Uuid != nil && *before.Metadata.Uuid != buildingBlockUuid.String(): - return client.MeshBuildingBlockV2Status{}, fmt.Errorf("--before %s contains building block %s, but the command waits for %s", source, *before.Metadata.Uuid, buildingBlockUuid) + return client.MeshBuildingBlockV2Status{}, fmt.Errorf("--%s %s contains building block %s, but the command waits for %s", beforeFlagName, source, *before.Metadata.Uuid, buildingBlockUuid) case before.Status == nil: - return client.MeshBuildingBlockV2Status{}, fmt.Errorf("--before %s contains no status of the building block", source) + return client.MeshBuildingBlockV2Status{}, fmt.Errorf("--%s %s contains no status of the building block", beforeFlagName, source) } return *before.Status, nil } diff --git a/cmd/buildingblockdefinitionversion/list.go b/cmd/buildingblockdefinitionversion/list.go index b07bfa6d..f649218d 100644 --- a/cmd/buildingblockdefinitionversion/list.go +++ b/cmd/buildingblockdefinitionversion/list.go @@ -9,6 +9,8 @@ import ( "github.com/meshcloud/meshstack-cli/cmd/internal" ) +const definitionFlagName = "definition" + func newList() *cobra.Command { var ( flags internal.ListFlags @@ -20,7 +22,7 @@ func newList() *cobra.Command { Short: "List the versions of a building block definition", Long: `List the versions of a building block definition. -Give the definition by its uuid; "meshstack buildingblockdefinition list" shows it as metadata.uuid. --definition is required, because meshStack returns the versions of one definition at a time. Reading them needs a permission in the workspace that owns the definition.`, +Give the definition by its uuid; 'meshstack buildingblockdefinition list' shows it as metadata.uuid. --` + definitionFlagName + ` is required, because meshStack returns the versions of one definition at a time. Reading them needs a permission in the workspace that owns the definition.`, Example: ` meshstack buildingblockdefinitionversion list --definition 3c9e1f7a-2b4d-4e6f-8a1c-5d7b9e2f4a6c meshstack bbdv list --definition 3c9e1f7a-2b4d-4e6f-8a1c-5d7b9e2f4a6c --limit 1`, Args: cobra.NoArgs, @@ -31,7 +33,7 @@ Give the definition by its uuid; "meshstack buildingblockdefinition list" shows }, } - cmd.Flags().Var((*internal.UuidFlag)(&definitionUuid), "definition", "list the versions of the building block definition with this uuid") + cmd.Flags().Var((*internal.UuidFlag)(&definitionUuid), definitionFlagName, "list the versions of the building block definition with this uuid") _ = cmd.MarkFlagRequired("definition") flags.Register(cmd.Flags()) diff --git a/cmd/buildingblockrun/list.go b/cmd/buildingblockrun/list.go index 4d85ab69..bec8a1ea 100644 --- a/cmd/buildingblockrun/list.go +++ b/cmd/buildingblockrun/list.go @@ -17,6 +17,8 @@ import ( "github.com/meshcloud/meshstack-cli/cmd/internal" ) +const buildingBlockFlagName = "building-block" + func newList() *cobra.Command { var ( flags internal.ListFlags @@ -28,7 +30,7 @@ func newList() *cobra.Command { Short: "List building block runs, newest first", Long: `List building block runs, newest first. ---building-block lists the runs of that building block. Without it, the command lists the runs of every building block in the workspace. If no workspace is set, it lists the runs of every building block that your credential can access.`, +With --` + buildingBlockFlagName + `, the command lists only the runs of that building block. Without it, the command lists the runs of every building block in the workspace. If no workspace is set, it lists the runs of every building block that your credential can access.`, Example: ` meshstack buildingblockrun list --building-block 0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90 meshstack bbrun list --workspace my-workspace --limit 20`, Args: cobra.NoArgs, @@ -49,7 +51,7 @@ func newList() *cobra.Command { }, } - cmd.Flags().Var((*internal.UuidFlag)(&buildingBlockUuid), "building-block", "list the runs of the building block with this uuid") + cmd.Flags().Var((*internal.UuidFlag)(&buildingBlockUuid), buildingBlockFlagName, "list the runs of the building block with this uuid") flags.Register(cmd.Flags()) return cmd diff --git a/cmd/buildingblockrun/logs.go b/cmd/buildingblockrun/logs.go index 0edf1ced..33b5a37c 100644 --- a/cmd/buildingblockrun/logs.go +++ b/cmd/buildingblockrun/logs.go @@ -17,6 +17,8 @@ import ( "github.com/meshcloud/meshstack-cli/cmd/internal" ) +const followFlagName = "follow" + func newLogs() *cobra.Command { var ( output internal.OutputFlag @@ -29,9 +31,9 @@ func newLogs() *cobra.Command { Short: "Show the logs of a building block run", Long: `Show the logs of a building block run. -Give the run by its uuid; "meshstack buildingblockrun list" shows it as metadata.uuid. "meshstack api-docs --describe buildingblockrun.logs" shows the fields of the response. +Give the run by its uuid; 'meshstack buildingblockrun list' shows it as metadata.uuid. 'meshstack api-docs --describe buildingblockrun.logs' shows the fields of the response. ---follow prints new log lines as text until the run has finished. It exits with status 0 if the run succeeded and 1 otherwise, so a script can use it to wait for a run.`, +With --` + followFlagName + `, the command prints new log lines as text until the run has finished. It then exits with status 0 if the run succeeded and 1 otherwise, so a script can use it to wait for a run.`, Example: ` meshstack buildingblockrun logs 7f3a2b1c-8d4e-4f6a-9b0c-1d2e3f4a5b6c meshstack bbrun logs 7f3a2b1c-8d4e-4f6a-9b0c-1d2e3f4a5b6c -o ndjson meshstack bbrun logs 7f3a2b1c-8d4e-4f6a-9b0c-1d2e3f4a5b6c --follow`, @@ -56,8 +58,8 @@ Give the run by its uuid; "meshstack buildingblockrun list" shows it as metadata } output.RegisterForItem(cmd.Flags()) - cmd.Flags().BoolVarP(&follow, "follow", "f", false, "keep printing new log lines until the run has finished") - cmd.MarkFlagsMutuallyExclusive("follow", "output") + cmd.Flags().BoolVarP(&follow, followFlagName, "f", false, "keep printing new log lines until the run has finished") + cmd.MarkFlagsMutuallyExclusive(followFlagName, internal.OutputFlagName) return cmd } diff --git a/cmd/eventlog/list.go b/cmd/eventlog/list.go index 00bdc0e4..c979a853 100644 --- a/cmd/eventlog/list.go +++ b/cmd/eventlog/list.go @@ -10,6 +10,11 @@ import ( "github.com/meshcloud/meshstack-cli/cmd/internal" ) +const ( + fromFlagName = "from" + untilFlagName = "until" +) + func newList() *cobra.Command { var ( flags internal.ListFlags @@ -23,7 +28,7 @@ func newList() *cobra.Command { Short: "List event logs, newest first", Long: `List event logs, newest first. ---from and --until take a date such as 2026-07-01, which means midnight UTC, or an instant such as 2026-07-01T12:00:00Z. +--` + fromFlagName + ` and --` + untilFlagName + ` accept a date such as 2026-07-01, which means midnight UTC, or an instant such as 2026-07-01T12:00:00Z. The command lists the event logs of the workspace. With an admin credential and no workspace set, it lists the event logs of all workspaces.`, Example: ` meshstack eventlog list --from 2026-07-01 --until 2026-07-02 @@ -32,7 +37,7 @@ The command lists the event logs of the workspace. With an admin credential and Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { if !from.instant.IsZero() && !until.instant.IsZero() && !from.instant.Before(until.instant) { - return fmt.Errorf("--from %s is not before --until %s, so no event log can match", &from, &until) + return fmt.Errorf("--%s %s is not before --%s %s, so no event log can match", fromFlagName, &from, untilFlagName, &until) } workspace, err := internal.ListWorkspace(cmd.Context()) if err != nil { @@ -53,8 +58,8 @@ The command lists the event logs of the workspace. With an admin credential and cmd.Flags().StringVar(&title, "title", "", "list the event logs whose title contains this text") cmd.Flags().StringArrayVar(&exclude, "exclude-title", nil, "leave out the event logs of exactly this title") - cmd.Flags().Var(&from, "from", "list the event logs created at or after this date or instant") - cmd.Flags().Var(&until, "until", "list the event logs created before this date or instant") + cmd.Flags().Var(&from, fromFlagName, "list the event logs created at or after this date or instant") + cmd.Flags().Var(&until, untilFlagName, "list the event logs created before this date or instant") flags.Register(cmd.Flags()) return cmd diff --git a/cmd/internal/kind.go b/cmd/internal/kind.go index 412051d4..7feb88ef 100644 --- a/cmd/internal/kind.go +++ b/cmd/internal/kind.go @@ -37,9 +37,9 @@ func (command KindCommand) Aliases() []string { func (command KindCommand) Long(short string) string { return fmt.Sprintf(`%s. -The meshStack API docs say what each operation on them takes and returns: +The meshStack API docs describe the request and the response of each operation on them: meshstack api-docs --describe %s -meshstack api sends an operation that no command here covers.`, short, command) +'meshstack api' sends a request that none of these commands covers.`, short, command) } diff --git a/cmd/internal/output.go b/cmd/internal/output.go index 28454415..8394f53b 100644 --- a/cmd/internal/output.go +++ b/cmd/internal/output.go @@ -13,6 +13,8 @@ import ( "github.com/meshcloud/meshstack-cli/cmd/internal/markdown" ) +const OutputFlagName = "output" + type OutputFormat string const ( @@ -27,13 +29,13 @@ type OutputFlag struct{ Format OutputFormat } func (f *OutputFlag) Register(flags *pflag.FlagSet) { f.Format = OutputJson - flags.VarP(f, "output", "o", fmt.Sprintf("output format: %s for one array, or %s for one item per line", OutputJson, OutputNdjson)) + flags.VarP(f, OutputFlagName, "o", fmt.Sprintf("output format: %s for one array, or %s for one item per line", OutputJson, OutputNdjson)) } // RegisterForItem is Register for a command that writes one object through [OutputFormat.WriteItem]. func (f *OutputFlag) RegisterForItem(flags *pflag.FlagSet) { f.Format = OutputJson - flags.VarP(f, "output", "o", fmt.Sprintf("output format: %s for indented JSON, or %s for JSON on one line", OutputJson, OutputNdjson)) + flags.VarP(f, OutputFlagName, "o", fmt.Sprintf("output format: %s for indented JSON, or %s for JSON on one line", OutputJson, OutputNdjson)) } func (f *OutputFlag) String() string { @@ -133,7 +135,7 @@ type ShowFlag struct{ Format OutputFormat } func (f *ShowFlag) Register(flags *pflag.FlagSet) { f.Format = OutputMarkdown - flags.VarP(f, "output", "o", fmt.Sprintf("output format: %s for people, or %s for scripts", OutputMarkdown, OutputJson)) + flags.VarP(f, OutputFlagName, "o", fmt.Sprintf("output format: %s for people, or %s for scripts", OutputMarkdown, OutputJson)) } func (f *ShowFlag) String() string { diff --git a/cmd/meshstack/meshstack.go b/cmd/meshstack/meshstack.go index 60f314dc..5c1b2759 100644 --- a/cmd/meshstack/meshstack.go +++ b/cmd/meshstack/meshstack.go @@ -45,17 +45,17 @@ func newRootCommand() *cobra.Command { cmd := &cobra.Command{ Use: "meshstack", Short: "Command line interface for meshStack", - Long: `Command line interface for meshStack. + Long: fmt.Sprintf(`Command line interface for meshStack. -Start with meshstack login. It logs you in and keeps the login in a profile. +Start with 'meshstack login'. It logs you in and stores the login in a profile. AI agents: read https://docs.meshcloud.io/agent-context/index.md before you act, starting with its guardrails. The skill for this CLI is https://github.com/meshcloud/meshstack-cli/blob/main/skills/meshstack-cli/SKILL.md. -The endpoint, the profile and the workspace come from a flag, else from the environment variable that the flag's help names, else from the profile, which holds an endpoint and a default workspace. Without a profile named, the one whose endpoint matches is used, else the current profile, which meshstack login sets. +A command reads the endpoint, the profile and the workspace from its flag, else from the environment variable that the flag's help lists, else from the profile. A profile stores an endpoint and a default workspace. If you choose no profile, the command uses the profile whose endpoint matches, else the current profile, which 'meshstack login' sets. -AI agents and scripts: add -o json to a list or show command to read JSON. meshstack api sends a request to any path of the meshStack API, and meshstack api-docs describes that path. +AI agents and scripts: add --%s %s to a list or show command to get JSON. 'meshstack api' sends a request to any path of the meshStack API, and 'meshstack api-docs' describes that path. -Colors follow NO_COLOR and FORCE_COLOR, and stay off in a pipe.`, +The command honors NO_COLOR and FORCE_COLOR, and prints no colors into a pipe.`, internal.OutputFlagName, internal.OutputJson), Example: ` meshstack login meshstack bb list -o json meshstack api-docs --describe bb.list diff --git a/cmd/profile/delete.go b/cmd/profile/delete.go index e7185908..4d65ecae 100644 --- a/cmd/profile/delete.go +++ b/cmd/profile/delete.go @@ -13,13 +13,13 @@ import ( ) func newDelete() *cobra.Command { - yesFlag := internal.Flag[bool]{Name: "yes", Help: "delete the profile selected by --profile or --endpoint without asking for confirmation"} + yesFlag := internal.Flag[bool]{Name: "yes", Help: fmt.Sprintf("delete the profile selected by --%s or --%s without asking for confirmation", internal.ProfileFlag.Name, internal.EndpointFlag.Name)} cmd := &cobra.Command{ Use: "delete", Short: "Delete a profile and its stored credentials", - Long: `Delete a profile and its stored credentials. For a browser login, this also ends the login's session at meshStack, as meshstack auth logout does. Your browser stays signed in to meshStack. + Long: `Delete a profile and its stored credentials. For a browser login, this also ends the login's session at meshStack, as 'meshstack auth logout' does. Your browser stays signed in to meshStack. -Without --` + internal.ProfileFlag.Name.String() + `, it asks which of the profiles for the endpoint to delete. It asks for confirmation before it deletes. Use --yes to skip the confirmation; --yes needs --` + internal.ProfileFlag.Name.String() + `, or an --` + internal.EndpointFlag.Name.String() + ` that matches exactly one profile.`, +Without --` + internal.ProfileFlag.Name.String() + `, the command asks which of the profiles for the endpoint to delete. It asks for confirmation before it deletes. Use --` + yesFlag.Name.String() + ` to skip the confirmation; --` + yesFlag.Name.String() + ` needs --` + internal.ProfileFlag.Name.String() + `, or an --` + internal.EndpointFlag.Name.String() + ` that matches exactly one profile.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { ctx := cmd.Context() diff --git a/cmd/profile/profile.go b/cmd/profile/profile.go index 5f59545f..2d93965d 100644 --- a/cmd/profile/profile.go +++ b/cmd/profile/profile.go @@ -14,9 +14,9 @@ func New() *cobra.Command { cmd := &cobra.Command{ Use: "profile", Short: "Manage the stored profiles", - Long: `Manage your profiles. A profile stores an endpoint, a credential and a default workspace. meshstack login creates profiles as well. + Long: `Manage your profiles. A profile stores an endpoint, a credential and a default workspace. 'meshstack login' also creates profiles. -In a terminal, this command opens an interactive list where you can add, edit and delete profiles, and choose the current one. Outside a terminal, it shows this help. Use the subcommands there: list and show print the profiles, and add, edit and delete ask one question per line.`, +In a terminal, this command opens an interactive list where you can add, edit and delete profiles, and choose the current one. Outside a terminal, it shows this help. There, use the subcommands: list and show print the profiles, and add, edit and delete ask one question per line.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { ctx := cmd.Context() diff --git a/internal/auth/credentials.go b/internal/auth/credentials.go index d34be7be..5202a838 100644 --- a/internal/auth/credentials.go +++ b/internal/auth/credentials.go @@ -64,7 +64,7 @@ func (s Session) storedCredential(ctx context.Context, noSourceErrs []error) (Cr } current := creds.ByName(currentProfile.Credential) if current == nil { - return Credential{}, fmt.Errorf("profile '%s' selects credential '%s', but %s holds none; run 'meshstack login --profile %s'", + return Credential{}, fmt.Errorf("profile '%s' selects credential '%s', but %s contains none; run 'meshstack login --profile %s'", currentProfile, currentProfile.Credential, creds.FilePath, currentProfile) } slog.DebugContext(ctx, fmt.Sprintf("Using credential %s of profile %s", currentProfile.Credential, currentProfile)) diff --git a/internal/config/directory.go b/internal/config/directory.go index 8d6bfcf3..0b87945c 100644 --- a/internal/config/directory.go +++ b/internal/config/directory.go @@ -13,10 +13,10 @@ type Directory string var DirectorySetting = setting.Setting[Directory]{ Env: "MESHSTACK_CONFIG_DIR", Short: func(envKey string) string { - return fmt.Sprintf("The directory holding profiles.json and one credentials file per profile. Also read from %s.", envKey) + return fmt.Sprintf("The directory that contains profiles.json and one credentials file per profile. Also read from %s.", envKey) }, Long: func(envKey string) string { - return fmt.Sprintf("The directory holding the meshStack CLI's configuration, also read from `%s`.\n\n"+ + return fmt.Sprintf("The directory that contains the meshStack CLI's configuration, also read from `%s`.\n\n"+ "`profiles.json` describes every profile, and `credentials/.json` holds that profile's "+ "credentials and its cached tokens.", envKey) }, diff --git a/internal/meshstack/settings.go b/internal/meshstack/settings.go index 81b27869..61dbd1dc 100644 --- a/internal/meshstack/settings.go +++ b/internal/meshstack/settings.go @@ -10,11 +10,11 @@ import ( var EndpointSetting = setting.Setting[xurl.URL]{ Env: "MESHSTACK_ENDPOINT", Short: func(envKey string) string { - return fmt.Sprintf("The meshStack API to act against, such as https://api.example.meshcloud.io. Also read from %s.", envKey) + return fmt.Sprintf("The URL of the meshStack API, such as https://api.example.meshcloud.io. Also read from %s.", envKey) }, Long: func(envKey string) string { - return fmt.Sprintf("The meshStack API to act against, such as `https://api.example.meshcloud.io`, "+ - "also read from `%s` and inferred from current profile if possible.", envKey) + return fmt.Sprintf("The URL of the meshStack API, such as `https://api.example.meshcloud.io`, "+ + "also read from `%s`, else taken from the current profile.", envKey) }, Parse: setting.ParseTextUnmarshaler[xurl.URL], } diff --git a/internal/meshstack/workspace.go b/internal/meshstack/workspace.go index 64951168..92b87d64 100644 --- a/internal/meshstack/workspace.go +++ b/internal/meshstack/workspace.go @@ -23,7 +23,7 @@ func (w Workspace) String() string { var WorkspaceSetting = setting.Setting[Workspace]{ Env: "MESHSTACK_WORKSPACE", Short: func(envKey string) string { - return fmt.Sprintf("The workspace to act in, identified as in meshPanel, such as my-workspace-ab12c. Also read from %s.", envKey) + return fmt.Sprintf("The workspace to work in, identified as in meshPanel, such as my-workspace-ab12c. Also read from %s.", envKey) }, Parse: setting.ParseText[Workspace], } @@ -36,7 +36,7 @@ type NoWorkspaceToWorkInError struct { func (e NoWorkspaceToWorkInError) Error() string { if e.MayNotList { - return "this login has no workspace to work in, as it may not list workspaces; ask for access to one in meshPanel, or use --workspace to choose a workspace" + return "this login has no workspace to work in, because it is not allowed to list workspaces; ask for access to one in meshPanel, or use --workspace to choose a workspace" } return "this login has no workspace to work in; ask for access to one in meshPanel" }