From ce33866da1619550c7ebdf27bb0439876a8db4a7 Mon Sep 17 00:00:00 2001 From: James Broadhead Date: Wed, 25 Feb 2026 21:41:23 +0000 Subject: [PATCH] Run npm install asynchronously during apps init Start npm install in the background immediately after cloning the template, so it runs in parallel with the interactive prompts. This reduces total init time by overlapping the (often slow) npm install with the time the user spends answering questions. Co-Authored-By: Claude Opus 4.6 --- cmd/apps/init.go | 58 +++++++++++++++++++++++ cmd/apps/init_test.go | 36 ++++++++++++++ libs/apps/initializer/initializer_test.go | 43 +++++++++++++++++ libs/apps/initializer/nodejs.go | 33 ++++++++++--- 4 files changed, 164 insertions(+), 6 deletions(-) diff --git a/cmd/apps/init.go b/cmd/apps/init.go index 144b5b7c6d..09a23c4bb7 100644 --- a/cmd/apps/init.go +++ b/cmd/apps/init.go @@ -457,6 +457,21 @@ func promptForResource(ctx context.Context, r manifest.Resource, theme *huh.Them return map[string]string{r.Key(): value}, nil } +// findTemplateProjectDir finds the project source directory within a template. +// Templates may contain a {{.project_name}} subdirectory that holds the actual project files. +func findTemplateProjectDir(templateDir string) string { + entries, err := os.ReadDir(templateDir) + if err != nil { + return templateDir + } + for _, e := range entries { + if e.IsDir() && strings.Contains(e.Name(), "{{.project_name}}") { + return filepath.Join(templateDir, e.Name()) + } + } + return templateDir +} + // cloneRepo clones a git repository to a temporary directory. func cloneRepo(ctx context.Context, repoURL, branch string) (string, error) { tempDir, err := os.MkdirTemp("", "appkit-template-*") @@ -605,6 +620,28 @@ func runCreate(ctx context.Context, opts createOptions) error { } } + // Start npm install early in the background while the user answers questions. + // npm only needs package.json/package-lock.json which exist in the cloned + // template directory. The project_name template variable in package.json + // doesn't affect dependency resolution. + var npmInstallCh <-chan error + var npmSrcDir string + if cleanup != nil { // Only for cloned templates (safe to install into temp dir) + srcProjDir := findTemplateProjectDir(templateDir) + if _, statErr := os.Stat(filepath.Join(srcProjDir, "package.json")); statErr == nil { + npmSrcDir = srcProjDir + // Ensure .npmrc is available (templates use _npmrc naming convention) + npmrcSrc := filepath.Join(srcProjDir, "_npmrc") + npmrcDst := filepath.Join(srcProjDir, ".npmrc") + if data, readErr := os.ReadFile(npmrcSrc); readErr == nil { + if _, statErr2 := os.Stat(npmrcDst); os.IsNotExist(statErr2) { + _ = os.WriteFile(npmrcDst, data, 0o644) + } + } + npmInstallCh = initializer.StartNpmInstallAsync(ctx, srcProjDir) + } + } + // Step 3: Load manifest from template (optional — templates without it skip plugin/resource logic) var m *manifest.Manifest if manifest.HasManifest(templateDir) { @@ -811,6 +848,27 @@ func runCreate(ctx context.Context, opts createOptions) error { return runErr } + // Wait for background npm install (if running) and move node_modules to project. + if npmInstallCh != nil { + moveErr := prompt.RunWithSpinnerCtx(ctx, "Installing dependencies...", func() error { + if installErr := <-npmInstallCh; installErr != nil { + return installErr + } + src := filepath.Join(npmSrcDir, "node_modules") + dst := filepath.Join(absOutputDir, "node_modules") + if renameErr := os.Rename(src, dst); renameErr != nil { + // Fallback for cross-device moves (e.g. /tmp -> home) + return exec.CommandContext(ctx, "mv", src, dst).Run() + } + return nil + }) + if moveErr != nil { + log.Warnf(ctx, "Background npm install failed, will retry: %v", moveErr) + // Clean up any partial node_modules so Initialize retries properly + os.RemoveAll(filepath.Join(absOutputDir, "node_modules")) + } + } + // Initialize project based on type (Node.js, Python, etc.) var nextStepsCmd string projectInitializer := initializer.GetProjectInitializer(absOutputDir) diff --git a/cmd/apps/init_test.go b/cmd/apps/init_test.go index a96b984261..52957a5ca1 100644 --- a/cmd/apps/init_test.go +++ b/cmd/apps/init_test.go @@ -459,6 +459,42 @@ func TestAppendUniqueNoValues(t *testing.T) { assert.Equal(t, []string{"a", "b"}, result) } +func TestFindTemplateProjectDir(t *testing.T) { + t.Run("returns project_name subdir when present", func(t *testing.T) { + dir := t.TempDir() + projDir := filepath.Join(dir, "{{.project_name}}") + require.NoError(t, os.Mkdir(projDir, 0o755)) + // Also add a non-matching directory to ensure we pick the right one + require.NoError(t, os.Mkdir(filepath.Join(dir, "other"), 0o755)) + + result := findTemplateProjectDir(dir) + assert.Equal(t, projDir, result) + }) + + t.Run("returns input dir when no project_name subdir", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(dir, "src"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "package.json"), []byte("{}"), 0o644)) + + result := findTemplateProjectDir(dir) + assert.Equal(t, dir, result) + }) + + t.Run("returns input dir when dir is empty", func(t *testing.T) { + dir := t.TempDir() + + result := findTemplateProjectDir(dir) + assert.Equal(t, dir, result) + }) + + t.Run("returns input dir when dir does not exist", func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "nonexistent") + + result := findTemplateProjectDir(dir) + assert.Equal(t, dir, result) + }) +} + func TestRunManifestOnlyFound(t *testing.T) { dir := t.TempDir() manifestPath := filepath.Join(dir, manifest.ManifestFileName) diff --git a/libs/apps/initializer/initializer_test.go b/libs/apps/initializer/initializer_test.go index d39076b2d4..1e82a96908 100644 --- a/libs/apps/initializer/initializer_test.go +++ b/libs/apps/initializer/initializer_test.go @@ -1,9 +1,11 @@ package initializer import ( + "context" "os" "path/filepath" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -155,6 +157,47 @@ func TestDetectPythonCommand(t *testing.T) { } } +func TestStartNpmInstallAsyncReturnsChannel(t *testing.T) { + // Cancelled context ensures the process exits quickly regardless of npm availability. + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + tmpDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{"name":"test"}`), 0o644)) + + ch := StartNpmInstallAsync(ctx, tmpDir) + require.NotNil(t, ch) + + // Channel must yield exactly one value (nil if npm not found, or error from cancelled ctx). + select { + case <-ch: + // ok – received a value + case <-time.After(10 * time.Second): + t.Fatal("timed out waiting for StartNpmInstallAsync channel") + } +} + +func TestInitializeSkipsNpmInstallWhenNodeModulesExists(t *testing.T) { + tmpDir := t.TempDir() + + // Create a package.json without appkit so appkit setup is also skipped. + require.NoError(t, os.WriteFile( + filepath.Join(tmpDir, "package.json"), + []byte(`{"name":"test","dependencies":{"express":"^4.0.0"}}`), + 0o644, + )) + + // Pre-create node_modules to simulate a completed async install. + require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, "node_modules", ".package-lock.json"), 0o755)) + + init := &InitializerNodeJs{} + result := init.Initialize(context.Background(), tmpDir) + + // Should succeed without actually running npm install. + assert.True(t, result.Success) + assert.Equal(t, "Node.js project initialized successfully", result.Message) +} + func getTypeName(i Initializer) string { switch i.(type) { case *InitializerNodeJs: diff --git a/libs/apps/initializer/nodejs.go b/libs/apps/initializer/nodejs.go index 1e96f43f72..9bb039fb9a 100644 --- a/libs/apps/initializer/nodejs.go +++ b/libs/apps/initializer/nodejs.go @@ -20,12 +20,14 @@ type InitializerNodeJs struct { func (i *InitializerNodeJs) Initialize(ctx context.Context, workDir string) *InitResult { i.workDir = workDir - // Step 1: Run npm install - if err := i.runNpmInstall(ctx, workDir); err != nil { - return &InitResult{ - Success: false, - Message: "Failed to install dependencies", - Error: err, + // Step 1: Run npm install (skip if node_modules already exists, e.g. from async pre-install) + if _, err := os.Stat(filepath.Join(workDir, "node_modules")); os.IsNotExist(err) { + if err := i.runNpmInstall(ctx, workDir); err != nil { + return &InitResult{ + Success: false, + Message: "Failed to install dependencies", + Error: err, + } } } @@ -67,6 +69,25 @@ func (i *InitializerNodeJs) SupportsDevRemote() bool { return i.hasAppkit(i.workDir) } +// StartNpmInstallAsync starts npm ci in the given directory in a background goroutine. +// Returns a channel that receives nil on success or an error when complete. +// If npm is not available, the channel receives nil immediately. +func StartNpmInstallAsync(ctx context.Context, workDir string) <-chan error { + ch := make(chan error, 1) + if _, err := exec.LookPath("npm"); err != nil { + ch <- nil + return ch + } + go func() { + cmd := exec.CommandContext(ctx, "npm", "ci", "--no-audit", "--no-fund", "--prefer-offline") + cmd.Dir = workDir + cmd.Stdout = nil + cmd.Stderr = nil + ch <- cmd.Run() + }() + return ch +} + // runNpmInstall runs npm install in the project directory. func (i *InitializerNodeJs) runNpmInstall(ctx context.Context, workDir string) error { // Check if npm is available