diff --git a/CHANGELOG.md b/CHANGELOG.md index ae7ccaaa8..ecfb16c3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -220,6 +220,8 @@ both the old and new forms are: - 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(service/logging): moved the `logging` commands under the `service` command, with an unlisted and deprecated alias of `logging` ([#1642](https://github.com/fastly/cli/pull/1642)) - 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): 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: - fix(docker): Use base image toolchain instead of reinstalling stable, which could pull in an unvalidated Rust version. diff --git a/pkg/commands/compute/build_test.go b/pkg/commands/compute/build_test.go index f61418bbd..c1b356a01 100644 --- a/pkg/commands/compute/build_test.go +++ b/pkg/commands/compute/build_test.go @@ -513,6 +513,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"), @@ -523,8 +524,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", @@ -548,7 +549,7 @@ func TestBuildJavaScript(t *testing.T) { language = "javascript" [scripts] - build = "%s"`, compute.JsDefaultBuildCommandForWebpack), + build = "%s"`, compute.JsDefaultBuildCommand), wantOutput: []string{ "Creating ./bin directory (for Wasm binary)", "Built package", @@ -591,7 +592,6 @@ func TestBuildJavaScript(t *testing.T) { T: t, Copy: []testutil.FileIO{ {Src: filepath.Join("testdata", "build", "javascript", "package.json"), Dst: "package.json"}, - {Src: filepath.Join("testdata", "build", "javascript", "webpack.config.js"), Dst: "webpack.config.js"}, {Src: filepath.Join("testdata", "build", "javascript", "src", "index.js"), Dst: filepath.Join("src", "index.js")}, }, Write: []testutil.FileIO{ diff --git a/pkg/commands/compute/language_javascript.go b/pkg/commands/compute/language_javascript.go index c33f972b1..73edb5bbf 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" @@ -23,19 +25,25 @@ import ( // This makes the experience less confusing as users didn't expect file changes. var JsDefaultBuildCommand = fmt.Sprintf("npm exec js-compute-runtime ./src/index.js %s", binWasmPath) -// JsDefaultBuildCommandForWebpack is a build command compiled into the CLI -// binary so it can be used as a fallback for customer's who have an existing -// Compute project using the 'default' JS Starter Kit, and are simply upgrading -// their CLI version and might not be familiar with the changes in the 4.0.0 -// release with regards to how build logic has moved to the fastly.toml manifest. -// -// NOTE: For this variation of the build script to be added to the user's -// fastly.toml will require a successful check for the webpack dependency. -var JsDefaultBuildCommandForWebpack = fmt.Sprintf("npm exec webpack && npm exec js-compute-runtime ./bin/index.js %s", binWasmPath) +// BunDefaultBuildCommand is the default build command when Bun is the detected runtime. +var BunDefaultBuildCommand = fmt.Sprintf("bunx js-compute-runtime ./src/index.js %s", binWasmPath) // 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,6 +91,9 @@ type JavaScript struct { manifestFilename string // metadataFilterEnvVars is a comma-separated list of user defined env vars. metadataFilterEnvVars string + // nodeModulesDirs is the set of node_modules directories found walking up the tree. + // Supports monorepo/hoisted setups where dependencies may be split across levels. + nodeModulesDirs []string // nonInteractive is the --non-interactive flag. nonInteractive bool // output is the users terminal stdout stream @@ -90,6 +101,8 @@ type JavaScript struct { // 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. @@ -137,18 +150,29 @@ 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 == "" { - j.build = JsDefaultBuildCommand - j.defaultBuild = true - - usesWebpack, err := j.checkForWebpack() - if err != nil { + if err := j.verifyToolchain(); err != nil { return err } - if usesWebpack { - j.build = JsDefaultBuildCommandForWebpack + j.build = j.getDefaultBuildCommand() + j.defaultBuild = true + } else if j.isDefaultBuildScript() { + if err := j.verifyToolchain(); err != nil { + return err } } @@ -176,81 +200,331 @@ func (j *JavaScript) Build() error { return bt.Build() } -func (j JavaScript) checkForWebpack() (bool, error) { +// search recurses up the directory tree looking for the given file. +func search(filename, wd, home string) (found bool, path string, err error) { + parent := filepath.Dir(wd) + + var noManifest bool + path = filepath.Join(wd, filename) + if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { + noManifest = true + } + + // We've found the manifest. + if !noManifest { + return true, path, nil + } + + // NOTE: The first condition catches if we reach the user's 'root' directory. + if wd != parent && wd != home { + return search(filename, parent, home) + } + + return false, "", nil +} + +// NPMPackage represents a package.json manifest and its dependencies. +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 false, err + 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 your command.`, + } + } + + 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 your command.`, } +} +// findAllNodeModules collects every node_modules directory from startDir up to +// (but not including) the user's home directory. The result is ordered nearest +// first, which matches the Node.js module resolution order. +func (j *JavaScript) findAllNodeModules(startDir, home string) []string { + var dirs []string + dir := startDir + for { + candidate := filepath.Join(dir, "node_modules") + if info, err := os.Stat(candidate); err == nil && info.IsDir() { + dirs = append(dirs, candidate) + } + parent := filepath.Dir(dir) + if parent == dir || dir == home { + break + } + dir = parent + } + return dirs +} + +// 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 false, err + return err } - found, path, err := search("package.json", wd, home) + found, pkgPath, err := search("package.json", wd, home) if err != nil { - return false, err - } - - if found { - // gosec flagged this: - // G304 (CWE-22): Potential file inclusion via variable - // - // Disabling as the path is determined by our own logic. - /* #nosec */ - data, err := os.ReadFile(path) - if err != nil { - return false, err + 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. - var pkg NPMPackage +Ensure you're in the correct project directory, or use --dir to specify the project root. - err = json.Unmarshal(data, &pkg) - if err != nil { - return false, err +To initialize a new project: + %s + %s + +Then retry your command.`, initCmd, installCmd), } + } - for k := range pkg.DevDependencies { - if k == "webpack" { - return true, nil - } + pkgDir := filepath.Dir(pkgPath) + j.nodeModulesDirs = j.findAllNodeModules(pkgDir, home) + if len(j.nodeModulesDirs) == 0 { + 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. - for k := range pkg.Dependencies { - if k == "webpack" { - return true, nil - } +Run: %s + +This will install all dependencies from package.json. +Then retry your command.`, installCmd), } } - return false, nil + if j.verbose { + text.Info(j.output, "Found package.json at %s\n", pkgPath) + for _, d := range j.nodeModulesDirs { + text.Info(j.output, "Found node_modules at %s\n", d) + } + } + return nil } -// search recurses up the directory tree looking for the given file. -func search(filename, wd, home string) (found bool, path string, err error) { - parent := filepath.Dir(wd) - - var noManifest bool - path = filepath.Join(wd, filename) - if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { - noManifest = true +// verifyJsComputeRuntime checks that @fastly/js-compute is installed. +func (j *JavaScript) verifyJsComputeRuntime() error { + for _, nmDir := range j.nodeModulesDirs { + runtimePath := filepath.Join(nmDir, "@fastly", "js-compute") + if _, err := os.Stat(runtimePath); err == nil { + if j.verbose { + text.Info(j.output, "Found @fastly/js-compute runtime in %s\n", nmDir) + } + return nil + } } + 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. - // We've found the manifest. - if !noManifest { - return true, path, nil +Run: %s + +This package is required to compile JavaScript for Fastly Compute. +Then retry your command.`, installCmd), } +} - // NOTE: The first condition catches if we reach the user's 'root' directory. - if wd != parent && wd != home { - return search(filename, parent, home) +// verifyToolchain checks that a JavaScript runtime is installed and accessible. +// 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 { + return err } + j.runtime = runtime - return false, "", nil + if err := j.verifyDependencies(); err != nil { + return err + } + if err := j.verifyJsComputeRuntime(); err != nil { + return err + } + return nil } -// NPMPackage represents a package.json manifest and its dependencies. -type NPMPackage struct { - DevDependencies map[string]string `json:"devDependencies"` - Dependencies map[string]string `json:"dependencies"` +// getDefaultBuildCommand returns the appropriate build command for the detected runtime. +func (j *JavaScript) getDefaultBuildCommand() string { + if j.runtime != nil && j.runtime.PkgMgr == "bun" { + return BunDefaultBuildCommand + } + return JsDefaultBuildCommand } diff --git a/pkg/commands/compute/language_javascript_test.go b/pkg/commands/compute/language_javascript_test.go new file mode 100644 index 000000000..8b1bf98be --- /dev/null +++ b/pkg/commands/compute/language_javascript_test.go @@ -0,0 +1,578 @@ +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_findAllNodeModules(t *testing.T) { + // Create directory structure: + // tmpDir/project/node_modules (parent) + // tmpDir/project/subdir/node_modules (child) + tmpDir := t.TempDir() + projectDir := filepath.Join(tmpDir, "project") + subDir := filepath.Join(projectDir, "subdir") + parentNM := filepath.Join(projectDir, "node_modules") + childNM := filepath.Join(subDir, "node_modules") + + if err := os.MkdirAll(childNM, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(parentNM, 0o755); err != nil { + t.Fatal(err) + } + + j := &JavaScript{} + + // From subDir should find both, nearest first + dirs := j.findAllNodeModules(subDir, tmpDir) + if len(dirs) != 2 { + t.Fatalf("expected 2 node_modules dirs, got %d: %v", len(dirs), dirs) + } + if dirs[0] != childNM { + t.Errorf("expected first dir %q, got %q", childNM, dirs[0]) + } + if dirs[1] != parentNM { + t.Errorf("expected second dir %q, got %q", parentNM, dirs[1]) + } + + // From projectDir should find only one + dirs = j.findAllNodeModules(projectDir, tmpDir) + if len(dirs) != 1 { + t.Fatalf("expected 1 node_modules dir, got %d: %v", len(dirs), dirs) + } + if dirs[0] != parentNM { + t.Errorf("expected path %q, got %q", parentNM, dirs[0]) + } + + // Should not find node_modules above home + dirs = j.findAllNodeModules(tmpDir, tmpDir) + if len(dirs) != 0 { + t.Errorf("expected no node_modules dirs, got %v", dirs) + } +} + +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, + nodeModulesDirs: []string{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, + nodeModulesDirs: []string{nodeModulesDir}, + runtime: &JSRuntime{Name: "node", PkgMgr: "npm"}, + } + + err := j.verifyJsComputeRuntime() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestJavaScript_verifyJsComputeRuntime_InParentNodeModules(t *testing.T) { + // Monorepo: @fastly/js-compute is hoisted to root node_modules + tmpDir := t.TempDir() + rootNM := filepath.Join(tmpDir, "node_modules") + childNM := filepath.Join(tmpDir, "app", "node_modules") + runtimeDir := filepath.Join(rootNM, "@fastly", "js-compute") + if err := os.MkdirAll(runtimeDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(childNM, 0o755); err != nil { + t.Fatal(err) + } + + j := &JavaScript{ + output: &bytes.Buffer{}, + verbose: false, + nodeModulesDirs: []string{childNM, rootNM}, + runtime: &JSRuntime{Name: "node", PkgMgr: "npm"}, + } + + err := j.verifyJsComputeRuntime() + if err != nil { + t.Fatalf("expected to find @fastly/js-compute in parent node_modules: %v", err) + } +} + +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_Node(t *testing.T) { + j := &JavaScript{ + runtime: &JSRuntime{Name: "node", PkgMgr: "npm"}, + } + + cmd := j.getDefaultBuildCommand() + if cmd != JsDefaultBuildCommand { + t.Errorf("expected default command, got %q", cmd) + } +} + +func TestJavaScript_getDefaultBuildCommand_Bun(t *testing.T) { + j := &JavaScript{ + runtime: &JSRuntime{Name: "bun", PkgMgr: "bun"}, + } + + cmd := j.getDefaultBuildCommand() + 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) + } +} diff --git a/pkg/commands/compute/testdata/build/javascript/webpack.config.js b/pkg/commands/compute/testdata/build/javascript/webpack.config.js deleted file mode 100644 index 35d287c59..000000000 --- a/pkg/commands/compute/testdata/build/javascript/webpack.config.js +++ /dev/null @@ -1,20 +0,0 @@ -const path = require("path"); -const webpack = require("webpack"); - -module.exports = { - entry: "./src/index.js", - optimization: { - minimize: true - }, - target: "webworker", - output: { - filename: "index.js", - path: path.resolve(__dirname, "bin"), - libraryTarget: "this", - }, - plugins: [ - new webpack.ProvidePlugin({ - URL: "core-js/web/url", - }), - ], -};