From cef90e56a2dddf361d2d1ffc3ede7c5b85cdc5b7 Mon Sep 17 00:00:00 2001 From: Pawel Kosiec Date: Thu, 19 Feb 2026 10:35:44 +0100 Subject: [PATCH 1/2] Read plugin-owned paths from manifest during the `apps init` --- cmd/apps/init.go | 14 +++----- cmd/apps/init_test.go | 53 +++++++++++++++++++++++++++++ libs/apps/manifest/manifest.go | 13 +++++++ libs/apps/manifest/manifest_test.go | 28 +++++++++++++++ 4 files changed, 98 insertions(+), 10 deletions(-) diff --git a/cmd/apps/init.go b/cmd/apps/init.go index 1d5c30efb5..230b8101ca 100644 --- a/cmd/apps/init.go +++ b/cmd/apps/init.go @@ -794,7 +794,7 @@ func runCreate(ctx context.Context, opts createOptions) error { // Apply plugin-specific post-processing (e.g., remove config/queries if analytics not selected) runErr = prompt.RunWithSpinnerCtx(ctx, "Configuring plugins...", func() error { - return applyPlugins(absOutputDir, selectedPlugins) + return applyPlugins(absOutputDir, selectedPlugins, m.GetTemplatePaths()) }) if runErr != nil { return runErr @@ -946,20 +946,14 @@ func buildPluginStrings(pluginNames []string) (pluginImport, pluginUsage string) return pluginImport, pluginUsage } -// pluginOwnedPaths maps plugin names to directories they own. -// When a plugin is not selected, its owned paths are removed from the project. -var pluginOwnedPaths = map[string][]string{ - "analytics": {"config/queries"}, -} - -// applyPlugins removes directories owned by unselected plugins. -func applyPlugins(projectDir string, pluginNames []string) error { +// applyPlugins removes template directories owned by unselected plugins. +func applyPlugins(projectDir string, pluginNames []string, templatePaths map[string][]string) error { selectedSet := make(map[string]bool) for _, name := range pluginNames { selectedSet[name] = true } - for plugin, paths := range pluginOwnedPaths { + for plugin, paths := range templatePaths { if selectedSet[plugin] { continue } diff --git a/cmd/apps/init_test.go b/cmd/apps/init_test.go index 703e28a326..049fe2f252 100644 --- a/cmd/apps/init_test.go +++ b/cmd/apps/init_test.go @@ -458,6 +458,59 @@ func TestAppendUniqueNoValues(t *testing.T) { assert.Equal(t, []string{"a", "b"}, result) } +func TestApplyPlugins(t *testing.T) { + tests := []struct { + name string + selected []string + templatePaths map[string][]string + expectRemoved []string + expectKept []string + }{ + { + name: "unselected plugin directory is removed", + selected: []string{"server"}, + templatePaths: map[string][]string{"analytics": {"config/queries"}}, + expectRemoved: []string{"config/queries"}, + }, + { + name: "selected plugin directory is kept", + selected: []string{"analytics", "server"}, + templatePaths: map[string][]string{"analytics": {"config/queries"}}, + expectKept: []string{"config/queries"}, + }, + { + name: "empty templatePaths is a no-op", + selected: []string{"server"}, + templatePaths: map[string][]string{}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + + // Create all directories referenced in templatePaths + for _, paths := range tc.templatePaths { + for _, p := range paths { + require.NoError(t, os.MkdirAll(filepath.Join(dir, p), 0o755)) + } + } + + err := applyPlugins(dir, tc.selected, tc.templatePaths) + require.NoError(t, err) + + for _, p := range tc.expectRemoved { + _, statErr := os.Stat(filepath.Join(dir, p)) + assert.True(t, os.IsNotExist(statErr), "expected %s to be removed", p) + } + for _, p := range tc.expectKept { + _, statErr := os.Stat(filepath.Join(dir, p)) + assert.NoError(t, statErr, "expected %s to exist", p) + } + }) + } +} + func TestRunManifestOnlyFound(t *testing.T) { dir := t.TempDir() manifestPath := filepath.Join(dir, manifest.ManifestFileName) diff --git a/libs/apps/manifest/manifest.go b/libs/apps/manifest/manifest.go index b0eccebc9d..3b106b638f 100644 --- a/libs/apps/manifest/manifest.go +++ b/libs/apps/manifest/manifest.go @@ -67,6 +67,7 @@ type Plugin struct { Description string `json:"description"` Package string `json:"package"` RequiredByTemplate bool `json:"requiredByTemplate"` + TemplatePaths []string `json:"templatePaths,omitempty"` Resources Resources `json:"resources"` } @@ -205,6 +206,18 @@ func (m *Manifest) CollectResources(pluginNames []string) []Resource { return resources } +// GetTemplatePaths returns a map of plugin name to template directory paths. +// Only plugins that declare at least one path are included. +func (m *Manifest) GetTemplatePaths() map[string][]string { + result := make(map[string][]string) + for name, p := range m.Plugins { + if len(p.TemplatePaths) > 0 { + result[name] = p.TemplatePaths + } + } + return result +} + // CollectOptionalResources returns all optional resources for the given plugin names. func (m *Manifest) CollectOptionalResources(pluginNames []string) []Resource { seen := make(map[string]bool) diff --git a/libs/apps/manifest/manifest_test.go b/libs/apps/manifest/manifest_test.go index 5a1c4f8212..bb1279f797 100644 --- a/libs/apps/manifest/manifest_test.go +++ b/libs/apps/manifest/manifest_test.go @@ -320,6 +320,34 @@ func TestResourceKey(t *testing.T) { assert.Equal(t, "sql_warehouse", r.VarPrefix()) } +func TestGetTemplatePaths(t *testing.T) { + m := &manifest.Manifest{ + Plugins: map[string]manifest.Plugin{ + "analytics": { + Name: "analytics", + TemplatePaths: []string{"config/queries"}, + }, + "server": { + Name: "server", + }, + }, + } + + paths := m.GetTemplatePaths() + assert.Equal(t, map[string][]string{"analytics": {"config/queries"}}, paths) +} + +func TestGetTemplatePathsEmpty(t *testing.T) { + m := &manifest.Manifest{ + Plugins: map[string]manifest.Plugin{ + "server": {Name: "server"}, + }, + } + + paths := m.GetTemplatePaths() + assert.Empty(t, paths) +} + func TestCollectOptionalResources(t *testing.T) { m := &manifest.Manifest{ Plugins: map[string]manifest.Plugin{ From be69f4304b68bbdccb61dd61a48a9a598480dc0a Mon Sep 17 00:00:00 2001 From: Pawel Kosiec Date: Thu, 19 Feb 2026 14:07:15 +0100 Subject: [PATCH 2/2] Run an optional "postinit" command in a template directory --- cmd/apps/init.go | 30 --------------- cmd/apps/init_test.go | 53 -------------------------- libs/apps/initializer/nodejs.go | 40 +++++++++++++++++++ libs/apps/initializer/nodejs_test.go | 57 ++++++++++++++++++++++++++++ libs/apps/manifest/manifest.go | 13 ------- libs/apps/manifest/manifest_test.go | 28 -------------- 6 files changed, 97 insertions(+), 124 deletions(-) diff --git a/cmd/apps/init.go b/cmd/apps/init.go index 230b8101ca..091de6ac00 100644 --- a/cmd/apps/init.go +++ b/cmd/apps/init.go @@ -792,14 +792,6 @@ func runCreate(ctx context.Context, opts createOptions) error { absOutputDir = destDir } - // Apply plugin-specific post-processing (e.g., remove config/queries if analytics not selected) - runErr = prompt.RunWithSpinnerCtx(ctx, "Configuring plugins...", func() error { - return applyPlugins(absOutputDir, selectedPlugins, m.GetTemplatePaths()) - }) - if runErr != nil { - return runErr - } - // Initialize project based on type (Node.js, Python, etc.) var nextStepsCmd string projectInitializer := initializer.GetProjectInitializer(absOutputDir) @@ -946,28 +938,6 @@ func buildPluginStrings(pluginNames []string) (pluginImport, pluginUsage string) return pluginImport, pluginUsage } -// applyPlugins removes template directories owned by unselected plugins. -func applyPlugins(projectDir string, pluginNames []string, templatePaths map[string][]string) error { - selectedSet := make(map[string]bool) - for _, name := range pluginNames { - selectedSet[name] = true - } - - for plugin, paths := range templatePaths { - if selectedSet[plugin] { - continue - } - for _, p := range paths { - target := filepath.Join(projectDir, p) - if err := os.RemoveAll(target); err != nil && !os.IsNotExist(err) { - return err - } - } - } - - return nil -} - // renameFiles maps source file names to destination names (for files that can't use special chars). var renameFiles = map[string]string{ "_gitignore": ".gitignore", diff --git a/cmd/apps/init_test.go b/cmd/apps/init_test.go index 049fe2f252..703e28a326 100644 --- a/cmd/apps/init_test.go +++ b/cmd/apps/init_test.go @@ -458,59 +458,6 @@ func TestAppendUniqueNoValues(t *testing.T) { assert.Equal(t, []string{"a", "b"}, result) } -func TestApplyPlugins(t *testing.T) { - tests := []struct { - name string - selected []string - templatePaths map[string][]string - expectRemoved []string - expectKept []string - }{ - { - name: "unselected plugin directory is removed", - selected: []string{"server"}, - templatePaths: map[string][]string{"analytics": {"config/queries"}}, - expectRemoved: []string{"config/queries"}, - }, - { - name: "selected plugin directory is kept", - selected: []string{"analytics", "server"}, - templatePaths: map[string][]string{"analytics": {"config/queries"}}, - expectKept: []string{"config/queries"}, - }, - { - name: "empty templatePaths is a no-op", - selected: []string{"server"}, - templatePaths: map[string][]string{}, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - dir := t.TempDir() - - // Create all directories referenced in templatePaths - for _, paths := range tc.templatePaths { - for _, p := range paths { - require.NoError(t, os.MkdirAll(filepath.Join(dir, p), 0o755)) - } - } - - err := applyPlugins(dir, tc.selected, tc.templatePaths) - require.NoError(t, err) - - for _, p := range tc.expectRemoved { - _, statErr := os.Stat(filepath.Join(dir, p)) - assert.True(t, os.IsNotExist(statErr), "expected %s to be removed", p) - } - for _, p := range tc.expectKept { - _, statErr := os.Stat(filepath.Join(dir, p)) - assert.NoError(t, statErr, "expected %s to exist", p) - } - }) - } -} - func TestRunManifestOnlyFound(t *testing.T) { dir := t.TempDir() manifestPath := filepath.Join(dir, manifest.ManifestFileName) diff --git a/libs/apps/initializer/nodejs.go b/libs/apps/initializer/nodejs.go index 1e96f43f72..b30db46caa 100644 --- a/libs/apps/initializer/nodejs.go +++ b/libs/apps/initializer/nodejs.go @@ -40,6 +40,9 @@ func (i *InitializerNodeJs) Initialize(ctx context.Context, workDir string) *Ini } } + // Step 3: Run postinit script if defined (fully optional — errors are logged, not fatal) + i.runNpmPostInit(ctx, workDir) + return &InitResult{ Success: true, Message: "Node.js project initialized successfully", @@ -102,6 +105,43 @@ func (i *InitializerNodeJs) runAppkitSetup(ctx context.Context, workDir string) }) } +// runNpmPostInit runs "npm run postinit" if the script is defined in package.json. +// Failures are logged as warnings and never propagate — postinit is fully optional. +func (i *InitializerNodeJs) runNpmPostInit(ctx context.Context, workDir string) { + if !i.hasNpmScript(workDir, "postinit") { + return + } + err := prompt.RunWithSpinnerCtx(ctx, "Running post-init...", func() error { + cmd := exec.CommandContext(ctx, "npm", "run", "postinit") + cmd.Dir = workDir + cmd.Stdout = nil + cmd.Stderr = nil + return cmd.Run() + }) + if err != nil { + log.Debugf(ctx, "postinit script failed (non-fatal): %v", err) + } +} + +// hasNpmScript reports whether the given script name is defined in the project's package.json. +func (i *InitializerNodeJs) hasNpmScript(workDir, script string) bool { + packageJSONPath := filepath.Join(workDir, "package.json") + data, err := os.ReadFile(packageJSONPath) + if err != nil { + return false + } + + var pkg struct { + Scripts map[string]string `json:"scripts"` + } + if err := json.Unmarshal(data, &pkg); err != nil { + return false + } + + _, ok := pkg.Scripts[script] + return ok +} + // hasAppkit checks if the project has @databricks/appkit in its dependencies. func (i *InitializerNodeJs) hasAppkit(workDir string) bool { packageJSONPath := filepath.Join(workDir, "package.json") diff --git a/libs/apps/initializer/nodejs_test.go b/libs/apps/initializer/nodejs_test.go index eb9095453f..c390517b76 100644 --- a/libs/apps/initializer/nodejs_test.go +++ b/libs/apps/initializer/nodejs_test.go @@ -65,3 +65,60 @@ func TestHasAppkitNoPackageJSON(t *testing.T) { init := &InitializerNodeJs{} assert.False(t, init.hasAppkit(tmpDir)) } + +func TestHasNpmScript(t *testing.T) { + tests := []struct { + name string + packageJSON string + script string + want bool + }{ + { + name: "script present", + packageJSON: `{"scripts": {"postinit": "appkit postinit"}}`, + script: "postinit", + want: true, + }, + { + name: "script absent", + packageJSON: `{"scripts": {"build": "tsc"}}`, + script: "postinit", + want: false, + }, + { + name: "no scripts section", + packageJSON: `{}`, + script: "postinit", + want: false, + }, + { + name: "invalid json", + packageJSON: `not json`, + script: "postinit", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "nodejs-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + err = os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(tt.packageJSON), 0o644) + require.NoError(t, err) + + i := &InitializerNodeJs{} + assert.Equal(t, tt.want, i.hasNpmScript(tmpDir, tt.script)) + }) + } +} + +func TestHasNpmScriptNoPackageJSON(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "nodejs-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + i := &InitializerNodeJs{} + assert.False(t, i.hasNpmScript(tmpDir, "postinit")) +} diff --git a/libs/apps/manifest/manifest.go b/libs/apps/manifest/manifest.go index 3b106b638f..b0eccebc9d 100644 --- a/libs/apps/manifest/manifest.go +++ b/libs/apps/manifest/manifest.go @@ -67,7 +67,6 @@ type Plugin struct { Description string `json:"description"` Package string `json:"package"` RequiredByTemplate bool `json:"requiredByTemplate"` - TemplatePaths []string `json:"templatePaths,omitempty"` Resources Resources `json:"resources"` } @@ -206,18 +205,6 @@ func (m *Manifest) CollectResources(pluginNames []string) []Resource { return resources } -// GetTemplatePaths returns a map of plugin name to template directory paths. -// Only plugins that declare at least one path are included. -func (m *Manifest) GetTemplatePaths() map[string][]string { - result := make(map[string][]string) - for name, p := range m.Plugins { - if len(p.TemplatePaths) > 0 { - result[name] = p.TemplatePaths - } - } - return result -} - // CollectOptionalResources returns all optional resources for the given plugin names. func (m *Manifest) CollectOptionalResources(pluginNames []string) []Resource { seen := make(map[string]bool) diff --git a/libs/apps/manifest/manifest_test.go b/libs/apps/manifest/manifest_test.go index bb1279f797..5a1c4f8212 100644 --- a/libs/apps/manifest/manifest_test.go +++ b/libs/apps/manifest/manifest_test.go @@ -320,34 +320,6 @@ func TestResourceKey(t *testing.T) { assert.Equal(t, "sql_warehouse", r.VarPrefix()) } -func TestGetTemplatePaths(t *testing.T) { - m := &manifest.Manifest{ - Plugins: map[string]manifest.Plugin{ - "analytics": { - Name: "analytics", - TemplatePaths: []string{"config/queries"}, - }, - "server": { - Name: "server", - }, - }, - } - - paths := m.GetTemplatePaths() - assert.Equal(t, map[string][]string{"analytics": {"config/queries"}}, paths) -} - -func TestGetTemplatePathsEmpty(t *testing.T) { - m := &manifest.Manifest{ - Plugins: map[string]manifest.Plugin{ - "server": {Name: "server"}, - }, - } - - paths := m.GetTemplatePaths() - assert.Empty(t, paths) -} - func TestCollectOptionalResources(t *testing.T) { m := &manifest.Manifest{ Plugins: map[string]manifest.Plugin{