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