Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,13 @@ installers.

Every non-dry-run `install` and `update` also refreshes the status of
docs-builder, the Vale binary, Elastic Vale rules, managed skills, and Elastic
Docs Utils.
Docs Utils. Each component that is not current prints its next step. A
component shown as `not installed` is usually one that was never requested;
install it with the matching `--with-...` option above.

Version lookups use the unauthenticated GitHub API, which allows 60 requests
per hour per address. Set `GITHUB_TOKEN` or `GH_TOKEN` to avoid that shared
limit; without it, exhausted lookups report `unknown` and say so.

For a complete first-time setup, use `--with-docs-tools`. It runs the
maintained installers for Vale, Elastic Vale rules, and docs-builder:
Expand All @@ -78,6 +84,14 @@ overwrites an existing docs-builder binary. Vale itself remains managed by its
platform package manager when it is already installed; the Vale installer does
not forcibly replace that executable.

Add `--yes` where nothing can answer a prompt, such as CI or a command with no
terminal attached. It closes the installers' input rather than accepting
replacement prompts, so existing configuration is left alone.

Because these tools are optional, a failing installer does not abandon the
rest of the command. Skills and host adapters are still configured, every
failure is reported, and the command exits non-zero.

See the [command and managed-locations reference](docs/reference.md) for
`--verbose`, configuration paths, and optional-tool locations.

Expand Down
74 changes: 57 additions & 17 deletions cmd/elastic-docs-utils/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,6 @@ func commandInstall(r *ui.Renderer, args []string) error {
if err := fs.Parse(args); err != nil {
return err
}
_ = yes
r.Header(Version)
if *dryRun {
r.DryRun()
Expand All @@ -154,9 +153,12 @@ func commandInstall(r *ui.Renderer, args []string) error {
rows = append(rows, []string{string(id), "selected"})
}
r.Table([]string{"HOST", "STATUS"}, rows)
if err := installOptionalTools(r, *withVale, *withDocsBuilder, *dryRun, *force); err != nil {
return err
}
// Vale and docs-builder are optional, and their installers touch system
// locations that can fail for reasons unrelated to this setup. Report the
// failure but still configure skills and host adapters, which are the
// point of the command.
toolFailures := installOptionalTools(r, *withVale, *withDocsBuilder, *dryRun, *force, *yes)
reportToolFailures(r, toolFailures)

if err := synchronize(ids, *internal, *dryRun, *force, r); err != nil {
return err
Expand All @@ -178,11 +180,17 @@ func commandInstall(r *ui.Renderer, args []string) error {
if *force {
r.Warn("Replacing conflicting managed MCP entries with the Elastic Docs Utils configuration.")
}
if len(toolFailures) > 0 {
return toolFailureError(toolFailures)
}
r.Success("Elastic Docs Utils is configured.")
return nil
}

func installOptionalTools(r *ui.Renderer, vale, docsBuilder, dryRun, force bool) error {
// installOptionalTools runs the selected upstream installers and returns every
// failure instead of stopping at the first one, so one broken installer cannot
// hide the state of the other.
func installOptionalTools(r *ui.Renderer, vale, docsBuilder, dryRun, force, assumeYes bool) []error {
if !vale && !docsBuilder {
return nil
}
Expand All @@ -196,21 +204,35 @@ func installOptionalTools(r *ui.Renderer, vale, docsBuilder, dryRun, force bool)
}
return nil
}
var failures []error
if vale {
r.Info("Installing Vale and Elastic Vale rules%s.", forced(force))
r.Verbose("Runs the upstream Elastic Vale Rules installer; it reports the Vale binary, configuration, and rule paths it edits.")
if err := bootstrap.InstallVale(force); err != nil {
return fmt.Errorf("install Vale and Elastic Vale rules: %w", err)
if err := bootstrap.InstallVale(force, assumeYes); err != nil {
failures = append(failures, fmt.Errorf("install Vale and Elastic Vale rules: %w", err))
}
}
if docsBuilder {
r.Info("Installing docs-builder%s.", forced(force))
r.Verbose("Runs the upstream docs-builder installer; it reports the binary path it edits.")
if err := bootstrap.InstallDocsBuilder(force); err != nil {
return fmt.Errorf("install docs-builder: %w", err)
if err := bootstrap.InstallDocsBuilder(force, assumeYes); err != nil {
failures = append(failures, fmt.Errorf("install docs-builder: %w", err))
}
}
return nil
return failures
}

func reportToolFailures(r *ui.Renderer, failures []error) {
for _, failure := range failures {
r.Warn("%v", failure)
}
if len(failures) > 0 {
r.Warn("Continuing with the rest of the setup. Re-run the installer for the tools above once the cause is resolved.")
}
}

func toolFailureError(failures []error) error {
return fmt.Errorf("%d optional documentation tool installer(s) failed: %w", len(failures), errors.Join(failures...))
}

func forced(force bool) string {
Expand Down Expand Up @@ -291,6 +313,7 @@ func commandStatus(r *ui.Renderer, args []string) error {
updateRows = append(updateRows, []string{item.Name, item.State})
}
r.Table([]string{"COMPONENT", "STATUS"}, updateRows)
renderUpdateHints(r, cache.Items)
}
return nil
}
Expand Down Expand Up @@ -346,25 +369,31 @@ func commandUpdate(r *ui.Renderer, args []string) error {
} else {
r.Info("Skipping Elastic Docs skills.")
}
// A failed component must not stop the remaining ones, and the refreshed
// status below is most useful precisely when something went wrong.
var toolFailures []error
if selected.vale {
if err := installOptionalTools(r, true, false, *dryRun, *force); err != nil {
return err
}
toolFailures = append(toolFailures, installOptionalTools(r, true, false, *dryRun, *force, false)...)
} else {
r.Info("Skipping Vale and Elastic Vale rules.")
}
if selected.docsBuilder {
if err := installOptionalTools(r, false, true, *dryRun, *force); err != nil {
return err
}
toolFailures = append(toolFailures, installOptionalTools(r, false, true, *dryRun, *force, false)...)
} else {
r.Info("Skipping docs-builder.")
}
reportToolFailures(r, toolFailures)
if *dryRun {
r.Info("Would refresh documentation tool status after updates.")
return nil
}
return refreshUpdates(r)
if err := refreshUpdates(r); err != nil {
return err
}
if len(toolFailures) > 0 {
return toolFailureError(toolFailures)
}
return nil
}

type updateComponents struct {
Expand Down Expand Up @@ -411,6 +440,17 @@ func renderUpdateStatus(r *ui.Renderer, status updates.Status) {
rows = append(rows, []string{item.Name, item.Installed, item.Latest, item.State})
}
r.Table([]string{"COMPONENT", "INSTALLED", "LATEST", "STATUS"}, rows)
renderUpdateHints(r, status.Items)
}

// renderUpdateHints prints the next step for each row that is not current. A
// status of "missing" or "unknown" is not actionable on its own.
func renderUpdateHints(r *ui.Renderer, items []updates.Item) {
for _, item := range items {
if item.Hint != "" && item.State != "current" && item.State != "local" {
r.Info("%s: %s", item.Name, item.Hint)
}
}
}

func commandDoctor(r *ui.Renderer, args []string) error {
Expand Down
73 changes: 72 additions & 1 deletion cmd/elastic-docs-utils/main_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
package main

import "testing"
import (
"bytes"
"errors"
"strings"
"testing"

"github.com/elastic/docs-utils/internal/ui"
"github.com/elastic/docs-utils/internal/updates"
)

func TestParseUpdateComponents(t *testing.T) {
tests := []struct {
Expand Down Expand Up @@ -42,3 +50,66 @@ func TestParseUpdateComponentsRejectsUnknownComponent(t *testing.T) {
t.Fatal("unknown component was accepted")
}
}

func TestInstallOptionalToolsSkippedWhenNoneSelected(t *testing.T) {
var out bytes.Buffer
r := ui.New(ui.ColorNever, &out, &out)
if failures := installOptionalTools(r, false, false, false, false, false); failures != nil {
t.Fatalf("failures = %v, want none", failures)
}
if out.Len() != 0 {
t.Fatalf("unselected tools produced output: %q", out.String())
}
}

func TestInstallOptionalToolsDryRunRunsNoInstaller(t *testing.T) {
var out bytes.Buffer
r := ui.New(ui.ColorNever, &out, &out)
if failures := installOptionalTools(r, true, true, true, false, false); failures != nil {
t.Fatalf("failures = %v, want none", failures)
}
for _, want := range []string{"Would run the supported Elastic Vale Rules installer", "Would run the supported docs-builder installer"} {
if !strings.Contains(out.String(), want) {
t.Fatalf("dry run output missing %q:\n%s", want, out.String())
}
}
}

func TestToolFailureErrorKeepsEveryCause(t *testing.T) {
vale := errors.New("install Vale and Elastic Vale rules: boom")
builder := errors.New("install docs-builder: boom")
err := toolFailureError([]error{vale, builder})
if !errors.Is(err, vale) || !errors.Is(err, builder) {
t.Fatalf("aggregated error lost a cause: %v", err)
}
if !strings.Contains(err.Error(), "2 optional") {
t.Fatalf("error = %q, want the failure count", err.Error())
}
}

func TestReportToolFailuresWarnsAndContinues(t *testing.T) {
var out bytes.Buffer
r := ui.New(ui.ColorNever, &out, &out)
reportToolFailures(r, []error{errors.New("install docs-builder: boom")})
if !strings.Contains(out.String(), "install docs-builder: boom") {
t.Fatalf("failure was not reported:\n%s", out.String())
}
if !strings.Contains(out.String(), "Continuing with the rest of the setup") {
t.Fatalf("output does not say setup continues:\n%s", out.String())
}
}

func TestRenderUpdateStatusShowsHintsForActionableRows(t *testing.T) {
var out bytes.Buffer
r := ui.New(ui.ColorNever, &out, &out)
renderUpdateStatus(r, updates.Status{Items: []updates.Item{
{Name: "docs-builder", Installed: "not installed", State: "missing", Hint: "Run `elastic-docs-utils install --with-docs-builder`"},
{Name: "Vale", Installed: "3.17.0", Latest: "3.17.0", State: "current", Hint: "should not appear"},
}})
if !strings.Contains(out.String(), "Run `elastic-docs-utils install --with-docs-builder`") {
t.Fatalf("missing component has no next step:\n%s", out.String())
}
if strings.Contains(out.String(), "should not appear") {
t.Fatalf("current component printed a hint:\n%s", out.String())
}
}
22 changes: 22 additions & 0 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,16 @@ overwrites docs-builder when it already exists. The upstream Vale installer
keeps an existing Vale executable package-managed; it refreshes the Elastic
rules rather than forcibly replacing the executable.

Add `--yes` when no one is available to answer a prompt, such as in CI or when
a command runs without a terminal. Unlike `--force`, it does not accept
replacement prompts: it closes the installer's input so the installer takes its
own default and leaves existing configuration in place.

These tools are optional, so a failing installer does not stop the rest of the
command. `install` still synchronizes skills and host adapters, `update` still
processes the remaining components and refreshes status, and both report every
failure and then exit non-zero.

`install --with-vale` runs the maintained Elastic Vale Rules platform
installer. It may install the Vale binary and manages the following locations:

Expand Down Expand Up @@ -85,3 +95,15 @@ Supported components are `skills`, `vale`, `vale-rules`, and `docs-builder`.
The `vale` and `vale-rules` selections use the same upstream installer because
it manages both components together. Add `--force` to accept replacement
prompts from upstream installers.

### Update status

`status` and `check-updates` print a next step beneath any component that is
not current. A component reported as `not installed` is usually one that was
never requested, so its next step is the matching `install --with-...` option
rather than an update command.

Version lookups use the GitHub API, which allows 60 unauthenticated requests
per hour per address. When that limit is reached, or GitHub is unreachable, the
affected rows report `unknown` and say why. Set `GITHUB_TOKEN` or `GH_TOKEN` to
authenticate the lookups and avoid the shared limit.
42 changes: 27 additions & 15 deletions internal/bootstrap/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,24 +139,26 @@ func copyExecutable(source, target string) error {
// InstallVale delegates to the official Elastic Vale Rules installer, which
// installs the Vale binary when necessary and installs the Elastic rule bundle.
// Force confirms replacement of an existing non-Elastic Vale configuration.
func InstallVale(force bool) error {
// AssumeYes keeps the installer from blocking on a prompt it cannot read.
func InstallVale(force, assumeYes bool) error {
name, shell, err := valeScript()
if err != nil {
return err
}
return downloadAndRun(valeRulesRaw+name, shell, force)
return downloadAndRun(valeRulesRaw+name, shell, force, assumeYes)
}

// InstallDocsBuilder delegates to the official Docs Builder installer. Force
// confirms replacement when the installer finds an existing binary.
func InstallDocsBuilder(force bool) error {
// confirms replacement when the installer finds an existing binary. AssumeYes
// keeps the installer from blocking on a prompt it cannot read.
func InstallDocsBuilder(force, assumeYes bool) error {
if runtime.GOOS == "windows" {
return downloadAndRun(docsBuilderWindows, "powershell", force)
return downloadAndRun(docsBuilderWindows, "powershell", force, assumeYes)
}
if runtime.GOOS != "darwin" && runtime.GOOS != "linux" {
return fmt.Errorf("docs-builder installation is not supported on %s", runtime.GOOS)
}
return downloadAndRun(docsBuilderUnix, "sh", force)
return downloadAndRun(docsBuilderUnix, "sh", force, assumeYes)
}

func valeScript() (string, string, error) {
Expand All @@ -172,7 +174,7 @@ func valeScript() (string, string, error) {
}
}

func downloadAndRun(url, shell string, force bool) error {
func downloadAndRun(url, shell string, force, assumeYes bool) error {
path, err := download(url, extension(shell))
if err != nil {
return err
Expand All @@ -184,20 +186,30 @@ func downloadAndRun(url, shell string, force bool) error {
}
cmd := exec.Command(command, args...)
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
if force {
// The maintained installers ask only before replacing existing local
// configuration or binaries. Supplying yes lets --force be safely
// non-interactive without exposing configuration contents.
cmd.Stdin = strings.NewReader("y\n")
} else {
cmd.Stdin = os.Stdin
}
cmd.Stdin = installerInput(force, assumeYes)
if err := cmd.Run(); err != nil {
return fmt.Errorf("run upstream installer: %w", err)
}
return nil
}

// installerInput selects the stream the upstream installers read prompts from.
// The maintained installers ask only before replacing existing local
// configuration or binaries, so --force answers yes without exposing
// configuration contents. --yes only guarantees the installer never blocks on
// a read it cannot satisfy: closed input makes it take its own default, which
// leaves existing configuration in place.
func installerInput(force, assumeYes bool) io.Reader {
switch {
case force:
return strings.NewReader("y\n")
case assumeYes:
return strings.NewReader("")
default:
return os.Stdin
}
}

func download(url, suffix string) (string, error) {
client := http.Client{Timeout: 2 * time.Minute}
resp, err := client.Get(url)
Expand Down
Loading