@@ -329,6 +329,14 @@ func promptForPluginsAndDeps(ctx context.Context, m *manifest.Manifest, preSelec
329329 }
330330 theme := prompt .AppkitTheme ()
331331
332+ // Eagerly start fetching resources for ALL plugins in the background.
333+ // This runs while the user is selecting plugins, so by the time resource
334+ // pickers appear the data is likely already cached.
335+ allPluginNames := m .GetPluginNames ()
336+ allPossibleResources := m .CollectResources (allPluginNames )
337+ allPossibleResources = append (allPossibleResources , m .CollectOptionalResources (allPluginNames )... )
338+ ctx = prompt .PrefetchResources (ctx , allPossibleResources )
339+
332340 // Step 1: Plugin selection (skip if plugins already provided via flag)
333341 selectablePlugins := m .GetSelectablePlugins ()
334342 if len (config .Features ) == 0 && len (selectablePlugins ) > 0 {
@@ -361,8 +369,11 @@ func promptForPluginsAndDeps(ctx context.Context, m *manifest.Manifest, preSelec
361369 // Always include mandatory plugins.
362370 config .Features = appendUnique (config .Features , m .GetMandatoryPluginNames ()... )
363371
364- // Step 2: Prompt for required plugin resource dependencies
372+ // Collect resources for the user's actual selection.
365373 resources := m .CollectResources (config .Features )
374+ optionalResources := m .CollectOptionalResources (config .Features )
375+
376+ // Step 2: Prompt for required plugin resource dependencies
366377 for _ , r := range resources {
367378 values , err := promptForResource (ctx , r , theme , true )
368379 if err != nil {
@@ -374,7 +385,6 @@ func promptForPluginsAndDeps(ctx context.Context, m *manifest.Manifest, preSelec
374385 }
375386
376387 // Step 3: Prompt for optional plugin resource dependencies
377- optionalResources := m .CollectOptionalResources (config .Features )
378388 for _ , r := range optionalResources {
379389 values , err := promptForResource (ctx , r , theme , false )
380390 if err != nil {
@@ -490,43 +500,178 @@ func cloneRepo(ctx context.Context, repoURL, branch string) (string, error) {
490500 return tempDir , nil
491501}
492502
493- // resolveTemplate resolves a template path, handling both local paths and GitHub URLs.
494- // branch is used for cloning (can contain "/" for feature branches).
495- // subdir is an optional subdirectory within the repo to use (for default appkit template).
496- // Returns the local path to use, a cleanup function (for temp dirs), and any error.
497- func resolveTemplate (ctx context.Context , templatePath , branch , subdir string ) (localPath string , cleanup func (), err error ) {
498- // Case 1: Local path - return as-is
503+ // resolveTemplate resolves a template synchronously with a spinner.
504+ // Used by commands that don't benefit from background cloning (e.g., manifest).
505+ func resolveTemplate (ctx context.Context , templatePath , branch , subdir string ) (string , func (), error ) {
506+ ch := resolveTemplateAsync (ctx , templatePath , branch , subdir )
507+ return awaitTemplate (ctx , ch )
508+ }
509+
510+ // templateResult holds the outcome of a background template resolution.
511+ type templateResult struct {
512+ path string
513+ cleanup func ()
514+ err error
515+ }
516+
517+ // resolveTemplateAsync starts resolving the template in a background goroutine.
518+ // For local paths this completes immediately; for GitHub URLs it clones the repo.
519+ // The caller reads the result from the returned channel, optionally showing a
520+ // spinner if the clone hasn't finished by the time it's needed.
521+ func resolveTemplateAsync (ctx context.Context , templatePath , branch , subdir string ) <- chan templateResult {
522+ ch := make (chan templateResult , 1 )
523+
524+ // Local path — instant.
499525 if ! strings .HasPrefix (templatePath , "https://" ) {
500- return templatePath , nil , nil
526+ ch <- templateResult {path : templatePath }
527+ return ch
501528 }
502529
503- // Case 2: GitHub URL - parse and clone
504530 repoURL , urlSubdir , urlBranch := git .ParseGitHubURL (templatePath )
505531 if branch == "" {
506- branch = urlBranch // Use branch from URL if not overridden by flag
532+ branch = urlBranch
507533 }
508534 if subdir == "" {
509- subdir = urlSubdir // Use subdir from URL if not overridden
535+ subdir = urlSubdir
510536 }
511537
512- // Clone to temp dir with spinner
513- var tempDir string
514- err = prompt .RunWithSpinnerCtx (ctx , "Cloning template..." , func () error {
515- var cloneErr error
516- tempDir , cloneErr = cloneRepo (ctx , repoURL , branch )
517- return cloneErr
518- })
538+ go func () {
539+ tempDir , err := cloneRepo (ctx , repoURL , branch )
540+ if err != nil {
541+ ch <- templateResult {err : err }
542+ return
543+ }
544+ cleanup := func () { os .RemoveAll (tempDir ) }
545+ localPath := tempDir
546+ if subdir != "" {
547+ localPath = filepath .Join (tempDir , subdir )
548+ }
549+ ch <- templateResult {path : localPath , cleanup : cleanup }
550+ }()
551+
552+ return ch
553+ }
554+
555+ // awaitTemplate waits for the background clone to finish.
556+ // If the result is already available it returns immediately with a
557+ // checkmark; otherwise it shows a spinner while waiting.
558+ func awaitTemplate (ctx context.Context , ch <- chan templateResult ) (string , func (), error ) {
559+ select {
560+ case res := <- ch :
561+ // Clone finished while the user was typing — print completion.
562+ if res .err == nil && res .cleanup != nil {
563+ prompt .PrintDone (ctx , "Template cloned" )
564+ }
565+ return res .path , res .cleanup , res .err
566+ default :
567+ // Still cloning — show a spinner for the remaining wait.
568+ var res templateResult
569+ err := prompt .RunWithSpinnerCtx (ctx , "Cloning template..." , func () error {
570+ res = <- ch
571+ return res .err
572+ })
573+ return res .path , res .cleanup , err
574+ }
575+ }
576+
577+ // findProjectSrcDir locates the actual source directory inside a template.
578+ // Templates may nest their content inside a {{.project_name}} directory.
579+ func findProjectSrcDir (templateDir string ) string {
580+ entries , err := os .ReadDir (templateDir )
519581 if err != nil {
520- return "" , nil , err
582+ return templateDir
583+ }
584+ for _ , e := range entries {
585+ if e .IsDir () && strings .Contains (e .Name (), "{{.project_name}}" ) {
586+ return filepath .Join (templateDir , e .Name ())
587+ }
521588 }
589+ return templateDir
590+ }
522591
523- cleanup = func () { os .RemoveAll (tempDir ) }
592+ // startBackgroundNpmInstall copies the package files from the template into
593+ // destDir and launches `npm ci` in the background. The caller should read the
594+ // returned channel after copyTemplate to get the result. Returns nil if the
595+ // template is not a Node.js project or npm is not available.
596+ func startBackgroundNpmInstall (ctx context.Context , srcProjectDir , destDir , projectName string ) <- chan error {
597+ // Check that the template has a package-lock.json (needed by npm ci).
598+ lockFile := filepath .Join (srcProjectDir , "package-lock.json" )
599+ if _ , err := os .Stat (lockFile ); err != nil {
600+ return nil
601+ }
524602
525- // Return path to subdirectory if specified
526- if subdir != "" {
527- return filepath .Join (tempDir , subdir ), cleanup , nil
603+ if _ , err := exec .LookPath ("npm" ); err != nil {
604+ return nil
605+ }
606+
607+ if err := os .MkdirAll (destDir , 0o755 ); err != nil {
608+ return nil
609+ }
610+
611+ // Copy package.json (apply template substitution so the file is valid JSON)
612+ // and package-lock.json (no template vars — copy raw).
613+ for _ , name := range []string {"package.json" , "package.json.tmpl" } {
614+ src := filepath .Join (srcProjectDir , name )
615+ content , err := os .ReadFile (src )
616+ if err != nil {
617+ continue
618+ }
619+ // Minimal template vars so package.json renders to valid JSON.
620+ minVars := templateData (templateVars {
621+ ProjectName : projectName ,
622+ AppDescription : prompt .DefaultAppDescription ,
623+ Plugins : make (map [string ]* pluginVar ),
624+ })
625+ tmpl , err := template .New (name ).Option ("missingkey=zero" ).Parse (string (content ))
626+ if err != nil {
627+ // Not a Go template — copy raw.
628+ _ = os .WriteFile (filepath .Join (destDir , "package.json" ), content , 0o644 )
629+ break
630+ }
631+ var buf bytes.Buffer
632+ if err := tmpl .Execute (& buf , minVars ); err != nil {
633+ _ = os .WriteFile (filepath .Join (destDir , "package.json" ), content , 0o644 )
634+ break
635+ }
636+ _ = os .WriteFile (filepath .Join (destDir , "package.json" ), buf .Bytes (), 0o644 )
637+ break
638+ }
639+
640+ // Copy package-lock.json raw (never has template vars).
641+ if data , err := os .ReadFile (lockFile ); err == nil {
642+ _ = os .WriteFile (filepath .Join (destDir , "package-lock.json" ), data , 0o644 )
643+ }
644+
645+ ch := make (chan error , 1 )
646+ go func () {
647+ cmd := exec .CommandContext (ctx , "npm" , "ci" , "--no-audit" , "--no-fund" , "--prefer-offline" )
648+ cmd .Dir = destDir
649+ cmd .Stdout = nil
650+ cmd .Stderr = nil
651+ ch <- cmd .Run ()
652+ }()
653+
654+ log .Debugf (ctx , "Started background npm install in %s" , destDir )
655+ return ch
656+ }
657+
658+ // awaitBackgroundNpmInstall waits for the background npm install to complete.
659+ // Shows an instant checkmark if already done, or a spinner for the remainder.
660+ func awaitBackgroundNpmInstall (ctx context.Context , ch <- chan error ) error {
661+ select {
662+ case err := <- ch :
663+ if err == nil {
664+ prompt .PrintDone (ctx , "Dependencies installed" )
665+ }
666+ return err
667+ default :
668+ var installErr error
669+ err := prompt .RunWithSpinnerCtx (ctx , "Installing dependencies..." , func () error {
670+ installErr = <- ch
671+ return installErr
672+ })
673+ return err
528674 }
529- return tempDir , cleanup , nil
530675}
531676
532677func runCreate (ctx context.Context , opts createOptions ) error {
@@ -564,8 +709,16 @@ func runCreate(ctx context.Context, opts createOptions) error {
564709 templateSrc = appkitRepoURL
565710 }
566711
567- // Step 1: Get project name first (needed before we can check destination)
568- // Determine output directory for validation
712+ // Start cloning in the background so it runs while the user types the name.
713+ branchForClone := opts .branch
714+ subdirForClone := ""
715+ if usingDefaultTemplate {
716+ branchForClone = gitRef
717+ subdirForClone = appkitTemplateDir
718+ }
719+ templateCh := resolveTemplateAsync (ctx , templateSrc , branchForClone , subdirForClone )
720+
721+ // Step 1: Get project name (clone runs in parallel for remote templates)
569722 destDir := opts .name
570723 if opts .outputDir != "" {
571724 destDir = filepath .Join (opts .outputDir , opts .name )
@@ -575,19 +728,16 @@ func runCreate(ctx context.Context, opts createOptions) error {
575728 if ! isInteractive {
576729 return errors .New ("--name is required in non-interactive mode" )
577730 }
578- // Prompt includes validation for name format AND directory existence
579731 name , err := prompt .PromptForProjectName (ctx , opts .outputDir )
580732 if err != nil {
581733 return err
582734 }
583735 opts .name = name
584- // Update destDir with the actual name
585736 destDir = opts .name
586737 if opts .outputDir != "" {
587738 destDir = filepath .Join (opts .outputDir , opts .name )
588739 }
589740 } else {
590- // Non-interactive mode: validate name and directory existence
591741 if err := prompt .ValidateProjectName (opts .name ); err != nil {
592742 return err
593743 }
@@ -596,16 +746,8 @@ func runCreate(ctx context.Context, opts createOptions) error {
596746 }
597747 }
598748
599- // Step 2: Resolve template (handles GitHub URLs by cloning)
600- // For custom templates, --branch can override the URL's branch
601- // For default appkit template, pass gitRef directly (supports branches with "/" in name)
602- branchForClone := opts .branch
603- subdirForClone := ""
604- if usingDefaultTemplate {
605- branchForClone = gitRef
606- subdirForClone = appkitTemplateDir
607- }
608- resolvedPath , cleanup , err := resolveTemplate (ctx , templateSrc , branchForClone , subdirForClone )
749+ // Step 2: Wait for template (may already be done if the user took time typing the name)
750+ resolvedPath , cleanup , err := awaitTemplate (ctx , templateCh )
609751 if err != nil {
610752 return err
611753 }
@@ -623,6 +765,11 @@ func runCreate(ctx context.Context, opts createOptions) error {
623765 }
624766 }
625767
768+ // Start npm install in the background so it runs while the user answers prompts.
769+ // This is a Node.js-only optimisation — non-Node templates skip this.
770+ srcProjectDir := findProjectSrcDir (templateDir )
771+ npmInstallCh := startBackgroundNpmInstall (ctx , srcProjectDir , destDir , opts .name )
772+
626773 // Step 3: Load manifest from template (optional — templates without it skip plugin/resource logic)
627774 var m * manifest.Manifest
628775 if manifest .HasManifest (templateDir ) {
@@ -737,12 +884,12 @@ func runCreate(ctx context.Context, opts createOptions) error {
737884 }
738885 }
739886
740- // Track whether we started creating the project for cleanup on failure
887+ // Track whether we started creating the project for cleanup on failure.
888+ // The background npm install may have created destDir early.
741889 var projectCreated bool
742890 var runErr error
743891 defer func () {
744- if runErr != nil && projectCreated {
745- // Clean up partially created project on failure
892+ if runErr != nil && (projectCreated || npmInstallCh != nil ) {
746893 os .RemoveAll (destDir )
747894 }
748895 }()
@@ -826,7 +973,17 @@ func runCreate(ctx context.Context, opts createOptions) error {
826973 absOutputDir = destDir
827974 }
828975
829- // Initialize project based on type (Node.js, Python, etc.)
976+ // Await background npm install (started before prompts to overlap with user interaction).
977+ // If it finishes before this point, the checkmark appears instantly.
978+ if npmInstallCh != nil {
979+ if err := awaitBackgroundNpmInstall (ctx , npmInstallCh ); err != nil {
980+ log .Warnf (ctx , "Background npm install failed: %v, will retry during project initialization" , err )
981+ }
982+ }
983+
984+ // Initialize project based on type (Node.js, Python, etc.).
985+ // For Node.js, if the background install succeeded node_modules exists
986+ // and the initializer skips the redundant install step.
830987 var nextStepsCmd string
831988 projectInitializer := initializer .GetProjectInitializer (absOutputDir )
832989 if projectInitializer != nil {
@@ -892,10 +1049,11 @@ func runCreate(ctx context.Context, opts createOptions) error {
8921049
8931050 if shouldDeploy {
8941051 cmdio .LogString (ctx , "" )
895- cmdio .LogString (ctx , "Deploying app..." )
8961052 if err := runPostCreateDeploy (ctx , profile ); err != nil {
8971053 cmdio .LogString (ctx , fmt .Sprintf ("⚠ Deploy failed: %v" , err ))
8981054 cmdio .LogString (ctx , " You can deploy manually with: databricks apps deploy" )
1055+ } else {
1056+ prompt .PrintDone (ctx , "Deploy complete" )
8991057 }
9001058 }
9011059
0 commit comments