From 5af8b976a7c5ac78764b1f7f7221a885132ac3ad Mon Sep 17 00:00:00 2001 From: Frank Denis Date: Tue, 27 Jan 2026 10:39:26 +0100 Subject: [PATCH] [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 | 4 +- pkg/commands/compute/language_javascript.go | 383 ++++++++++- .../compute/language_javascript_test.go | 592 ++++++++++++++++++ 4 files changed, 972 insertions(+), 8 deletions(-) create mode 100644 pkg/commands/compute/language_javascript_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d30fe3b9..92dee8fa8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ - feat(service/ratelimit): moved the `rate-limit` commands under the `service` command, with an unlisted and deprecated alias of `rate-limit` ([#1632](https://github.com/fastly/cli/pull/1632)) - feat(compute/build): Remove Rust version restriction, allowing 1.93.0 and later versions to be used. ([#1633](https://github.com/fastly/cli/pull/1633)) - feat(service/resourcelink): moved the `resource-link` commands under the `service` command, with an unlisted and deprecated alias of `resource-link` ([#1635](https://github.com/fastly/cli/pull/1635)) +- feat(compute/build): improved error messaging for JavaScript builds with pre-flight toolchain verification including Bun runtime support ### Bug fixes: - fix(compute/serve): ensure hostname has a port nubmer when building pushpin routes ([#1631](https://github.com/fastly/cli/pull/1631)) diff --git a/pkg/commands/compute/build_test.go b/pkg/commands/compute/build_test.go index d9265455d..06bfe23e9 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,9 @@ 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 + // The exact command depends on detected runtime (bun or node) }, + 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) + } +}