@@ -31,6 +31,7 @@ import (
3131 "github.com/databricks/cli/libs/log"
3232 ignore "github.com/sabhiram/go-gitignore"
3333 "github.com/spf13/cobra"
34+ "go.yaml.in/yaml/v3"
3435)
3536
3637const (
@@ -40,6 +41,13 @@ const (
4041 appkitDefaultBranch = "main"
4142 appkitTemplateTagPfx = "template-v"
4243 defaultProfile = "DEFAULT"
44+
45+ // projectNamePlaceholder is the Go template variable used in template
46+ // directory names to stand in for the user-provided project name.
47+ projectNamePlaceholder = "{{.project_name}}"
48+
49+ // bundleConfigFile is the standard bundle configuration filename.
50+ bundleConfigFile = "databricks.yml"
4351)
4452
4553// normalizeVersion converts a version string to the template tag format "template-vX.X.X".
@@ -649,11 +657,11 @@ func isPreRenderedTemplate(templateDir string) bool {
649657 return false
650658 }
651659 for _ , e := range entries {
652- if e .IsDir () && strings .Contains (e .Name (), "{{.project_name}}" ) {
660+ if e .IsDir () && strings .Contains (e .Name (), projectNamePlaceholder ) {
653661 return false
654662 }
655663 }
656- data , err := os .ReadFile (filepath .Join (templateDir , "databricks.yml" ))
664+ data , err := os .ReadFile (filepath .Join (templateDir , bundleConfigFile ))
657665 if err != nil {
658666 // No databricks.yml — can't tell, assume normal template.
659667 return false
@@ -662,8 +670,8 @@ func isPreRenderedTemplate(templateDir string) bool {
662670}
663671
664672// replaceProjectName updates the project name in key files after copying a
665- // pre-rendered template. It reads the original bundle name from
666- // databricks.yml and replaces it with newName in databricks.yml and
673+ // pre-rendered template. It sets bundle. name and the first
674+ // resources.apps.*.name in databricks.yml, and the name field in
667675// package.json.
668676func replaceProjectName (destDir , newName string ) error {
669677 // Update package.json name field via JSON round-trip.
@@ -681,34 +689,87 @@ func replaceProjectName(destDir, newName string) error {
681689 }
682690 }
683691
684- // Update databricks.yml: bundle name and app name .
685- ymlPath := filepath .Join (destDir , "databricks.yml" )
692+ // Update databricks.yml using yaml.Node to preserve comments and formatting .
693+ ymlPath := filepath .Join (destDir , bundleConfigFile )
686694 data , err := os .ReadFile (ymlPath )
687695 if err != nil {
688- return nil
696+ return err
689697 }
690- content := string (data )
691698
692- // Read the original bundle name so we can do a targeted replace of the
693- // app resource name that usually matches it.
694- origName := ""
695- for _ , line := range strings .Split (content , "\n " ) {
696- trimmed := strings .TrimSpace (line )
697- if strings .HasPrefix (trimmed , "name:" ) && origName == "" {
698- origName = strings .TrimSpace (strings .TrimPrefix (trimmed , "name:" ))
699- origName = strings .Trim (origName , "\" '" )
700- break
699+ var doc yaml.Node
700+ if err := yaml .Unmarshal (data , & doc ); err != nil {
701+ return fmt .Errorf ("parse %s: %w" , bundleConfigFile , err )
702+ }
703+
704+ setYAMLValue (& doc , []string {"bundle" , "name" }, newName )
705+ setFirstAppName (& doc , newName )
706+
707+ var buf bytes.Buffer
708+ enc := yaml .NewEncoder (& buf )
709+ enc .SetIndent (2 )
710+ if err := enc .Encode (& doc ); err != nil {
711+ return fmt .Errorf ("encode %s: %w" , bundleConfigFile , err )
712+ }
713+ enc .Close ()
714+
715+ return os .WriteFile (ymlPath , buf .Bytes (), 0o644 )
716+ }
717+
718+ // setYAMLValue walks a yaml.Node tree following the given key path and
719+ // replaces the leaf scalar value. It handles both document and mapping nodes.
720+ func setYAMLValue (node * yaml.Node , keys []string , value string ) {
721+ if node .Kind == yaml .DocumentNode && len (node .Content ) > 0 {
722+ setYAMLValue (node .Content [0 ], keys , value )
723+ return
724+ }
725+ if node .Kind != yaml .MappingNode || len (keys ) == 0 {
726+ return
727+ }
728+ for i := 0 ; i < len (node .Content )- 1 ; i += 2 {
729+ if node .Content [i ].Value == keys [0 ] {
730+ if len (keys ) == 1 {
731+ node .Content [i + 1 ].Value = value
732+ // Clear any explicit style so the encoder picks the
733+ // simplest representation for the new value.
734+ node .Content [i + 1 ].Style = 0
735+ return
736+ }
737+ setYAMLValue (node .Content [i + 1 ], keys [1 :], value )
738+ return
701739 }
702740 }
741+ }
703742
704- // Replace the bundle name line (first occurrence of "name:" under "bundle:").
705- if origName != "" {
706- content = strings .Replace (content , "name: " + origName , "name: " + newName , 1 )
707- // Replace the quoted app name in the resources section.
708- content = strings .Replace (content , "name: \" " + origName + "\" " , "name: \" " + newName + "\" " , 1 )
743+ // setFirstAppName sets the name field of the first app entry under
744+ // resources.apps in a yaml.Node tree.
745+ func setFirstAppName (node * yaml.Node , name string ) {
746+ if node .Kind == yaml .DocumentNode && len (node .Content ) > 0 {
747+ setFirstAppName (node .Content [0 ], name )
748+ return
749+ }
750+ if node .Kind != yaml .MappingNode {
751+ return
709752 }
753+ // Find resources → apps → first entry → name.
754+ appsNode := yamlMapLookup (yamlMapLookup (node , "resources" ), "apps" )
755+ if appsNode == nil || appsNode .Kind != yaml .MappingNode || len (appsNode .Content ) < 2 {
756+ return
757+ }
758+ // First app entry is at Content[1] (Content[0] is the key).
759+ setYAMLValue (appsNode .Content [1 ], []string {"name" }, name )
760+ }
710761
711- return os .WriteFile (ymlPath , []byte (content ), 0o644 )
762+ // yamlMapLookup returns the value node for a key in a mapping node, or nil.
763+ func yamlMapLookup (node * yaml.Node , key string ) * yaml.Node {
764+ if node == nil || node .Kind != yaml .MappingNode {
765+ return nil
766+ }
767+ for i := 0 ; i < len (node .Content )- 1 ; i += 2 {
768+ if node .Content [i ].Value == key {
769+ return node .Content [i + 1 ]
770+ }
771+ }
772+ return nil
712773}
713774
714775// findProjectSrcDir locates the actual source directory inside a template.
@@ -719,7 +780,7 @@ func findProjectSrcDir(templateDir string) string {
719780 return templateDir
720781 }
721782 for _ , e := range entries {
722- if e .IsDir () && strings .Contains (e .Name (), "{{.project_name}}" ) {
783+ if e .IsDir () && strings .Contains (e .Name (), projectNamePlaceholder ) {
723784 return filepath .Join (templateDir , e .Name ())
724785 }
725786 }
@@ -1170,9 +1231,9 @@ func runCreate(ctx context.Context, opts createOptions) error {
11701231 }
11711232 for _ , p := range opts .plugins {
11721233 if ! mandatory [p ] {
1173- log .Warnf (ctx , "Feature %q is not supported by this pre-rendered template and will be ignored. " +
1174- " Only .env will include its configuration. " +
1175- " To use all features dynamically, use the default AppKit template instead ." , p )
1234+ log .Warnf (ctx , "Adding feature %q to a pre-rendered template is not currently supported. \n " +
1235+ "To add it manually, register the plugin in server/server.ts and run `npx @databricks/appkit plugin sync --write`. \n " +
1236+ "To use all features dynamically, run `databricks apps init` without --template ." , p )
11761237 }
11771238 }
11781239 }
@@ -1532,7 +1593,7 @@ func copyTemplate(ctx context.Context, src, dest string, vars templateVars) (int
15321593 return 0 , err
15331594 }
15341595 for _ , e := range entries {
1535- if e .IsDir () && strings .Contains (e .Name (), "{{.project_name}}" ) {
1596+ if e .IsDir () && strings .Contains (e .Name (), projectNamePlaceholder ) {
15361597 srcProjectDir = filepath .Join (src , e .Name ())
15371598 break
15381599 }
0 commit comments