From f171c9b997d61030e88a9c407f7a4a3d4d0be9ba Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Wed, 7 Oct 2026 09:33:15 +0200 Subject: [PATCH 1/2] Use the detected package manager in apps run-local `apps run-local` always ran `npm install`, `npm run build`, and `npm run start` for Node.js apps. Select the package manager with the same detection as `apps validate` and `apps deploy`, so pnpm projects are installed, built, and started with pnpm. Co-authored-by: Isaac --- .../cli/apps-run-local-package-manager.md | 1 + .../apps/run-local-node/app-pnpm/package.json | 8 ++++ .../run-local-node/app-pnpm/pnpm-lock.yaml | 1 + .../workspace/apps/run-local-node/bin/pnpm | 2 + .../apps/run-local-node/bin/pnpm.cmd | 2 + .../workspace/apps/run-local-node/output.txt | 10 ++++ .../cmd/workspace/apps/run-local-node/script | 10 +++- .../workspace/apps/run-local-node/test.toml | 1 + cmd/apps/run_local.go | 2 +- libs/apps/runlocal/apps.go | 11 ++++- libs/apps/runlocal/apps_test.go | 46 +++++++++++++++++++ libs/apps/runlocal/node.go | 22 +++++---- libs/apps/runlocal/node_test.go | 6 +-- 13 files changed, 105 insertions(+), 17 deletions(-) create mode 100644 .nextchanges/cli/apps-run-local-package-manager.md create mode 100644 acceptance/cmd/workspace/apps/run-local-node/app-pnpm/package.json create mode 100644 acceptance/cmd/workspace/apps/run-local-node/app-pnpm/pnpm-lock.yaml create mode 100755 acceptance/cmd/workspace/apps/run-local-node/bin/pnpm create mode 100644 acceptance/cmd/workspace/apps/run-local-node/bin/pnpm.cmd create mode 100644 libs/apps/runlocal/apps_test.go diff --git a/.nextchanges/cli/apps-run-local-package-manager.md b/.nextchanges/cli/apps-run-local-package-manager.md new file mode 100644 index 00000000000..d7b2f2da58a --- /dev/null +++ b/.nextchanges/cli/apps-run-local-package-manager.md @@ -0,0 +1 @@ +* `apps run-local` now uses the npm or pnpm selection from `apps validate` to install dependencies, run the build script, and start Node.js apps, instead of always using npm. diff --git a/acceptance/cmd/workspace/apps/run-local-node/app-pnpm/package.json b/acceptance/cmd/workspace/apps/run-local-node/app-pnpm/package.json new file mode 100644 index 00000000000..5b1c57db05d --- /dev/null +++ b/acceptance/cmd/workspace/apps/run-local-node/app-pnpm/package.json @@ -0,0 +1,8 @@ +{ + "name": "app-pnpm", + "version": "1.0.0", + "description": "A Node.js app that uses pnpm", + "scripts": { + "build": "node build.js" + } +} diff --git a/acceptance/cmd/workspace/apps/run-local-node/app-pnpm/pnpm-lock.yaml b/acceptance/cmd/workspace/apps/run-local-node/app-pnpm/pnpm-lock.yaml new file mode 100644 index 00000000000..b0a073a9c13 --- /dev/null +++ b/acceptance/cmd/workspace/apps/run-local-node/app-pnpm/pnpm-lock.yaml @@ -0,0 +1 @@ +lockfileVersion: '9.0' diff --git a/acceptance/cmd/workspace/apps/run-local-node/bin/pnpm b/acceptance/cmd/workspace/apps/run-local-node/bin/pnpm new file mode 100755 index 00000000000..d0c9edcdaa2 --- /dev/null +++ b/acceptance/cmd/workspace/apps/run-local-node/bin/pnpm @@ -0,0 +1,2 @@ +#!/bin/sh +printf '%s %s\n' "${0##*/}" "$*" >> commands.log diff --git a/acceptance/cmd/workspace/apps/run-local-node/bin/pnpm.cmd b/acceptance/cmd/workspace/apps/run-local-node/bin/pnpm.cmd new file mode 100644 index 00000000000..ddd7ab33b51 --- /dev/null +++ b/acceptance/cmd/workspace/apps/run-local-node/bin/pnpm.cmd @@ -0,0 +1,2 @@ +@echo off +echo %~n0 %*>>commands.log diff --git a/acceptance/cmd/workspace/apps/run-local-node/output.txt b/acceptance/cmd/workspace/apps/run-local-node/output.txt index e6b0bd0a642..ed983f57540 100644 --- a/acceptance/cmd/workspace/apps/run-local-node/output.txt +++ b/acceptance/cmd/workspace/apps/run-local-node/output.txt @@ -26,3 +26,13 @@ Debugger listening on ws://127.0.0.1:[DEBUG_PORT]/[UUID] === Shutting the app down >>> curl -sS -o /dev/null http://127.0.0.1:[PROXY_PORT]/shutdown + +=== A pnpm lockfile selects pnpm to install, build, and start the app + +>>> grep -x Running command: pnpm run start ../LOG.pnpm +Running command: pnpm run start + +>>> cat commands.log +pnpm install +pnpm run build +pnpm run start diff --git a/acceptance/cmd/workspace/apps/run-local-node/script b/acceptance/cmd/workspace/apps/run-local-node/script index 9749e979748..74e3ea0270e 100755 --- a/acceptance/cmd/workspace/apps/run-local-node/script +++ b/acceptance/cmd/workspace/apps/run-local-node/script @@ -1,7 +1,7 @@ cd app # Ports are allocated per run so that tests in parallel worktrees do not collide. -read -r APP_PORT PROXY_PORT DEBUG_PORT PREPARE_PORT <<< "$(free_port.py 4)" +read -r APP_PORT PROXY_PORT DEBUG_PORT PREPARE_PORT PNPM_PORT <<< "$(free_port.py 5)" add_repl "$PROXY_PORT" PROXY_PORT add_repl "$DEBUG_PORT" DEBUG_PORT @@ -43,3 +43,11 @@ trace grep "Debugger listening on ws://127.0.0.1:$DEBUG_PORT/" LOG.run title "Shutting the app down\n" trace curl -sS -o /dev/null "http://127.0.0.1:$PROXY_PORT/shutdown" wait $PID + +title "A pnpm lockfile selects pnpm to install, build, and start the app\n" +# bin/pnpm only records its arguments, so no real pnpm is needed and the start command exits at once. +export PATH="$PWD/bin:$PATH" +cd app-pnpm +trace $CLI apps run-local --prepare-environment --port "$PNPM_PORT" &> ../LOG.pnpm +trace grep -x "Running command: pnpm run start" ../LOG.pnpm +trace cat commands.log diff --git a/acceptance/cmd/workspace/apps/run-local-node/test.toml b/acceptance/cmd/workspace/apps/run-local-node/test.toml index f46c1e68b26..a8740412a35 100644 --- a/acceptance/cmd/workspace/apps/run-local-node/test.toml +++ b/acceptance/cmd/workspace/apps/run-local-node/test.toml @@ -4,6 +4,7 @@ RecordRequests = false # npm install writes package-lock.json even with nothing to install, and older npm versions # also create node_modules for their own copy of the lockfile. Ignore = [ + 'commands.log', 'node_modules', 'package-lock.json' ] diff --git a/cmd/apps/run_local.go b/cmd/apps/run_local.go index d9671276f0f..ba4100d735f 100644 --- a/cmd/apps/run_local.go +++ b/cmd/apps/run_local.go @@ -54,7 +54,7 @@ func setupWorkspaceAndConfig(cmd *cobra.Command, entryPoint string, appPort int) func setupApp(cmd *cobra.Command, config *runlocal.Config, spec *runlocal.AppSpec, customEnv []string, prepareEnvironment bool) (runlocal.App, []string, error) { ctx := cmd.Context() cfg := cmdctx.ConfigUsed(ctx) - app, err := runlocal.NewApp(config, spec) + app, err := runlocal.NewApp(ctx, config, spec) if err != nil { return nil, nil, err } diff --git a/libs/apps/runlocal/apps.go b/libs/apps/runlocal/apps.go index 983de220eec..7cf9b79fd0c 100644 --- a/libs/apps/runlocal/apps.go +++ b/libs/apps/runlocal/apps.go @@ -4,6 +4,8 @@ import ( "context" "os" "path/filepath" + + "github.com/databricks/cli/libs/apps/validation" ) type App interface { @@ -11,7 +13,7 @@ type App interface { GetCommand(ctx context.Context, debug bool) (cmd, env []string, err error) } -func NewApp(config *Config, spec *AppSpec) (App, error) { +func NewApp(ctx context.Context, config *Config, spec *AppSpec) (App, error) { // Check if the app is a Node.js app by checking if there is a package.json file in the root of the app packageJsonPath := filepath.Join(config.AppPath, "package.json") _, err := os.Stat(packageJsonPath) @@ -21,7 +23,12 @@ func NewApp(config *Config, spec *AppSpec) (App, error) { if err != nil { return nil, err } - return NewNodeApp(config, spec, packageJson), nil + // Use the same package manager as apps validate and apps deploy. + packageManager, err := validation.DetectPackageManager(ctx, config.AppPath) + if err != nil { + return nil, err + } + return NewNodeApp(config, spec, packageJson, packageManager), nil } return NewPythonApp(config, spec), nil diff --git a/libs/apps/runlocal/apps_test.go b/libs/apps/runlocal/apps_test.go new file mode 100644 index 00000000000..1ca0076bb9b --- /dev/null +++ b/libs/apps/runlocal/apps_test.go @@ -0,0 +1,46 @@ +package runlocal + +import ( + "path/filepath" + "testing" + + "github.com/databricks/cli/internal/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewAppNodeUsesDetectedPackageManager(t *testing.T) { + tests := []struct { + name string + lockfile string + wantCmd []string + }{ + { + name: "npm without a lockfile", + wantCmd: []string{"npm", "run", "start"}, + }, + { + name: "pnpm lockfile", + lockfile: "pnpm-lock.yaml", + wantCmd: []string{"pnpm", "run", "start"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + testutil.WriteFile(t, filepath.Join(dir, "package.json"), `{"name": "app"}`) + if tt.lockfile != "" { + testutil.Touch(t, dir, tt.lockfile) + } + + config := &Config{AppPath: dir} + app, err := NewApp(t.Context(), config, &AppSpec{config: config}) + require.NoError(t, err) + + cmd, _, err := app.GetCommand(t.Context(), false) + require.NoError(t, err) + assert.Equal(t, tt.wantCmd, cmd) + }) + } +} diff --git a/libs/apps/runlocal/node.go b/libs/apps/runlocal/node.go index f4dfb58a40b..0585409f0dd 100644 --- a/libs/apps/runlocal/node.go +++ b/libs/apps/runlocal/node.go @@ -16,33 +16,35 @@ type PackageJson struct { } type NodeApp struct { - config *Config - spec *AppSpec - packageJson *PackageJson + config *Config + spec *AppSpec + packageJson *PackageJson + packageManager string } -func NewNodeApp(config *Config, spec *AppSpec, packageJson *PackageJson) *NodeApp { +func NewNodeApp(config *Config, spec *AppSpec, packageJson *PackageJson, packageManager string) *NodeApp { if config.DebugPort == "" { config.DebugPort = NODE_DEBUG_PORT } return &NodeApp{ - config: config, - spec: spec, - packageJson: packageJson, + config: config, + spec: spec, + packageJson: packageJson, + packageManager: packageManager, } } func (n *NodeApp) PrepareEnvironment(ctx context.Context) error { // Install dependencies - installArgs := []string{"npm", "install"} + installArgs := []string{n.packageManager, "install"} if err := runCommand(ctx, n.config.AppPath, installArgs); err != nil { return err } // Run build script if it exists if _, ok := n.packageJson.Scripts["build"]; ok { - buildArgs := []string{"npm", "run", "build"} + buildArgs := []string{n.packageManager, "run", "build"} if err := runCommand(ctx, n.config.AppPath, buildArgs); err != nil { return err } @@ -58,7 +60,7 @@ func (n *NodeApp) GetCommand(ctx context.Context, debug bool) ([]string, []strin } if n.spec.Command == nil { - return []string{"npm", "run", "start"}, cmdEnv, nil + return []string{n.packageManager, "run", "start"}, cmdEnv, nil } return n.spec.Command, cmdEnv, nil diff --git a/libs/apps/runlocal/node_test.go b/libs/apps/runlocal/node_test.go index ba7de6ca854..80f0fa5d380 100644 --- a/libs/apps/runlocal/node_test.go +++ b/libs/apps/runlocal/node_test.go @@ -44,7 +44,7 @@ func TestNodeAppGetCommand(t *testing.T) { t.Run(tt.name, func(t *testing.T) { config := &Config{} spec := &AppSpec{config: config, Command: tt.command} - app := NewNodeApp(config, spec, &PackageJson{}) + app := NewNodeApp(config, spec, &PackageJson{}, "npm") ctx := env.Set(t.Context(), "NODE_OPTIONS", "") // unset ambient NODE_OPTIONS to keep env clean cmd, cmdEnv, err := app.GetCommand(ctx, tt.debug) require.NoError(t, err) @@ -60,7 +60,7 @@ func TestNodeAppGetCommandDebugAppendsToExistingNodeOptions(t *testing.T) { config := &Config{} spec := &AppSpec{config: config} - app := NewNodeApp(config, spec, &PackageJson{}) + app := NewNodeApp(config, spec, &PackageJson{}, "npm") _, cmdEnv, err := app.GetCommand(ctx, true) require.NoError(t, err) assert.Equal(t, []string{"NODE_OPTIONS=--max-old-space-size=4096 --inspect=9229"}, cmdEnv) @@ -69,7 +69,7 @@ func TestNodeAppGetCommandDebugAppendsToExistingNodeOptions(t *testing.T) { func TestNodeAppGetCommandDebugCustomPort(t *testing.T) { config := &Config{DebugPort: "5555"} spec := &AppSpec{config: config} - app := NewNodeApp(config, spec, &PackageJson{}) + app := NewNodeApp(config, spec, &PackageJson{}, "npm") ctx := env.Set(t.Context(), "NODE_OPTIONS", "") // unset ambient NODE_OPTIONS to keep env clean _, cmdEnv, err := app.GetCommand(ctx, true) require.NoError(t, err) From 4bba0f18f85da2cbb4225a442cf17cc8d965bad2 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Wed, 7 Oct 2026 09:33:56 +0200 Subject: [PATCH 2/2] Link the changelog entry to its PR Co-authored-by: Isaac --- .nextchanges/cli/apps-run-local-package-manager.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.nextchanges/cli/apps-run-local-package-manager.md b/.nextchanges/cli/apps-run-local-package-manager.md index d7b2f2da58a..af3fbcb3231 100644 --- a/.nextchanges/cli/apps-run-local-package-manager.md +++ b/.nextchanges/cli/apps-run-local-package-manager.md @@ -1 +1 @@ -* `apps run-local` now uses the npm or pnpm selection from `apps validate` to install dependencies, run the build script, and start Node.js apps, instead of always using npm. +* `apps run-local` now uses the npm or pnpm selection from `apps validate` to install dependencies, run the build script, and start Node.js apps, instead of always using npm. ([#6962](https://github.com/databricks/cli/pull/6962))