Skip to content

Commit e126356

Browse files
authored
Fix background npm install with file dependencies in apps init` flow (#5045)
## Summary When using `databricks apps init --template` with a template that has `file:` protocol dependencies (e.g., `"@databricks/appkit": "file:./databricks-appkit-0.24.0.tgz"`), the background `npm ci` fails with exit 254 (ENOENT): ``` npm error path /private/tmp/dest-dir/databricks-appkit-ui-0.24.0.tgz npm error errno -2 npm error enoent ENOENT: no such file or directory ``` **Root cause:** `startBackgroundNpmInstall()` copies only `package.json` and `package-lock.json` to the destination directory before running `npm ci`. The `.tgz` tarball files referenced by `file:` protocol are not copied — they only arrive later during `copyTemplate()`. **Fix:** Add `copyFileDeps()` that parses the rendered `package.json`, finds `file:` protocol dependencies in both `dependencies` and `devDependencies`, and copies the referenced files from the template source to the destination directory before `npm ci` starts. The retry after `copyTemplate()` still succeeds (all files present), so the issue was cosmetic (confusing warning + ~10s delay), but this eliminates the unnecessary failure and retry. ## Demo https://github.com/user-attachments/assets/2de06bf0-30ef-49e6-baac-3d86d873d3ed ## Test plan - [x] Added `TestCopyFileDeps` — verifies file: deps are copied, registry deps are ignored, missing files are skipped gracefully - [x] Added `TestCopyFileDepsInvalidJSON` — verifies no panic on malformed input - [x] Added `TestCopyFileDepsNoDeps` — verifies no side effects when no file: deps exist - [x] CI builds and passes all tests --------- Signed-off-by: Pawel Kosiec <pawel.kosiec@databricks.com>
1 parent 8665a3b commit e126356

2 files changed

Lines changed: 214 additions & 0 deletions

File tree

cmd/apps/init.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package apps
33
import (
44
"bytes"
55
"context"
6+
"encoding/json"
67
"errors"
78
"fmt"
89
"io/fs"
@@ -664,6 +665,14 @@ func startBackgroundNpmInstall(ctx context.Context, srcProjectDir, destDir, proj
664665
return nil
665666
}
666667

668+
// Copy any file: protocol dependencies (e.g., local .tgz tarballs) so npm ci can resolve them.
669+
pkgData, err := os.ReadFile(filepath.Join(destDir, "package.json"))
670+
if err != nil {
671+
log.Warnf(ctx, "Failed to read package.json for file dep copy: %v", err)
672+
} else {
673+
copyFileDeps(ctx, pkgData, srcProjectDir, destDir)
674+
}
675+
667676
// Copy package-lock.json raw (never has template vars).
668677
lockData, err := os.ReadFile(lockFile)
669678
if err != nil {
@@ -688,6 +697,41 @@ func startBackgroundNpmInstall(ctx context.Context, srcProjectDir, destDir, proj
688697
return ch
689698
}
690699

700+
// copyFileDeps copies local file: protocol dependencies (e.g., .tgz tarballs)
701+
// from srcDir to destDir so that npm ci can resolve them.
702+
func copyFileDeps(ctx context.Context, pkgJSON []byte, srcDir, destDir string) {
703+
var pkg struct {
704+
Dependencies map[string]string `json:"dependencies"`
705+
DevDependencies map[string]string `json:"devDependencies"`
706+
}
707+
if err := json.Unmarshal(pkgJSON, &pkg); err != nil {
708+
log.Debugf(ctx, "Failed to parse package.json for file dep copy: %v", err)
709+
return
710+
}
711+
for _, deps := range []map[string]string{pkg.Dependencies, pkg.DevDependencies} {
712+
for _, v := range deps {
713+
if !strings.HasPrefix(v, "file:") {
714+
continue
715+
}
716+
relPath := filepath.Clean(strings.TrimPrefix(v, "file:"))
717+
src := filepath.Join(srcDir, relPath)
718+
data, err := os.ReadFile(src)
719+
if err != nil {
720+
log.Debugf(ctx, "Skipping file dep %s: %v", relPath, err)
721+
continue
722+
}
723+
dst := filepath.Join(destDir, relPath)
724+
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
725+
log.Debugf(ctx, "Failed to create dir for file dep %s: %v", relPath, err)
726+
continue
727+
}
728+
if err := os.WriteFile(dst, data, 0o644); err != nil {
729+
log.Debugf(ctx, "Failed to copy file dep %s: %v", relPath, err)
730+
}
731+
}
732+
}
733+
}
734+
691735
// awaitBackgroundNpmInstall waits for the background npm install to complete.
692736
// Shows an instant checkmark if already done, or a spinner for the remainder.
693737
func awaitBackgroundNpmInstall(ctx context.Context, ch <-chan error) error {

cmd/apps/init_test.go

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import (
44
"bytes"
55
"errors"
66
"io"
7+
"io/fs"
78
"os"
9+
"os/exec"
810
"path/filepath"
911
"testing"
1012

@@ -687,3 +689,171 @@ func TestRunManifestOnlyUsesTemplatePathEnvVar(t *testing.T) {
687689
out := buf.String()
688690
assert.Equal(t, content, out)
689691
}
692+
693+
func TestCopyFileDeps(t *testing.T) {
694+
ctx := t.Context()
695+
696+
srcDir := t.TempDir()
697+
destDir := t.TempDir()
698+
699+
// Create a fake tarball in srcDir
700+
tgzContent := []byte("fake-tarball-content")
701+
require.NoError(t, os.WriteFile(filepath.Join(srcDir, "my-pkg-1.0.0.tgz"), tgzContent, 0o644))
702+
703+
// package.json with file: dep, a registry dep, and a devDep with file:
704+
pkgJSON := []byte(`{
705+
"dependencies": {
706+
"my-pkg": "file:./my-pkg-1.0.0.tgz",
707+
"lodash": "4.17.21"
708+
},
709+
"devDependencies": {
710+
"missing-pkg": "file:./nonexistent.tgz"
711+
}
712+
}`)
713+
714+
copyFileDeps(ctx, pkgJSON, srcDir, destDir)
715+
716+
// The file: dep should be copied
717+
copied, err := os.ReadFile(filepath.Join(destDir, "my-pkg-1.0.0.tgz"))
718+
require.NoError(t, err)
719+
assert.Equal(t, tgzContent, copied)
720+
721+
// The registry dep should NOT create any file
722+
_, err = os.Stat(filepath.Join(destDir, "4.17.21"))
723+
assert.ErrorIs(t, err, fs.ErrNotExist)
724+
725+
// The missing file: dep should be skipped gracefully (no panic, no error)
726+
_, err = os.Stat(filepath.Join(destDir, "nonexistent.tgz"))
727+
assert.ErrorIs(t, err, fs.ErrNotExist)
728+
}
729+
730+
func TestCopyFileDepsInvalidJSON(t *testing.T) {
731+
ctx := t.Context()
732+
srcDir := t.TempDir()
733+
destDir := t.TempDir()
734+
735+
// Should not panic on invalid JSON
736+
copyFileDeps(ctx, []byte("not json"), srcDir, destDir)
737+
738+
// destDir should remain empty
739+
entries, err := os.ReadDir(destDir)
740+
require.NoError(t, err)
741+
assert.Empty(t, entries)
742+
}
743+
744+
func TestCopyFileDepsNoDeps(t *testing.T) {
745+
ctx := t.Context()
746+
srcDir := t.TempDir()
747+
destDir := t.TempDir()
748+
749+
// package.json with no file: deps
750+
pkgJSON := []byte(`{"dependencies": {"react": "19.0.0"}}`)
751+
copyFileDeps(ctx, pkgJSON, srcDir, destDir)
752+
753+
entries, err := os.ReadDir(destDir)
754+
require.NoError(t, err)
755+
assert.Empty(t, entries)
756+
}
757+
758+
func skipIfNoNpm(t *testing.T) {
759+
t.Helper()
760+
if _, err := exec.LookPath("npm"); err != nil {
761+
t.Skip("npm not found in PATH, skipping")
762+
}
763+
}
764+
765+
func TestStartBackgroundNpmInstall_NoLockFile(t *testing.T) {
766+
srcDir := t.TempDir()
767+
destDir := t.TempDir()
768+
769+
// Only package.json, no lock file
770+
require.NoError(t, os.WriteFile(filepath.Join(srcDir, "package.json"), []byte(`{"name":"test"}`), 0o644))
771+
772+
ch := startBackgroundNpmInstall(t.Context(), srcDir, destDir, "test-app")
773+
assert.Nil(t, ch)
774+
}
775+
776+
func TestStartBackgroundNpmInstall_NoPackageJSON(t *testing.T) {
777+
srcDir := t.TempDir()
778+
destDir := t.TempDir()
779+
780+
// Only lock file, no package.json
781+
require.NoError(t, os.WriteFile(filepath.Join(srcDir, "package-lock.json"), []byte(`{}`), 0o644))
782+
783+
ch := startBackgroundNpmInstall(t.Context(), srcDir, destDir, "test-app")
784+
assert.Nil(t, ch)
785+
}
786+
787+
func TestStartBackgroundNpmInstall_CopiesFiles(t *testing.T) {
788+
skipIfNoNpm(t)
789+
790+
srcDir := t.TempDir()
791+
destDir := filepath.Join(t.TempDir(), "output")
792+
793+
pkgJSON := []byte(`{"name":"{{.projectName}}","version":"1.0.0"}`)
794+
lockJSON := []byte(`{"lockfileVersion":3,"packages":{}}`)
795+
require.NoError(t, os.WriteFile(filepath.Join(srcDir, "package.json"), pkgJSON, 0o644))
796+
require.NoError(t, os.WriteFile(filepath.Join(srcDir, "package-lock.json"), lockJSON, 0o644))
797+
798+
ch := startBackgroundNpmInstall(t.Context(), srcDir, destDir, "my-app")
799+
require.NotNil(t, ch)
800+
801+
// Drain the channel to avoid goroutine leak (npm ci will fail on fake data)
802+
<-ch
803+
804+
// package.json should be written with template substitution
805+
got, err := os.ReadFile(filepath.Join(destDir, "package.json"))
806+
require.NoError(t, err)
807+
assert.Contains(t, string(got), `"my-app"`)
808+
assert.NotContains(t, string(got), "{{.projectName}}")
809+
810+
// package-lock.json should be copied verbatim
811+
gotLock, err := os.ReadFile(filepath.Join(destDir, "package-lock.json"))
812+
require.NoError(t, err)
813+
assert.Equal(t, lockJSON, gotLock)
814+
}
815+
816+
func TestStartBackgroundNpmInstall_CopiesFileDeps(t *testing.T) {
817+
skipIfNoNpm(t)
818+
819+
srcDir := t.TempDir()
820+
destDir := filepath.Join(t.TempDir(), "output")
821+
822+
tgzContent := []byte("fake-tarball")
823+
require.NoError(t, os.WriteFile(filepath.Join(srcDir, "my-pkg-1.0.0.tgz"), tgzContent, 0o644))
824+
825+
pkgJSON := []byte(`{"name":"test","dependencies":{"my-pkg":"file:./my-pkg-1.0.0.tgz"}}`)
826+
lockJSON := []byte(`{"lockfileVersion":3,"packages":{}}`)
827+
require.NoError(t, os.WriteFile(filepath.Join(srcDir, "package.json"), pkgJSON, 0o644))
828+
require.NoError(t, os.WriteFile(filepath.Join(srcDir, "package-lock.json"), lockJSON, 0o644))
829+
830+
ch := startBackgroundNpmInstall(t.Context(), srcDir, destDir, "test-app")
831+
require.NotNil(t, ch)
832+
<-ch
833+
834+
// The file: dep tarball should be copied to destDir
835+
copied, err := os.ReadFile(filepath.Join(destDir, "my-pkg-1.0.0.tgz"))
836+
require.NoError(t, err)
837+
assert.Equal(t, tgzContent, copied)
838+
}
839+
840+
func TestStartBackgroundNpmInstall_TemplateSubstitution(t *testing.T) {
841+
skipIfNoNpm(t)
842+
843+
srcDir := t.TempDir()
844+
destDir := filepath.Join(t.TempDir(), "output")
845+
846+
pkgJSON := []byte(`{"name":"{{.projectName}}","description":"{{.appDescription}}"}`)
847+
lockJSON := []byte(`{"lockfileVersion":3}`)
848+
require.NoError(t, os.WriteFile(filepath.Join(srcDir, "package.json"), pkgJSON, 0o644))
849+
require.NoError(t, os.WriteFile(filepath.Join(srcDir, "package-lock.json"), lockJSON, 0o644))
850+
851+
ch := startBackgroundNpmInstall(t.Context(), srcDir, destDir, "cool-project")
852+
require.NotNil(t, ch)
853+
<-ch
854+
855+
got, err := os.ReadFile(filepath.Join(destDir, "package.json"))
856+
require.NoError(t, err)
857+
assert.Contains(t, string(got), `"cool-project"`)
858+
assert.NotContains(t, string(got), "{{.projectName}}")
859+
}

0 commit comments

Comments
 (0)