diff --git a/cmd/apps/init.go b/cmd/apps/init.go index 6e6ba2ccee8..6f7269e6fd0 100644 --- a/cmd/apps/init.go +++ b/cmd/apps/init.go @@ -295,9 +295,16 @@ type dotEnvVars struct { Example string } -// pluginVar represents a selected plugin. Currently empty, but extensible -// with properties as the plugin model evolves. -type pluginVar struct{} +// pluginVar represents a selected plugin in template substitution. +// Fields here are part of the AppKit template contract — the template +// reads them via {{$p.Field}} on map values in templateVars.Plugins. +type pluginVar struct { + // Stability mirrors manifest.Plugin.Stability ("" for GA, "beta" + // for beta, future tiers preserved). The AppKit template branches + // imports on this — see databricks/appkit#264 commit d826a532, which + // routes beta plugins through the `@databricks/appkit/beta` subpath. + Stability string +} // templateVars holds the variables for template substitution. type templateVars struct { @@ -357,7 +364,7 @@ func promptForPluginsAndDeps(ctx context.Context, m *manifest.Manifest, preSelec if len(config.Features) == 0 && len(selectablePlugins) > 0 { options := make([]huh.Option[string], 0, len(selectablePlugins)) for _, p := range selectablePlugins { - label := p.DisplayName + " - " + p.Description + label := p.DisplayName + prompt.RenderStabilityTier(p.StabilityLabel()) + " - " + p.Description options = append(options, huh.NewOption(label, p.Name)) } @@ -1036,7 +1043,11 @@ func runCreate(ctx context.Context, opts createOptions) error { plugins := make(map[string]*pluginVar, len(selectedPlugins)) for _, name := range selectedPlugins { - plugins[name] = &pluginVar{} + pv := &pluginVar{} + if mp, ok := m.Plugins[name]; ok { + pv.Stability = mp.Stability + } + plugins[name] = pv } // Template variables with generated content diff --git a/cmd/apps/init_test.go b/cmd/apps/init_test.go index d837474e125..b8a9a8f443c 100644 --- a/cmd/apps/init_test.go +++ b/cmd/apps/init_test.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "testing" "github.com/databricks/cli/libs/apps/manifest" @@ -251,6 +252,167 @@ func TestExecuteTemplateInvalidSyntaxReturnsOriginal(t *testing.T) { assert.Equal(t, input, string(result)) } +// TestExecuteTemplatePluginStability locks down the contract that the +// AppKit init template relies on: ranging over .plugins exposes a +// .Stability field per plugin, with GA/unset rendering as the empty +// string. See databricks/appkit#264 commit d826a532 (server.ts branches +// imports between `@databricks/appkit` and `@databricks/appkit/beta`). +func TestExecuteTemplatePluginStability(t *testing.T) { + ctx := t.Context() + vars := templateVars{ + Plugins: map[string]*pluginVar{ + "ga-plugin": {}, + "beta-plugin": {Stability: "beta"}, + }, + } + + input := `{{range $n, $p := .plugins}}{{$n}}={{$p.Stability}};{{end}}` + result, err := executeTemplate(ctx, "server.ts", []byte(input), vars) + require.NoError(t, err) + got := string(result) + + assert.Contains(t, got, "ga-plugin=;") + assert.Contains(t, got, "beta-plugin=beta;") +} + +// TestExecuteTemplateBetaImportAccumulator pins the full text/template +// pattern used by the AppKit server.ts template (databricks/appkit#264 +// commit 488797fc): a string-accumulator pre-pass over .plugins that +// reassigns an outer-scope variable inside `range` and concatenates +// names via `printf`, then emits a single guarded import line. +// +// If a future refactor of executeTemplate breaks variable reassignment, +// printf, or pointer-field access on map values, this test fails before +// users see broken init output. +func TestExecuteTemplateBetaImportAccumulator(t *testing.T) { + ctx := t.Context() + + // Mirror of the relevant slice of template/server/server.ts in AppKit. + // Kept as a literal string (not loaded from the AppKit repo) so this + // test is hermetic and survives AppKit branch movement. + input := `{{- $betaImports := "" -}} +{{- range $name, $p := .plugins -}} + {{- if eq $p.Stability "beta" -}} + {{- if eq $betaImports "" -}} + {{- $betaImports = $name -}} + {{- else -}} + {{- $betaImports = printf "%s, %s" $betaImports $name -}} + {{- end -}} + {{- end -}} +{{- end -}} +import { createApp{{range $name, $p := .plugins}}{{if ne $p.Stability "beta"}}, {{$name}}{{end}}{{end}} } from '@databricks/appkit'; +{{- if ne $betaImports "" }} +import { {{$betaImports}} } from '@databricks/appkit/beta'; +{{- end}} +` + + cases := []struct { + name string + plugins map[string]*pluginVar + wantGAImports []string // names that must appear on the GA line + wantBetaImports []string // names that must appear on the beta line, "" means no beta line + wantNoBetaLine bool + }{ + { + name: "all GA: no beta line", + plugins: map[string]*pluginVar{ + "server": {}, + "analytics": {}, + }, + wantGAImports: []string{"server", "analytics"}, + wantNoBetaLine: true, + }, + { + name: "mixed single beta", + plugins: map[string]*pluginVar{ + "server": {}, + "betaOne": {Stability: "beta"}, + }, + wantGAImports: []string{"server"}, + wantBetaImports: []string{"betaOne"}, + }, + { + name: "mixed multiple betas: combined into one import line", + plugins: map[string]*pluginVar{ + "server": {}, + "betaOne": {Stability: "beta"}, + "betaTwo": {Stability: "beta"}, + }, + wantGAImports: []string{"server"}, + wantBetaImports: []string{"betaOne", "betaTwo"}, + }, + { + name: "all beta: createApp alone on GA line", + plugins: map[string]*pluginVar{ + "betaOne": {Stability: "beta"}, + "betaTwo": {Stability: "beta"}, + }, + wantBetaImports: []string{"betaOne", "betaTwo"}, + }, + { + name: "future tier (alpha) routes to GA line for now", + plugins: map[string]*pluginVar{ + "server": {}, + "alphaOne": {Stability: "alpha"}, + }, + wantGAImports: []string{"server", "alphaOne"}, + wantNoBetaLine: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + vars := templateVars{Plugins: tc.plugins} + result, err := executeTemplate(ctx, "server.ts", []byte(input), vars) + require.NoError(t, err) + got := string(result) + + lines := strings.Split(got, "\n") + require.NotEmpty(t, lines) + + // GA line is always first: starts with `import { createApp` + // and ends with `from '@databricks/appkit';`. + gaLine := lines[0] + assert.True(t, strings.HasPrefix(gaLine, "import { createApp"), + "GA line: %q", gaLine) + assert.True(t, strings.HasSuffix(gaLine, "} from '@databricks/appkit';"), + "GA line: %q", gaLine) + for _, name := range tc.wantGAImports { + assert.Contains(t, gaLine, name, + "GA line missing %q: %q", name, gaLine) + } + for _, name := range tc.wantBetaImports { + assert.NotContains(t, gaLine, ", "+name, + "beta plugin %q leaked onto GA line: %q", name, gaLine) + } + + if tc.wantNoBetaLine { + assert.NotContains(t, got, "@databricks/appkit/beta", + "unexpected beta import emitted: %q", got) + return + } + + // Beta line: exactly one `from '@databricks/appkit/beta'` line. + betaLineCount := strings.Count(got, "from '@databricks/appkit/beta'") + assert.Equal(t, 1, betaLineCount, + "expected exactly one beta import line, got %d: %q", betaLineCount, got) + + var betaLine string + for _, l := range lines { + if strings.Contains(l, "@databricks/appkit/beta") { + betaLine = l + break + } + } + require.NotEmpty(t, betaLine, "beta line not found in: %q", got) + for _, name := range tc.wantBetaImports { + assert.Contains(t, betaLine, name, + "beta line missing %q: %q", name, betaLine) + } + }) + } +} + func TestInitCmdBranchAndVersionMutuallyExclusive(t *testing.T) { cmd := newInitCmd() cmd.PreRunE = nil // skip workspace client setup for flag validation test diff --git a/libs/apps/manifest/manifest.go b/libs/apps/manifest/manifest.go index 43a98385157..c4ecdd7f82f 100644 --- a/libs/apps/manifest/manifest.go +++ b/libs/apps/manifest/manifest.go @@ -76,6 +76,23 @@ type Plugin struct { RequiredByTemplate bool `json:"requiredByTemplate"` Resources Resources `json:"resources"` OnSetupMessage string `json:"onSetupMessage"` + + // Stability is one of "beta", "ga", or empty. + // Stored as a plain string so unknown future values round-trip unchanged. + // See https://github.com/databricks/appkit/pull/264. + Stability string `json:"stability,omitempty"` +} + +// StabilityLabel returns a user-facing tier label for non-GA plugins. +// Returns "" for GA, unset, or any value that maps to GA. +// Unknown values pass through so we are forward-compatible with new tiers. +func (p Plugin) StabilityLabel() string { + switch p.Stability { + case "", "ga": + return "" + default: + return p.Stability + } } // Manifest represents the appkit.plugins.json file structure. diff --git a/libs/apps/manifest/manifest_test.go b/libs/apps/manifest/manifest_test.go index f3ca3fe129f..db571993b68 100644 --- a/libs/apps/manifest/manifest_test.go +++ b/libs/apps/manifest/manifest_test.go @@ -16,13 +16,14 @@ func TestLoad(t *testing.T) { content := `{ "$schema": "https://databricks.github.io/appkit/schemas/template-plugins.schema.json", - "version": "1.0", + "version": "1.1", "plugins": { "analytics": { "name": "analytics", "displayName": "Analytics Plugin", "description": "SQL query execution", "package": "@databricks/appkit", + "stability": "beta", "resources": { "required": [ { @@ -39,11 +40,23 @@ func TestLoad(t *testing.T) { "optional": [] } }, + "genie": { + "name": "genie", + "displayName": "Genie Plugin", + "description": "Genie space integration", + "package": "@databricks/appkit", + "stability": "alpha", + "resources": { + "required": [], + "optional": [] + } + }, "server": { "name": "server", "displayName": "Server Plugin", "description": "HTTP server", "package": "@databricks/appkit", + "stability": "ga", "requiredByTemplate": true, "resources": { "required": [], @@ -58,10 +71,31 @@ func TestLoad(t *testing.T) { m, err := manifest.Load(dir) require.NoError(t, err) - assert.Equal(t, "1.0", m.Version) - assert.Len(t, m.Plugins, 2) + assert.Equal(t, "1.1", m.Version) + assert.Len(t, m.Plugins, 3) assert.True(t, m.Plugins["server"].RequiredByTemplate) assert.False(t, m.Plugins["analytics"].RequiredByTemplate) + assert.Equal(t, "beta", m.Plugins["analytics"].Stability) + assert.Equal(t, "alpha", m.Plugins["genie"].Stability) + assert.Equal(t, "ga", m.Plugins["server"].Stability) +} + +func TestPlugin_StabilityLabel(t *testing.T) { + tests := []struct { + stability string + want string + }{ + {"", ""}, + {"ga", ""}, + {"beta", "beta"}, + {"alpha", "alpha"}, + } + for _, tc := range tests { + t.Run(tc.stability, func(t *testing.T) { + p := manifest.Plugin{Stability: tc.stability} + assert.Equal(t, tc.want, p.StabilityLabel()) + }) + } } func TestLoadNotFound(t *testing.T) { diff --git a/libs/apps/prompt/prompt.go b/libs/apps/prompt/prompt.go index 1b10f15024a..277aa949e1f 100644 --- a/libs/apps/prompt/prompt.go +++ b/libs/apps/prompt/prompt.go @@ -68,6 +68,29 @@ var ( Bold(true) ) +// Stability tier styles, applied to the parenthetical suffix in plugin labels. +var ( + stabilityBetaStyle = lipgloss.NewStyle().Foreground(colorYellow) + stabilityUnknownStyle = lipgloss.NewStyle().Foreground(colorGray) +) + +// RenderStabilityTier renders a stability tier as a colored " (tier)" suffix, +// or returns "" for GA/unset. Unknown tiers are rendered in gray so we +// remain forward-compatible with future tier names. +func RenderStabilityTier(tier string) string { + if tier == "" { + return "" + } + var style lipgloss.Style + switch tier { + case "beta": + style = stabilityBetaStyle + default: + style = stabilityUnknownStyle + } + return " " + style.Render("("+tier+")") +} + // PrintAnswered prints a completed prompt answer to keep history visible. func PrintAnswered(ctx context.Context, title, value string) { cmdio.LogString(ctx, fmt.Sprintf("%s %s", answeredTitleStyle.Render(title+":"), answeredValueStyle.Render(value))) diff --git a/libs/apps/prompt/prompt_test.go b/libs/apps/prompt/prompt_test.go index 3400eab9bea..01091cf72ce 100644 --- a/libs/apps/prompt/prompt_test.go +++ b/libs/apps/prompt/prompt_test.go @@ -310,3 +310,25 @@ func TestMaxAppNameLength(t *testing.T) { assert.Len(t, invalidName, 27) assert.Error(t, ValidateProjectName(invalidName)) } + +func TestRenderStabilityTier(t *testing.T) { + tests := []struct { + tier string + wantEmpty bool + wantSubstr string + }{ + {"", true, ""}, + {"beta", false, "(beta)"}, + {"alpha", false, "(alpha)"}, + } + for _, tc := range tests { + t.Run(tc.tier, func(t *testing.T) { + got := RenderStabilityTier(tc.tier) + if tc.wantEmpty { + assert.Empty(t, got) + return + } + assert.Contains(t, got, tc.wantSubstr) + }) + } +}