Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions cmd/apps/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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))
}

Expand Down Expand Up @@ -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
Expand Down
162 changes: 162 additions & 0 deletions cmd/apps/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"

"github.com/databricks/cli/libs/apps/manifest"
Expand Down Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions libs/apps/manifest/manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
40 changes: 37 additions & 3 deletions libs/apps/manifest/manifest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
{
Expand All @@ -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": [],
Expand All @@ -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) {
Expand Down
23 changes: 23 additions & 0 deletions libs/apps/prompt/prompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
Expand Down
22 changes: 22 additions & 0 deletions libs/apps/prompt/prompt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
}
}
Loading