Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions cmd/apps/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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-*")
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down
36 changes: 36 additions & 0 deletions cmd/apps/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
43 changes: 43 additions & 0 deletions libs/apps/initializer/initializer_test.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package initializer

import (
"context"
"os"
"path/filepath"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -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:
Expand Down
33 changes: 27 additions & 6 deletions libs/apps/initializer/nodejs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
}

Expand Down Expand Up @@ -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
Expand Down