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) + } +}