Skip to content

Commit bcc9026

Browse files
committed
feat: apps init speed improvements
1 parent c8f42ce commit bcc9026

10 files changed

Lines changed: 933 additions & 119 deletions

File tree

cmd/apps/deploy_bundle.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/databricks/cli/bundle/run"
1313
"github.com/databricks/cli/cmd/bundle/utils"
1414
"github.com/databricks/cli/cmd/root"
15+
"github.com/databricks/cli/libs/apps/prompt"
1516
"github.com/databricks/cli/libs/apps/validation"
1617
"github.com/databricks/cli/libs/cmdio"
1718
"github.com/databricks/cli/libs/log"
@@ -153,11 +154,11 @@ func runBundleDeploy(cmd *cobra.Command, force, skipValidation, skipTests bool)
153154

154155
log.Infof(ctx, "Running app: %s", appKey)
155156
if err := runBundleApp(ctx, b, appKey); err != nil {
156-
cmdio.LogString(ctx, "Deployment succeeded, but failed to start app")
157+
prompt.PrintDone(ctx, "Deployment succeeded, but failed to start app")
157158
return fmt.Errorf("failed to run app: %w. Run `databricks apps logs` to view logs", err)
158159
}
159160

160-
cmdio.LogString(ctx, "Deployment complete!")
161+
prompt.PrintDone(ctx, "Deployment complete")
161162
return nil
162163
}
163164

cmd/apps/import.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"github.com/databricks/cli/cmd/root"
2323
"github.com/databricks/cli/libs/cmdctx"
2424
"github.com/databricks/cli/libs/cmdio"
25+
"github.com/databricks/cli/libs/apps/prompt"
2526
"github.com/databricks/cli/libs/dyn"
2627
"github.com/databricks/cli/libs/dyn/convert"
2728
"github.com/databricks/cli/libs/dyn/yamlsaver"
@@ -202,9 +203,10 @@ Examples:
202203
}
203204

204205
if !quiet {
205-
cmdio.LogString(ctx, fmt.Sprintf("\n✓ App '%s' has been successfully imported to %s", name, outputDir))
206+
cmdio.LogString(ctx, "")
207+
prompt.PrintDone(ctx, fmt.Sprintf("App '%s' imported to %s", name, outputDir))
206208
if cleanup && oldSourceCodePath != "" {
207-
cmdio.LogString(ctx, "Previous app folder has been cleaned up")
209+
prompt.PrintDone(ctx, "Previous app folder cleaned up")
208210
}
209211
cmdio.LogString(ctx, "\nYou can now deploy changes with: databricks bundle deploy")
210212
}

cmd/apps/init.go

Lines changed: 203 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -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

532677
func 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

Comments
 (0)