diff --git a/cmd/lk/app.go b/cmd/lk/app.go index 8a691e2e..8f5cdce1 100644 --- a/cmd/lk/app.go +++ b/cmd/lk/app.go @@ -128,6 +128,12 @@ var ( } ) +var missingExecutablePatterns = []*regexp.Regexp{ + regexp.MustCompile(`(?m)["']?([a-zA-Z0-9][a-zA-Z0-9._+-]*)["']?: executable file not found in (?:\$PATH|%PATH%)`), + regexp.MustCompile(`(?m)^(?:env|/usr/bin/env):[ \t]+([a-zA-Z0-9][a-zA-Z0-9._+-]*):[ \t]+No such file or directory[ \t]*\r?$`), + regexp.MustCompile(`(?m)^(?:/bin/)?(?:sh|bash|zsh)(?:: [0-9]+)?:[ \t]+([a-zA-Z0-9][a-zA-Z0-9._+-]*):[ \t]+(?:command )?not found[ \t]*\r?$`), +} + func requireProject(ctx context.Context, cmd *cli.Command) (context.Context, error) { return requireProjectWithOpts(ctx, cmd) } @@ -458,7 +464,7 @@ func setupTemplate(ctx context.Context, cmd *cli.Command) error { b.WriteString(line) b.WriteString("\n") } - out.Warnf("%s%sFix your toolchain, then re-run the install step manually in ./%s.", b.String(), fixPrefix, appName) + out.Warnf("%s%s%s", b.String(), fixPrefix, installFailureGuidance(err, appName)) } else { // Signal a successful install to post_create so the template can skip // printing the now-redundant install hint (guarded via `status:`). @@ -653,6 +659,30 @@ func doInstall(ctx context.Context, task bootstrap.KnownTask, rootPath string, v return nil } +func missingExecutable(message string) string { + for _, pattern := range missingExecutablePatterns { + matches := pattern.FindStringSubmatch(message) + if len(matches) > 1 { + return matches[1] + } + } + return "" +} + +func installFailureGuidance(err error, appName string) string { + if err != nil { + if command := missingExecutable(err.Error()); command != "" { + return fmt.Sprintf( + "`%s` is required but was not found in PATH. Install `%s`, ensure it is available in PATH, then re-run the install step manually in ./%s.", + command, + command, + appName, + ) + } + } + return fmt.Sprintf("Fix your toolchain, then re-run the install step manually in ./%s.", appName) +} + func runTask(ctx context.Context, cmd *cli.Command) error { verbose := cmd.Bool("verbose") rootDir := "." diff --git a/cmd/lk/app_test.go b/cmd/lk/app_test.go new file mode 100644 index 00000000..18e05f8a --- /dev/null +++ b/cmd/lk/app_test.go @@ -0,0 +1,95 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMissingExecutable(t *testing.T) { + tests := []struct { + name string + message string + want string + }{ + { + name: "go exec error", + message: `"uv": executable file not found in $PATH`, + want: "uv", + }, + { + name: "wrapped go exec error", + message: `exec: "pnpm": executable file not found in $PATH`, + want: "pnpm", + }, + { + name: "windows path spelling", + message: `exec: "npm": executable file not found in %PATH%`, + want: "npm", + }, + { + name: "env error", + message: "env: node: No such file or directory", + want: "node", + }, + { + name: "shell error", + message: "sh: yarn: command not found", + want: "yarn", + }, + { + name: "multiline task output", + message: "task: [install] uv sync\n\"uv\": executable file not found in $PATH\ntask: Failed to run task \"install\"", + want: "uv", + }, + { + name: "unrelated failure", + message: "package installation failed with exit status 1", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, missingExecutable(tt.message)) + }) + } +} + +func TestInstallFailureGuidance(t *testing.T) { + t.Run("missing executable", func(t *testing.T) { + got := installFailureGuidance( + errors.New(`"uv": executable file not found in $PATH`), + "my-agent", + ) + + assert.Equal(t, + "`uv` is required but was not found in PATH. Install `uv`, ensure it is available in PATH, then re-run the install step manually in ./my-agent.", + got, + ) + }) + + t.Run("other install error", func(t *testing.T) { + got := installFailureGuidance(errors.New("exit status 1"), "my-agent") + + assert.Equal(t, + "Fix your toolchain, then re-run the install step manually in ./my-agent.", + got, + ) + }) +}