From 7df4b4ec0473f615a14560e8cc46a04832087cf5 Mon Sep 17 00:00:00 2001 From: Frank Denis Date: Tue, 27 Jan 2026 10:39:26 +0100 Subject: [PATCH 1/4] [CDTOOL-691] Better error reporting when JavaScript tools are missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validate JavaScript toolchains before attempting a build, catching common setup issues early and providing accurate error messages instead of build failures. As Bun becomes increasingly popular, it’s not ideal to ask users to install Node when they already have Bun. So, this PR also adds proper support for Bun as an alternative runtime. The verification detects whether a project uses Node.js or Bun by checking for lockfiles, with support for Bun workspaces where the lockfile lives at the workspace root rather than in the subpackage. When something is missing, the error message now explains exactly what's wrong and how to fix it, whether that's installing a runtime, running npm install, or adding the @fastly/js-compute package. --- CHANGELOG.md | 1 + pkg/commands/compute/build_test.go | 3 +- pkg/commands/compute/language_javascript.go | 383 ++++++++++- .../compute/language_javascript_test.go | 592 ++++++++++++++++++ 4 files changed, 971 insertions(+), 8 deletions(-) create mode 100644 pkg/commands/compute/language_javascript_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index bbed479ad..a02cb02e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -112,6 +112,7 @@ both the old and new forms are: - feat(service/auth): moved the `service-auth` commands under the `service` command and renamed to `auth`, with an unlisted and deprecated alias of `service-auth` ([#1643](https://github.com/fastly/cli/pull/1643)) - feat(compute/build): Block version 1.93.0 of Rust to avoid a wasm32-wasip2 bug. ([#1653](https://github.com/fastly/cli/pull/1653)) - feat(service/vcl): escape control characters when displaying VCL content for cleaner terminal output ([#1637](https://github.com/fastly/cli/pull/1637)) +- feat(compute/build): improved error messaging for JavaScript builds with pre-flight toolchain verification including Bun runtime support ### Bug fixes: diff --git a/pkg/commands/compute/build_test.go b/pkg/commands/compute/build_test.go index d9265455d..57a0f2dd1 100644 --- a/pkg/commands/compute/build_test.go +++ b/pkg/commands/compute/build_test.go @@ -519,6 +519,7 @@ func TestBuildJavaScript(t *testing.T) { // default build script inserted. // // NOTE: This test passes --verbose so we can validate specific outputs. + // NOTE: npmInstall is required because toolchain verification checks for node_modules. { name: "build script inserted dynamically when missing", args: args("compute build --verbose"), @@ -529,8 +530,8 @@ func TestBuildJavaScript(t *testing.T) { wantOutput: []string{ "No [scripts.build] found in fastly.toml.", // requires --verbose "The following default build command for", - "npm exec webpack", // our testdata package.json references webpack }, + npmInstall: true, }, { name: "build error", diff --git a/pkg/commands/compute/language_javascript.go b/pkg/commands/compute/language_javascript.go index c33f972b1..0d95855e8 100644 --- a/pkg/commands/compute/language_javascript.go +++ b/pkg/commands/compute/language_javascript.go @@ -6,7 +6,9 @@ import ( "fmt" "io" "os" + "os/exec" "path/filepath" + "strings" fsterr "github.com/fastly/cli/pkg/errors" "github.com/fastly/cli/pkg/text" @@ -36,6 +38,19 @@ var JsDefaultBuildCommandForWebpack = fmt.Sprintf("npm exec webpack && npm exec // JsSourceDirectory represents the source code directory. const JsSourceDirectory = "src" +// ErrNpmMissing is returned when Node.js is found but npm is not installed. +var ErrNpmMissing = errors.New("node found but npm missing") + +// JSRuntime represents a detected JavaScript runtime. +type JSRuntime struct { + // Name is the runtime name (node or bun). + Name string + // Version is the runtime version string. + Version string + // PkgMgr is the package manager to use (npm or bun). + PkgMgr string +} + // NewJavaScript constructs a new JavaScript toolchain. func NewJavaScript( c *BuildCommand, @@ -83,13 +98,19 @@ type JavaScript struct { manifestFilename string // metadataFilterEnvVars is a comma-separated list of user defined env vars. metadataFilterEnvVars string + // nodeModulesDir is the resolved path to node_modules (may be in parent dir for monorepos). + nodeModulesDir string // nonInteractive is the --non-interactive flag. nonInteractive bool // output is the users terminal stdout stream output io.Writer + // pkgDir is the resolved directory containing package.json. + pkgDir string // postBuild is a custom script executed after the build but before the Wasm // binary is added to the .tar.gz archive. postBuild string + // runtime is the detected JavaScript runtime (node or bun). + runtime *JSRuntime // spinner is a terminal progress status indicator. spinner text.Spinner // timeout is the build execution threshold. @@ -140,16 +161,16 @@ func (j *JavaScript) Dependencies() map[string]string { // Build compiles the user's source code into a Wasm binary. func (j *JavaScript) Build() error { if j.build == "" { - j.build = JsDefaultBuildCommand - j.defaultBuild = true - - usesWebpack, err := j.checkForWebpack() - if err != nil { + // Only verify toolchain when using default build (no custom [scripts.build]) + if err := j.verifyToolchain(); err != nil { return err } - if usesWebpack { - j.build = JsDefaultBuildCommandForWebpack + cmd, err := j.getDefaultBuildCommand() + if err != nil { + return err } + j.build = cmd + j.defaultBuild = true } if j.defaultBuild && j.verbose { @@ -254,3 +275,351 @@ type NPMPackage struct { DevDependencies map[string]string `json:"devDependencies"` Dependencies map[string]string `json:"dependencies"` } + +// checkBun checks if Bun is installed and returns runtime info. +func (j *JavaScript) checkBun() (*JSRuntime, error) { + if _, err := exec.LookPath("bun"); err != nil { + return nil, err + } + cmd := exec.Command("bun", "--version") + output, err := cmd.CombinedOutput() + if err != nil { + return nil, err + } + return &JSRuntime{ + Name: "bun", + Version: strings.TrimSpace(string(output)), + PkgMgr: "bun", + }, nil +} + +// checkNode checks if Node.js and npm are installed and returns runtime info. +func (j *JavaScript) checkNode() (*JSRuntime, error) { + if _, err := exec.LookPath("node"); err != nil { + return nil, err + } + if _, err := exec.LookPath("npm"); err != nil { + return nil, ErrNpmMissing + } + nodeCmd := exec.Command("node", "--version") + nodeOutput, err := nodeCmd.CombinedOutput() + if err != nil { + return nil, err + } + return &JSRuntime{ + Name: "node", + Version: strings.TrimSpace(string(nodeOutput)), + PkgMgr: "npm", + }, nil +} + +// detectProjectRuntime checks lockfiles to determine which runtime the project uses. +// Searches from package.json location upward to handle workspace setups where +// bun.lockb is at the workspace root but package.json is in a subpackage. +// Only accepts bun.lockb if it's alongside a package.json (same dir) to avoid +// picking up unrelated lockfiles in parent directories. +// Returns "bun" if bun.lockb exists, "node" otherwise (default). +func (j *JavaScript) detectProjectRuntime() string { + wd, err := os.Getwd() + if err != nil { + return "node" + } + home, err := os.UserHomeDir() + if err != nil { + return "node" + } + + // Find package.json first to locate the project/subpackage root + found, pkgPath, err := search("package.json", wd, home) + if err != nil || !found { + return "node" + } + + // Search upward from package.json for bun.lockb (handles workspaces) + // Only accept bun.lockb if the same directory also has package.json + // (ensures we're in a proper Bun project/workspace, not picking up unrelated lockfiles) + dir := filepath.Dir(pkgPath) + for { + hasBunLock := false + for _, lockfile := range []string{"bun.lockb", "bun.lock"} { + if _, err := os.Stat(filepath.Join(dir, lockfile)); err == nil { + hasBunLock = true + break + } + } + // Only count bun.lockb if this directory also has package.json + if hasBunLock { + if _, err := os.Stat(filepath.Join(dir, "package.json")); err == nil { + return "bun" + } + } + parent := filepath.Dir(dir) + if parent == dir || dir == home { + break + } + dir = parent + } + + // Default to Node.js (npm) for package-lock.json, yarn.lock, pnpm-lock.yaml, or no lockfile + return "node" +} + +// detectRuntime checks for available JavaScript runtimes. +// Respects the project's lockfile to determine preferred runtime. +func (j *JavaScript) detectRuntime() (*JSRuntime, error) { + projectRuntime := j.detectProjectRuntime() + + // Track errors for better messaging + var nodeErr error + var nodeRuntime, bunRuntime *JSRuntime + + // Check both runtimes to provide accurate error messages + bunRuntime, _ = j.checkBun() + nodeRuntime, nodeErr = j.checkNode() + + // Use project's preferred runtime if available + if projectRuntime == "bun" && bunRuntime != nil { + if j.verbose { + text.Info(j.output, "Found Bun %s (bun.lockb detected)\n", bunRuntime.Version) + } + return bunRuntime, nil + } + if projectRuntime == "node" && nodeRuntime != nil { + if j.verbose { + text.Info(j.output, "Found Node.js %s with npm\n", nodeRuntime.Version) + } + return nodeRuntime, nil + } + + // Fall back to any available runtime + if nodeRuntime != nil { + if j.verbose { + text.Info(j.output, "Found Node.js %s with npm\n", nodeRuntime.Version) + } + return nodeRuntime, nil + } + if bunRuntime != nil { + if j.verbose { + text.Info(j.output, "Found Bun %s\n", bunRuntime.Version) + } + return bunRuntime, nil + } + + // Provide specific error if Node exists but npm is missing + if errors.Is(nodeErr, ErrNpmMissing) { + return nil, fsterr.RemediationError{ + Inner: nodeErr, + Remediation: `Node.js is installed but npm is missing. + +Install npm (usually bundled with Node.js): + - Reinstall Node.js from https://nodejs.org/ + - Or install npm separately: https://docs.npmjs.com/downloading-and-installing-node-js-and-npm + +Verify: npm --version + +Then retry: fastly compute build`, + } + } + + return nil, fsterr.RemediationError{ + Inner: fmt.Errorf("no JavaScript runtime found (node or bun)"), + Remediation: `A JavaScript runtime is required to build Compute applications. + +Install one of the following: + +Option 1 - Node.js: + Install from https://nodejs.org/ (LTS version recommended) + Or use nvm: https://github.com/nvm-sh/nvm + Verify: node --version && npm --version + +Option 2 - Bun: + curl -fsSL https://bun.sh/install | bash + Verify: bun --version + +Then retry: fastly compute build`, + } +} + +// findNodeModules searches for node_modules starting from startDir and moving up. +// Supports monorepo/hoisted setups where node_modules is in a parent directory. +func (j *JavaScript) findNodeModules(startDir, home string) (found bool, path string) { + dir := startDir + for { + candidate := filepath.Join(dir, "node_modules") + if info, err := os.Stat(candidate); err == nil && info.IsDir() { + return true, candidate + } + parent := filepath.Dir(dir) + if parent == dir || dir == home { + return false, "" + } + dir = parent + } +} + +// verifyDependencies checks that package.json and node_modules exist. +func (j *JavaScript) verifyDependencies() error { + wd, err := os.Getwd() + if err != nil { + return err + } + home, err := os.UserHomeDir() + if err != nil { + return err + } + + found, pkgPath, err := search("package.json", wd, home) + if err != nil { + return err + } + if !found { + initCmd := "npm init" + installCmd := "npm install @fastly/js-compute" + if j.runtime != nil && j.runtime.PkgMgr == "bun" { + initCmd = "bun init" + installCmd = "bun add @fastly/js-compute" + } + return fsterr.RemediationError{ + Inner: fmt.Errorf("package.json not found"), + Remediation: fmt.Sprintf(`A package.json file is required for JavaScript Compute projects. + +Ensure you're in the correct project directory, or use --dir to specify the project root. + +To initialize a new project: + %s + %s + +Then retry: fastly compute build`, initCmd, installCmd), + } + } + + j.pkgDir = filepath.Dir(pkgPath) + nodeModulesFound, nodeModulesPath := j.findNodeModules(j.pkgDir, home) + if !nodeModulesFound { + installCmd := "npm install" + if j.runtime != nil && j.runtime.PkgMgr == "bun" { + installCmd = "bun install" + } + return fsterr.RemediationError{ + Inner: fmt.Errorf("node_modules directory not found - dependencies not installed"), + Remediation: fmt.Sprintf(`Dependencies have not been installed. + +Run: %s + +This will install all dependencies from package.json. +Then retry: fastly compute build`, installCmd), + } + } + j.nodeModulesDir = nodeModulesPath + + if j.verbose { + text.Info(j.output, "Found package.json at %s\n", pkgPath) + text.Info(j.output, "Found node_modules at %s\n", nodeModulesPath) + } + return nil +} + +// verifyWebpackInstalled checks that webpack is installed if used. +func (j *JavaScript) verifyWebpackInstalled() error { + hasWebpack, err := j.checkForWebpack() + if err != nil { + return fmt.Errorf("failed to check for webpack in package.json: %w", err) + } + if !hasWebpack { + return nil + } + + binDir := filepath.Join(j.nodeModulesDir, ".bin") + for _, name := range []string{"webpack", "webpack.cmd"} { + if _, err := os.Stat(filepath.Join(binDir, name)); err == nil { + if j.verbose { + text.Info(j.output, "Found webpack in node_modules\n") + } + return nil + } + } + + installCmd := "npm install" + installSpecific := "npm install webpack webpack-cli --save-dev" + verifyCmd := "npx webpack --version" + bunTip := "" + if j.runtime != nil && j.runtime.PkgMgr == "bun" { + installCmd = "bun install" + installSpecific = "bun add -d webpack webpack-cli" + verifyCmd = "bunx webpack --version" + bunTip = "\n\nTip: Bun has a built-in bundler. You may not need webpack at all." + } + return fsterr.RemediationError{ + Inner: fmt.Errorf("webpack is listed in package.json but not installed"), + Remediation: fmt.Sprintf(`Your project uses webpack but it's not installed. + +Run: %s +Or specifically: %s +Verify with: %s + +Then retry: fastly compute build%s`, installCmd, installSpecific, verifyCmd, bunTip), + } +} + +// verifyJsComputeRuntime checks that @fastly/js-compute is installed. +func (j *JavaScript) verifyJsComputeRuntime() error { + runtimePath := filepath.Join(j.nodeModulesDir, "@fastly", "js-compute") + if _, err := os.Stat(runtimePath); os.IsNotExist(err) { + installCmd := "npm install @fastly/js-compute" + if j.runtime != nil && j.runtime.PkgMgr == "bun" { + installCmd = "bun add @fastly/js-compute" + } + return fsterr.RemediationError{ + Inner: fmt.Errorf("@fastly/js-compute package not found"), + Remediation: fmt.Sprintf(`The Fastly JavaScript Compute runtime is not installed. + +Run: %s + +This package is required to compile JavaScript for Fastly Compute. +Then retry: fastly compute build`, installCmd), + } + } + if j.verbose { + text.Info(j.output, "Found @fastly/js-compute runtime\n") + } + return nil +} + +// verifyToolchain checks that a JavaScript runtime is installed and accessible. +// Only called when using default build script (not custom [scripts.build]). +func (j *JavaScript) verifyToolchain() error { + runtime, err := j.detectRuntime() + if err != nil { + return err + } + j.runtime = runtime + + if err := j.verifyDependencies(); err != nil { + return err + } + if err := j.verifyWebpackInstalled(); err != nil { + return err + } + if err := j.verifyJsComputeRuntime(); err != nil { + return err + } + return nil +} + +// getDefaultBuildCommand returns the appropriate build command for the detected runtime. +func (j *JavaScript) getDefaultBuildCommand() (string, error) { + hasWebpack, err := j.checkForWebpack() + if err != nil { + return "", err + } + if j.runtime != nil && j.runtime.PkgMgr == "bun" { + if hasWebpack { + return fmt.Sprintf("bunx webpack && bunx js-compute-runtime ./bin/index.js %s", binWasmPath), nil + } + return fmt.Sprintf("bunx js-compute-runtime ./src/index.js %s", binWasmPath), nil + } + if hasWebpack { + return JsDefaultBuildCommandForWebpack, nil + } + return JsDefaultBuildCommand, nil +} diff --git a/pkg/commands/compute/language_javascript_test.go b/pkg/commands/compute/language_javascript_test.go new file mode 100644 index 000000000..3f56b98bc --- /dev/null +++ b/pkg/commands/compute/language_javascript_test.go @@ -0,0 +1,592 @@ +package compute + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "runtime" + "testing" + + fsterr "github.com/fastly/cli/pkg/errors" +) + +// createFakeRuntime creates a fake executable that outputs the given string. +func createFakeRuntime(t *testing.T, dir, name, output string) { + t.Helper() + var script string + if runtime.GOOS == "windows" { + script = "@echo off\r\necho " + output + name += ".bat" + } else { + script = "#!/bin/sh\necho '" + output + "'" + } + path := filepath.Join(dir, name) + // G306 (CWE-276): Expect WriteFile permissions to be 0600 or less + // Disabling as executables must be executable. + // #nosec G306 + err := os.WriteFile(path, []byte(script), 0o755) + if err != nil { + t.Fatal(err) + } +} + +func TestJavaScript_detectRuntime_NoRuntime(t *testing.T) { + // Create a temp directory with no executables + tmpDir := t.TempDir() + t.Setenv("PATH", tmpDir) + + j := &JavaScript{ + output: &bytes.Buffer{}, + verbose: false, + } + + _, err := j.detectRuntime() + if err == nil { + t.Fatal("expected error when no runtime is found") + } + + // Check it's a RemediationError with helpful message + var re fsterr.RemediationError + if !errors.As(err, &re) { + t.Fatalf("expected RemediationError, got %T", err) + } + + if re.Remediation == "" { + t.Error("expected remediation message") + } +} + +func TestJavaScript_detectRuntime_NodeFound(t *testing.T) { + tmpDir := t.TempDir() + createFakeRuntime(t, tmpDir, "node", "v24.13.0") + createFakeRuntime(t, tmpDir, "npm", "11.7.0") + t.Setenv("PATH", tmpDir) + + j := &JavaScript{ + output: &bytes.Buffer{}, + verbose: false, + } + + rt, err := j.detectRuntime() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if rt.Name != "node" { + t.Errorf("expected runtime name 'node', got %q", rt.Name) + } + if rt.PkgMgr != "npm" { + t.Errorf("expected package manager 'npm', got %q", rt.PkgMgr) + } +} + +func TestJavaScript_detectRuntime_BunFound(t *testing.T) { + tmpDir := t.TempDir() + createFakeRuntime(t, tmpDir, "bun", "1.3.7") + t.Setenv("PATH", tmpDir) + + j := &JavaScript{ + output: &bytes.Buffer{}, + verbose: false, + } + + rt, err := j.detectRuntime() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if rt.Name != "bun" { + t.Errorf("expected runtime name 'bun', got %q", rt.Name) + } + if rt.PkgMgr != "bun" { + t.Errorf("expected package manager 'bun', got %q", rt.PkgMgr) + } +} + +func TestJavaScript_detectRuntime_NodePreferredByDefault(t *testing.T) { + tmpDir := t.TempDir() + createFakeRuntime(t, tmpDir, "bun", "1.3.7") + createFakeRuntime(t, tmpDir, "node", "v24.13.0") + createFakeRuntime(t, tmpDir, "npm", "11.7.0") + t.Setenv("PATH", tmpDir) + + // Create project dir without bun.lockb (npm project) + projectDir := t.TempDir() + originalWd, _ := os.Getwd() + defer func() { _ = os.Chdir(originalWd) }() + if err := os.Chdir(projectDir); err != nil { + t.Fatal(err) + } + + j := &JavaScript{ + output: &bytes.Buffer{}, + verbose: false, + } + + rt, err := j.detectRuntime() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Node should be preferred by default (no bun.lockb) + if rt.Name != "node" { + t.Errorf("expected runtime name 'node' (default), got %q", rt.Name) + } +} + +func TestJavaScript_detectRuntime_BunPreferredWithLockfile(t *testing.T) { + tmpDir := t.TempDir() + createFakeRuntime(t, tmpDir, "bun", "1.3.7") + createFakeRuntime(t, tmpDir, "node", "v24.13.0") + createFakeRuntime(t, tmpDir, "npm", "11.7.0") + t.Setenv("PATH", tmpDir) + + // Create project dir with package.json and bun.lockb (bun project) + projectDir := t.TempDir() + // #nosec G306 + if err := os.WriteFile(filepath.Join(projectDir, "package.json"), []byte(`{}`), 0o644); err != nil { + t.Fatal(err) + } + // #nosec G306 + if err := os.WriteFile(filepath.Join(projectDir, "bun.lockb"), []byte{}, 0o644); err != nil { + t.Fatal(err) + } + originalWd, _ := os.Getwd() + defer func() { _ = os.Chdir(originalWd) }() + if err := os.Chdir(projectDir); err != nil { + t.Fatal(err) + } + + j := &JavaScript{ + output: &bytes.Buffer{}, + verbose: false, + } + + rt, err := j.detectRuntime() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Bun should be used when bun.lockb exists alongside package.json + if rt.Name != "bun" { + t.Errorf("expected runtime name 'bun' (bun.lockb detected), got %q", rt.Name) + } +} + +func TestJavaScript_detectRuntime_BunLockfileInParentDir(t *testing.T) { + tmpDir := t.TempDir() + createFakeRuntime(t, tmpDir, "bun", "1.3.7") + createFakeRuntime(t, tmpDir, "node", "v24.13.0") + createFakeRuntime(t, tmpDir, "npm", "11.7.0") + t.Setenv("PATH", tmpDir) + + // Create project structure: projectDir/subdir with package.json and bun.lockb in projectDir + projectDir := t.TempDir() + subDir := filepath.Join(projectDir, "subdir") + if err := os.MkdirAll(subDir, 0o755); err != nil { + t.Fatal(err) + } + // #nosec G306 + if err := os.WriteFile(filepath.Join(projectDir, "package.json"), []byte(`{}`), 0o644); err != nil { + t.Fatal(err) + } + // #nosec G306 + if err := os.WriteFile(filepath.Join(projectDir, "bun.lockb"), []byte{}, 0o644); err != nil { + t.Fatal(err) + } + + // Run from subdir - should detect bun.lockb alongside package.json in parent + originalWd, _ := os.Getwd() + defer func() { _ = os.Chdir(originalWd) }() + if err := os.Chdir(subDir); err != nil { + t.Fatal(err) + } + + j := &JavaScript{ + output: &bytes.Buffer{}, + verbose: false, + } + + rt, err := j.detectRuntime() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Bun should be detected from project root (where package.json is) + if rt.Name != "bun" { + t.Errorf("expected runtime name 'bun' (bun.lockb with package.json), got %q", rt.Name) + } +} + +func TestJavaScript_detectRuntime_BunWorkspace(t *testing.T) { + tmpDir := t.TempDir() + createFakeRuntime(t, tmpDir, "bun", "1.3.7") + createFakeRuntime(t, tmpDir, "node", "v24.13.0") + createFakeRuntime(t, tmpDir, "npm", "11.7.0") + t.Setenv("PATH", tmpDir) + + // Create Bun workspace structure: + // workspace/package.json (workspace root) + // workspace/bun.lockb + // workspace/packages/myapp/package.json (subpackage - we run from here) + workspaceDir := t.TempDir() + subpkgDir := filepath.Join(workspaceDir, "packages", "myapp") + if err := os.MkdirAll(subpkgDir, 0o755); err != nil { + t.Fatal(err) + } + // Workspace root package.json + // #nosec G306 + if err := os.WriteFile(filepath.Join(workspaceDir, "package.json"), []byte(`{"workspaces":["packages/*"]}`), 0o644); err != nil { + t.Fatal(err) + } + // #nosec G306 + if err := os.WriteFile(filepath.Join(workspaceDir, "bun.lockb"), []byte{}, 0o644); err != nil { + t.Fatal(err) + } + // Subpackage package.json + // #nosec G306 + if err := os.WriteFile(filepath.Join(subpkgDir, "package.json"), []byte(`{"name":"myapp"}`), 0o644); err != nil { + t.Fatal(err) + } + + // Run from subpackage + originalWd, _ := os.Getwd() + defer func() { _ = os.Chdir(originalWd) }() + if err := os.Chdir(subpkgDir); err != nil { + t.Fatal(err) + } + + j := &JavaScript{ + output: &bytes.Buffer{}, + verbose: false, + } + + rt, err := j.detectRuntime() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Bun should be detected from workspace root (bun.lockb + package.json) + if rt.Name != "bun" { + t.Errorf("expected runtime name 'bun' (workspace detected), got %q", rt.Name) + } +} + +func TestJavaScript_detectRuntime_IgnoresUnrelatedBunLockfile(t *testing.T) { + tmpDir := t.TempDir() + createFakeRuntime(t, tmpDir, "bun", "1.3.7") + createFakeRuntime(t, tmpDir, "node", "v24.13.0") + createFakeRuntime(t, tmpDir, "npm", "11.7.0") + t.Setenv("PATH", tmpDir) + + // Create structure: parentDir/bun.lockb (unrelated) and parentDir/project/package.json (npm project) + parentDir := t.TempDir() + projectDir := filepath.Join(parentDir, "project") + if err := os.MkdirAll(projectDir, 0o755); err != nil { + t.Fatal(err) + } + // Unrelated bun.lockb in parent (not alongside package.json) + // #nosec G306 + if err := os.WriteFile(filepath.Join(parentDir, "bun.lockb"), []byte{}, 0o644); err != nil { + t.Fatal(err) + } + // Project's package.json (no bun.lockb here) + // #nosec G306 + if err := os.WriteFile(filepath.Join(projectDir, "package.json"), []byte(`{}`), 0o644); err != nil { + t.Fatal(err) + } + + originalWd, _ := os.Getwd() + defer func() { _ = os.Chdir(originalWd) }() + if err := os.Chdir(projectDir); err != nil { + t.Fatal(err) + } + + j := &JavaScript{ + output: &bytes.Buffer{}, + verbose: false, + } + + rt, err := j.detectRuntime() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Should use Node because project root has no bun.lockb (parent's is unrelated) + if rt.Name != "node" { + t.Errorf("expected runtime name 'node' (unrelated bun.lockb ignored), got %q", rt.Name) + } +} + +func TestJavaScript_detectRuntime_NodeMissingNpm(t *testing.T) { + tmpDir := t.TempDir() + createFakeRuntime(t, tmpDir, "node", "v24.13.0") + // npm is NOT created + t.Setenv("PATH", tmpDir) + + j := &JavaScript{ + output: &bytes.Buffer{}, + verbose: false, + } + + _, err := j.detectRuntime() + if err == nil { + t.Fatal("expected error when npm is missing") + } + + // Check for specific error message + var re fsterr.RemediationError + if !errors.As(err, &re) { + t.Fatalf("expected RemediationError, got %T", err) + } + + if !errors.Is(re.Inner, ErrNpmMissing) { + t.Errorf("expected ErrNpmMissing, got %v", re.Inner) + } +} + +func TestJavaScript_findNodeModules(t *testing.T) { + // Create directory structure: project/subdir with node_modules in project + tmpDir := t.TempDir() + projectDir := filepath.Join(tmpDir, "project") + subDir := filepath.Join(projectDir, "subdir") + nodeModulesDir := filepath.Join(projectDir, "node_modules") + + if err := os.MkdirAll(subDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(nodeModulesDir, 0o755); err != nil { + t.Fatal(err) + } + + j := &JavaScript{} + + // Should find node_modules in parent directory + found, path := j.findNodeModules(subDir, tmpDir) + if !found { + t.Error("expected to find node_modules") + } + if path != nodeModulesDir { + t.Errorf("expected path %q, got %q", nodeModulesDir, path) + } + + // Should find node_modules in current directory + found, path = j.findNodeModules(projectDir, tmpDir) + if !found { + t.Error("expected to find node_modules") + } + if path != nodeModulesDir { + t.Errorf("expected path %q, got %q", nodeModulesDir, path) + } + + // Should not find node_modules above home + found, _ = j.findNodeModules(tmpDir, tmpDir) + if found { + t.Error("expected not to find node_modules above home") + } +} + +func TestJavaScript_verifyDependencies_NoPackageJson(t *testing.T) { + tmpDir := t.TempDir() + binDir := t.TempDir() + createFakeRuntime(t, binDir, "node", "v24.13.0") + createFakeRuntime(t, binDir, "npm", "11.7.0") + t.Setenv("PATH", binDir) + + // Change to temp dir with no package.json + originalWd, _ := os.Getwd() + defer func() { _ = os.Chdir(originalWd) }() + if err := os.Chdir(tmpDir); err != nil { + t.Fatal(err) + } + + j := &JavaScript{ + output: &bytes.Buffer{}, + verbose: false, + runtime: &JSRuntime{Name: "node", PkgMgr: "npm"}, + } + + err := j.verifyDependencies() + if err == nil { + t.Fatal("expected error when package.json not found") + } + + var re fsterr.RemediationError + if !errors.As(err, &re) { + t.Fatalf("expected RemediationError, got %T", err) + } +} + +func TestJavaScript_verifyDependencies_NoNodeModules(t *testing.T) { + tmpDir := t.TempDir() + binDir := t.TempDir() + createFakeRuntime(t, binDir, "node", "v24.13.0") + createFakeRuntime(t, binDir, "npm", "11.7.0") + t.Setenv("PATH", binDir) + + // Create package.json but no node_modules + // #nosec G306 + if err := os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{}`), 0o644); err != nil { + t.Fatal(err) + } + + originalWd, _ := os.Getwd() + defer func() { _ = os.Chdir(originalWd) }() + if err := os.Chdir(tmpDir); err != nil { + t.Fatal(err) + } + + j := &JavaScript{ + output: &bytes.Buffer{}, + verbose: false, + runtime: &JSRuntime{Name: "node", PkgMgr: "npm"}, + } + + err := j.verifyDependencies() + if err == nil { + t.Fatal("expected error when node_modules not found") + } + + var re fsterr.RemediationError + if !errors.As(err, &re) { + t.Fatalf("expected RemediationError, got %T", err) + } +} + +func TestJavaScript_verifyJsComputeRuntime_NotInstalled(t *testing.T) { + tmpDir := t.TempDir() + nodeModulesDir := filepath.Join(tmpDir, "node_modules") + if err := os.MkdirAll(nodeModulesDir, 0o755); err != nil { + t.Fatal(err) + } + + j := &JavaScript{ + output: &bytes.Buffer{}, + verbose: false, + nodeModulesDir: nodeModulesDir, + runtime: &JSRuntime{Name: "node", PkgMgr: "npm"}, + } + + err := j.verifyJsComputeRuntime() + if err == nil { + t.Fatal("expected error when @fastly/js-compute not found") + } + + var re fsterr.RemediationError + if !errors.As(err, &re) { + t.Fatalf("expected RemediationError, got %T", err) + } +} + +func TestJavaScript_verifyJsComputeRuntime_Installed(t *testing.T) { + tmpDir := t.TempDir() + nodeModulesDir := filepath.Join(tmpDir, "node_modules") + runtimeDir := filepath.Join(nodeModulesDir, "@fastly", "js-compute") + if err := os.MkdirAll(runtimeDir, 0o755); err != nil { + t.Fatal(err) + } + + j := &JavaScript{ + output: &bytes.Buffer{}, + verbose: false, + nodeModulesDir: nodeModulesDir, + runtime: &JSRuntime{Name: "node", PkgMgr: "npm"}, + } + + err := j.verifyJsComputeRuntime() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestJavaScript_getDefaultBuildCommand_NodeWithWebpack(t *testing.T) { + tmpDir := t.TempDir() + // #nosec G306 + if err := os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{"devDependencies":{"webpack":"5.0.0"}}`), 0o644); err != nil { + t.Fatal(err) + } + + originalWd, _ := os.Getwd() + defer func() { _ = os.Chdir(originalWd) }() + if err := os.Chdir(tmpDir); err != nil { + t.Fatal(err) + } + + j := &JavaScript{ + output: &bytes.Buffer{}, + verbose: false, + runtime: &JSRuntime{Name: "node", PkgMgr: "npm"}, + } + + cmd, err := j.getDefaultBuildCommand() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if cmd != JsDefaultBuildCommandForWebpack { + t.Errorf("expected webpack command, got %q", cmd) + } +} + +func TestJavaScript_getDefaultBuildCommand_NodeNoWebpack(t *testing.T) { + tmpDir := t.TempDir() + // #nosec G306 + if err := os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{}`), 0o644); err != nil { + t.Fatal(err) + } + + originalWd, _ := os.Getwd() + defer func() { _ = os.Chdir(originalWd) }() + if err := os.Chdir(tmpDir); err != nil { + t.Fatal(err) + } + + j := &JavaScript{ + output: &bytes.Buffer{}, + verbose: false, + runtime: &JSRuntime{Name: "node", PkgMgr: "npm"}, + } + + cmd, err := j.getDefaultBuildCommand() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if cmd != JsDefaultBuildCommand { + t.Errorf("expected default command, got %q", cmd) + } +} + +func TestJavaScript_getDefaultBuildCommand_Bun(t *testing.T) { + tmpDir := t.TempDir() + // #nosec G306 + if err := os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{}`), 0o644); err != nil { + t.Fatal(err) + } + + originalWd, _ := os.Getwd() + defer func() { _ = os.Chdir(originalWd) }() + if err := os.Chdir(tmpDir); err != nil { + t.Fatal(err) + } + + j := &JavaScript{ + output: &bytes.Buffer{}, + verbose: false, + runtime: &JSRuntime{Name: "bun", PkgMgr: "bun"}, + } + + cmd, err := j.getDefaultBuildCommand() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Should use bunx instead of npm exec + if cmd == JsDefaultBuildCommand { + t.Errorf("expected bun command, got npm command %q", cmd) + } + if !bytes.Contains([]byte(cmd), []byte("bunx")) { + t.Errorf("expected command to contain 'bunx', got %q", cmd) + } +} From 59c3c78e54a379a72ae18285cc995f61cce706d7 Mon Sep 17 00:00:00 2001 From: Frank Denis Date: Mon, 9 Mar 2026 15:20:34 +0100 Subject: [PATCH 2/4] Verify JS toolchain when scripts.build is a starter kit default As noted by @kpfleming: Most starter kits set scripts.build to "npm run build", which previously skipped all toolchain verification. Now we also verify when the build script matches known defaults like "npm run build" or "bun run build". --- pkg/commands/compute/language_javascript.go | 20 +++++++++++++++++-- .../compute/language_javascript_test.go | 20 +++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/pkg/commands/compute/language_javascript.go b/pkg/commands/compute/language_javascript.go index 0d95855e8..9cfd8c32c 100644 --- a/pkg/commands/compute/language_javascript.go +++ b/pkg/commands/compute/language_javascript.go @@ -158,10 +158,21 @@ func (j *JavaScript) Dependencies() map[string]string { return deps } +// isDefaultBuildScript reports whether the configured build script is one of +// the well-known defaults used by Fastly starter kits (e.g. "npm run build" +// or "bun run build"). These scripts delegate to the same toolchain that the +// CLI would invoke directly, so the same verification logic applies. +func (j *JavaScript) isDefaultBuildScript() bool { + switch j.build { + case "npm run build", "bun run build": + return true + } + return false +} + // Build compiles the user's source code into a Wasm binary. func (j *JavaScript) Build() error { if j.build == "" { - // Only verify toolchain when using default build (no custom [scripts.build]) if err := j.verifyToolchain(); err != nil { return err } @@ -171,6 +182,10 @@ func (j *JavaScript) Build() error { } j.build = cmd j.defaultBuild = true + } else if j.isDefaultBuildScript() { + if err := j.verifyToolchain(); err != nil { + return err + } } if j.defaultBuild && j.verbose { @@ -586,7 +601,8 @@ Then retry: fastly compute build`, installCmd), } // verifyToolchain checks that a JavaScript runtime is installed and accessible. -// Only called when using default build script (not custom [scripts.build]). +// Called when using the default build script or a well-known starter kit script +// (e.g. "npm run build"). func (j *JavaScript) verifyToolchain() error { runtime, err := j.detectRuntime() if err != nil { diff --git a/pkg/commands/compute/language_javascript_test.go b/pkg/commands/compute/language_javascript_test.go index 3f56b98bc..d49cf61e2 100644 --- a/pkg/commands/compute/language_javascript_test.go +++ b/pkg/commands/compute/language_javascript_test.go @@ -500,6 +500,26 @@ func TestJavaScript_verifyJsComputeRuntime_Installed(t *testing.T) { } } +func TestJavaScript_isDefaultBuildScript(t *testing.T) { + tests := []struct { + build string + want bool + }{ + {"npm run build", true}, + {"bun run build", true}, + {"", false}, + {"custom-build-cmd", false}, + {"npm run build && echo done", false}, + } + + for _, tt := range tests { + j := &JavaScript{build: tt.build} + if got := j.isDefaultBuildScript(); got != tt.want { + t.Errorf("isDefaultBuildScript() with build=%q: got %v, want %v", tt.build, got, tt.want) + } + } +} + func TestJavaScript_getDefaultBuildCommand_NodeWithWebpack(t *testing.T) { tmpDir := t.TempDir() // #nosec G306 From 12ba1afb9048e100a402edb309ce15cad708f343 Mon Sep 17 00:00:00 2001 From: Frank Denis Date: Mon, 9 Mar 2026 15:23:14 +0100 Subject: [PATCH 3/4] Replace hardcoded "fastly compute build" in retry messages Build verification can also be triggered by "publish" or "serve", so telling the user to retry with "fastly compute build" is misleading. Suggested by @kpfleming, thanks! --- pkg/commands/compute/language_javascript.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pkg/commands/compute/language_javascript.go b/pkg/commands/compute/language_javascript.go index 9cfd8c32c..0d7b8f47c 100644 --- a/pkg/commands/compute/language_javascript.go +++ b/pkg/commands/compute/language_javascript.go @@ -432,7 +432,7 @@ Install npm (usually bundled with Node.js): Verify: npm --version -Then retry: fastly compute build`, +Then retry your command.`, } } @@ -451,7 +451,7 @@ Option 2 - Bun: curl -fsSL https://bun.sh/install | bash Verify: bun --version -Then retry: fastly compute build`, +Then retry your command.`, } } @@ -504,7 +504,7 @@ To initialize a new project: %s %s -Then retry: fastly compute build`, initCmd, installCmd), +Then retry your command.`, initCmd, installCmd), } } @@ -522,7 +522,7 @@ Then retry: fastly compute build`, initCmd, installCmd), Run: %s This will install all dependencies from package.json. -Then retry: fastly compute build`, installCmd), +Then retry your command.`, installCmd), } } j.nodeModulesDir = nodeModulesPath @@ -572,7 +572,7 @@ Run: %s Or specifically: %s Verify with: %s -Then retry: fastly compute build%s`, installCmd, installSpecific, verifyCmd, bunTip), +Then retry your command.%s`, installCmd, installSpecific, verifyCmd, bunTip), } } @@ -591,7 +591,7 @@ func (j *JavaScript) verifyJsComputeRuntime() error { Run: %s This package is required to compile JavaScript for Fastly Compute. -Then retry: fastly compute build`, installCmd), +Then retry your command.`, installCmd), } } if j.verbose { From f64c4458c14b324638011b5afb295ff8cc0f3596 Mon Sep 17 00:00:00 2001 From: Frank Denis Date: Mon, 9 Mar 2026 15:26:02 +0100 Subject: [PATCH 4/4] Add PR link --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a02cb02e0..b4e044fb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -112,7 +112,7 @@ both the old and new forms are: - feat(service/auth): moved the `service-auth` commands under the `service` command and renamed to `auth`, with an unlisted and deprecated alias of `service-auth` ([#1643](https://github.com/fastly/cli/pull/1643)) - feat(compute/build): Block version 1.93.0 of Rust to avoid a wasm32-wasip2 bug. ([#1653](https://github.com/fastly/cli/pull/1653)) - feat(service/vcl): escape control characters when displaying VCL content for cleaner terminal output ([#1637](https://github.com/fastly/cli/pull/1637)) -- feat(compute/build): improved error messaging for JavaScript builds with pre-flight toolchain verification including Bun runtime support +- feat(compute/build): improved error messaging for JavaScript builds with pre-flight toolchain verification including Bun runtime support ([#1640](https://github.com/fastly/cli/pull/1640)) ### Bug fixes: