From 1614cb345edfc1fba992b2e39f35853039e3ed99 Mon Sep 17 00:00:00 2001 From: Arseny Kravchenko Date: Fri, 30 Jan 2026 12:08:26 +0100 Subject: [PATCH 1/6] Add --version flag to apps init for version pinning --- cmd/apps/init.go | 90 ++++++++++++++++++++++++++++++++++++++++--- cmd/apps/init_test.go | 19 +++++++++ 2 files changed, 103 insertions(+), 6 deletions(-) diff --git a/cmd/apps/init.go b/cmd/apps/init.go index 12e22146b8..7cb0585527 100644 --- a/cmd/apps/init.go +++ b/cmd/apps/init.go @@ -24,14 +24,41 @@ import ( ) const ( - templatePathEnvVar = "DATABRICKS_APPKIT_TEMPLATE_PATH" - defaultTemplateURL = "https://github.com/databricks/appkit/tree/main/template" + templatePathEnvVar = "DATABRICKS_APPKIT_TEMPLATE_PATH" + appkitRepoURL = "https://github.com/databricks/appkit" + appkitTemplateDir = "template" + appkitDefaultBranch = "main" + appkitRepoOwner = "databricks" + appkitRepoName = "appkit" ) +// fetchLatestRelease fetches the latest release tag from GitHub using gh CLI. +// Returns the tag name (e.g., "v0.1.0") or an error. +func fetchLatestRelease(ctx context.Context) (string, error) { + cmd := exec.CommandContext(ctx, "gh", "release", "view", "--repo", appkitRepoOwner+"/"+appkitRepoName, "--json", "tagName", "-q", ".tagName") + output, err := cmd.Output() + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return "", fmt.Errorf("failed to fetch latest release: %s", string(exitErr.Stderr)) + } + if errors.Is(err, exec.ErrNotFound) { + return "", errors.New("gh CLI not found, please install it or use --version to specify a version") + } + return "", fmt.Errorf("failed to fetch latest release: %w", err) + } + tag := strings.TrimSpace(string(output)) + if tag == "" { + return "", errors.New("no releases found for appkit repository") + } + return tag, nil +} + func newInitCmd() *cobra.Command { var ( templatePath string branch string + version string name string warehouseID string description string @@ -51,10 +78,19 @@ When run without arguments, uses the default AppKit template and an interactive guides you through the setup. When run with --name, runs in non-interactive mode (all required flags must be provided). +By default, the command uses the latest released version of AppKit. Use --version +to specify a different version, or --version latest to use the main branch. + Examples: # Interactive mode with default template (recommended) databricks apps init + # Use a specific AppKit version + databricks apps init --version v0.2.0 + + # Use the latest development version (main branch) + databricks apps init --version latest + # Non-interactive with flags databricks apps init --name my-app @@ -80,9 +116,17 @@ Environment variables: PreRunE: root.MustWorkspaceClient, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() + + // Validate mutual exclusivity of --branch and --version + if cmd.Flags().Changed("branch") && cmd.Flags().Changed("version") { + return errors.New("--branch and --version are mutually exclusive") + } + return runCreate(ctx, createOptions{ templatePath: templatePath, branch: branch, + version: version, + versionChanged: cmd.Flags().Changed("version"), name: name, nameProvided: cmd.Flags().Changed("name"), warehouseID: warehouseID, @@ -99,7 +143,8 @@ Environment variables: } cmd.Flags().StringVar(&templatePath, "template", "", "Template path (local directory or GitHub URL)") - cmd.Flags().StringVar(&branch, "branch", "", "Git branch or tag (for GitHub templates)") + cmd.Flags().StringVar(&branch, "branch", "", "Git branch or tag (for GitHub templates, mutually exclusive with --version)") + cmd.Flags().StringVar(&version, "version", "", "AppKit version to use (default: latest release, use 'latest' for main branch)") cmd.Flags().StringVar(&name, "name", "", "Project name (prompts if not provided)") cmd.Flags().StringVar(&warehouseID, "warehouse-id", "", "SQL warehouse ID") cmd.Flags().StringVar(&description, "description", "", "App description") @@ -114,6 +159,8 @@ Environment variables: type createOptions struct { templatePath string branch string + version string + versionChanged bool // true if --version flag was explicitly set name string nameProvided bool // true if --name flag was explicitly set (enables "flags mode") warehouseID string @@ -427,9 +474,33 @@ func runCreate(ctx context.Context, opts createOptions) error { if templateSrc == "" { templateSrc = os.Getenv(templatePathEnvVar) } + + // Resolve the git reference (branch/tag) to use for default appkit template + gitRef := opts.branch if templateSrc == "" { - // Use default template from GitHub - templateSrc = defaultTemplateURL + // Using default appkit template - resolve version + switch { + case opts.branch != "": + // --branch takes precedence (already set in gitRef) + case opts.version == "latest": + gitRef = appkitDefaultBranch + case opts.version != "": + gitRef = opts.version + default: + // Default: fetch latest release + var tag string + err := prompt.RunWithSpinnerCtx(ctx, "Fetching latest AppKit version...", func() error { + var fetchErr error + tag, fetchErr = fetchLatestRelease(ctx) + return fetchErr + }) + if err != nil { + return err + } + gitRef = tag + log.Infof(ctx, "Using AppKit version %s", tag) + } + templateSrc = fmt.Sprintf("%s/tree/%s/%s", appkitRepoURL, gitRef, appkitTemplateDir) } // Step 1: Get project name first (needed before we can check destination) @@ -465,7 +536,14 @@ func runCreate(ctx context.Context, opts createOptions) error { } // Step 2: Resolve template (handles GitHub URLs by cloning) - resolvedPath, cleanup, err := resolveTemplate(ctx, templateSrc, opts.branch) + // For custom templates, --branch can override the URL's branch + // For default appkit template, gitRef is already embedded in templateSrc + branchOverride := opts.branch + if opts.templatePath == "" && os.Getenv(templatePathEnvVar) == "" { + // Using default appkit - no override needed, gitRef is in URL + branchOverride = "" + } + resolvedPath, cleanup, err := resolveTemplate(ctx, templateSrc, branchOverride) if err != nil { return err } diff --git a/cmd/apps/init_test.go b/cmd/apps/init_test.go index 8805e1ec88..b247d55970 100644 --- a/cmd/apps/init_test.go +++ b/cmd/apps/init_test.go @@ -1,10 +1,13 @@ package apps import ( + "errors" "testing" "github.com/databricks/cli/libs/apps/prompt" + "github.com/spf13/cobra" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestIsTextFile(t *testing.T) { @@ -169,6 +172,22 @@ func TestSubstituteVarsNoPlugins(t *testing.T) { } } +func TestInitCmdBranchAndVersionMutuallyExclusive(t *testing.T) { + cmd := newInitCmd() + cmd.PreRunE = nil // skip workspace client setup for flag validation test + // Replace RunE to only test flag validation, not the full create flow + cmd.RunE = func(cmd *cobra.Command, args []string) error { + if cmd.Flags().Changed("branch") && cmd.Flags().Changed("version") { + return errors.New("--branch and --version are mutually exclusive") + } + return nil + } + cmd.SetArgs([]string{"--branch", "dev", "--version", "v1.0.0"}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "--branch and --version are mutually exclusive") +} + func TestParseDeployAndRunFlags(t *testing.T) { tests := []struct { name string From d926084df748933bab9dabfa85a098c00c681cd4 Mon Sep 17 00:00:00 2001 From: Arseny Kravchenko Date: Tue, 3 Feb 2026 12:00:11 +0100 Subject: [PATCH 2/6] Fix --branch with slashes, fallback when gh missing, normalize version prefix --- cmd/apps/init.go | 60 +++++++++++++++++++++++++++++++------------ cmd/apps/init_test.go | 23 +++++++++++++++++ 2 files changed, 67 insertions(+), 16 deletions(-) diff --git a/cmd/apps/init.go b/cmd/apps/init.go index 7cb0585527..39bdf86654 100644 --- a/cmd/apps/init.go +++ b/cmd/apps/init.go @@ -34,17 +34,19 @@ const ( // fetchLatestRelease fetches the latest release tag from GitHub using gh CLI. // Returns the tag name (e.g., "v0.1.0") or an error. +// If gh CLI is not found or fails, returns empty string (caller should fall back to latest). func fetchLatestRelease(ctx context.Context) (string, error) { cmd := exec.CommandContext(ctx, "gh", "release", "view", "--repo", appkitRepoOwner+"/"+appkitRepoName, "--json", "tagName", "-q", ".tagName") output, err := cmd.Output() if err != nil { + // If gh CLI is not installed, return empty string to signal fallback + if errors.Is(err, exec.ErrNotFound) { + return "", nil + } var exitErr *exec.ExitError if errors.As(err, &exitErr) { return "", fmt.Errorf("failed to fetch latest release: %s", string(exitErr.Stderr)) } - if errors.Is(err, exec.ErrNotFound) { - return "", errors.New("gh CLI not found, please install it or use --version to specify a version") - } return "", fmt.Errorf("failed to fetch latest release: %w", err) } tag := strings.TrimSpace(string(output)) @@ -54,6 +56,19 @@ func fetchLatestRelease(ctx context.Context) (string, error) { return tag, nil } +// normalizeVersion ensures the version string has a "v" prefix if it looks like a semver. +// Examples: "0.3.0" -> "v0.3.0", "v0.3.0" -> "v0.3.0", "latest" -> "latest" +func normalizeVersion(version string) string { + if version == "" || version == "latest" { + return version + } + // If it starts with a digit, prepend "v" + if len(version) > 0 && version[0] >= '0' && version[0] <= '9' { + return "v" + version + } + return version +} + func newInitCmd() *cobra.Command { var ( templatePath string @@ -424,18 +439,23 @@ func cloneRepo(ctx context.Context, repoURL, branch string) (string, error) { } // resolveTemplate resolves a template path, handling both local paths and GitHub URLs. +// branch is used for cloning (can contain "/" for feature branches). +// subdir is an optional subdirectory within the repo to use (for default appkit template). // Returns the local path to use, a cleanup function (for temp dirs), and any error. -func resolveTemplate(ctx context.Context, templatePath, branch string) (localPath string, cleanup func(), err error) { +func resolveTemplate(ctx context.Context, templatePath, branch, subdir string) (localPath string, cleanup func(), err error) { // Case 1: Local path - return as-is if !strings.HasPrefix(templatePath, "https://") { return templatePath, nil, nil } // Case 2: GitHub URL - parse and clone - repoURL, subdir, urlBranch := git.ParseGitHubURL(templatePath) + repoURL, urlSubdir, urlBranch := git.ParseGitHubURL(templatePath) if branch == "" { branch = urlBranch // Use branch from URL if not overridden by flag } + if subdir == "" { + subdir = urlSubdir // Use subdir from URL if not overridden + } // Clone to temp dir with spinner var tempDir string @@ -477,7 +497,8 @@ func runCreate(ctx context.Context, opts createOptions) error { // Resolve the git reference (branch/tag) to use for default appkit template gitRef := opts.branch - if templateSrc == "" { + usingDefaultTemplate := templateSrc == "" + if usingDefaultTemplate { // Using default appkit template - resolve version switch { case opts.branch != "": @@ -485,7 +506,7 @@ func runCreate(ctx context.Context, opts createOptions) error { case opts.version == "latest": gitRef = appkitDefaultBranch case opts.version != "": - gitRef = opts.version + gitRef = normalizeVersion(opts.version) default: // Default: fetch latest release var tag string @@ -497,10 +518,16 @@ func runCreate(ctx context.Context, opts createOptions) error { if err != nil { return err } - gitRef = tag - log.Infof(ctx, "Using AppKit version %s", tag) + if tag == "" { + // gh CLI not found - fall back to main branch + gitRef = appkitDefaultBranch + log.Infof(ctx, "Using AppKit main branch (install gh CLI to use specific versions)") + } else { + gitRef = tag + log.Infof(ctx, "Using AppKit version %s", tag) + } } - templateSrc = fmt.Sprintf("%s/tree/%s/%s", appkitRepoURL, gitRef, appkitTemplateDir) + templateSrc = appkitRepoURL } // Step 1: Get project name first (needed before we can check destination) @@ -537,13 +564,14 @@ func runCreate(ctx context.Context, opts createOptions) error { // Step 2: Resolve template (handles GitHub URLs by cloning) // For custom templates, --branch can override the URL's branch - // For default appkit template, gitRef is already embedded in templateSrc - branchOverride := opts.branch - if opts.templatePath == "" && os.Getenv(templatePathEnvVar) == "" { - // Using default appkit - no override needed, gitRef is in URL - branchOverride = "" + // For default appkit template, pass gitRef directly (supports branches with "/" in name) + branchForClone := opts.branch + subdirForClone := "" + if usingDefaultTemplate { + branchForClone = gitRef + subdirForClone = appkitTemplateDir } - resolvedPath, cleanup, err := resolveTemplate(ctx, templateSrc, branchOverride) + resolvedPath, cleanup, err := resolveTemplate(ctx, templateSrc, branchForClone, subdirForClone) if err != nil { return err } diff --git a/cmd/apps/init_test.go b/cmd/apps/init_test.go index b247d55970..9fa2d6ecac 100644 --- a/cmd/apps/init_test.go +++ b/cmd/apps/init_test.go @@ -188,6 +188,29 @@ func TestInitCmdBranchAndVersionMutuallyExclusive(t *testing.T) { assert.Contains(t, err.Error(), "--branch and --version are mutually exclusive") } +func TestNormalizeVersion(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"0.3.0", "v0.3.0"}, + {"1.0.0", "v1.0.0"}, + {"v0.3.0", "v0.3.0"}, + {"v1.0.0", "v1.0.0"}, + {"latest", "latest"}, + {"", ""}, + {"main", "main"}, + {"feat/something", "feat/something"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := normalizeVersion(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + func TestParseDeployAndRunFlags(t *testing.T) { tests := []struct { name string From e27c0fcd34582671d43dc6479e3c8a560c4d8e16 Mon Sep 17 00:00:00 2001 From: Arseny Kravchenko Date: Tue, 3 Feb 2026 12:55:01 +0100 Subject: [PATCH 3/6] Show warning when gh CLI not found --- cmd/apps/init.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/apps/init.go b/cmd/apps/init.go index 39bdf86654..1fae2475b8 100644 --- a/cmd/apps/init.go +++ b/cmd/apps/init.go @@ -519,9 +519,9 @@ func runCreate(ctx context.Context, opts createOptions) error { return err } if tag == "" { - // gh CLI not found - fall back to main branch + // gh CLI not found - fall back to main branch with warning gitRef = appkitDefaultBranch - log.Infof(ctx, "Using AppKit main branch (install gh CLI to use specific versions)") + cmdio.LogString(ctx, "Warning: gh CLI not found, using main branch. Install gh CLI or use --version to pin to a specific release.") } else { gitRef = tag log.Infof(ctx, "Using AppKit version %s", tag) From cc85ae873191a5e909a852db91863a67e06c56be Mon Sep 17 00:00:00 2001 From: Arseny Kravchenko Date: Tue, 3 Feb 2026 15:40:39 +0100 Subject: [PATCH 4/6] Remove gh CLI dependency, default to main branch --- cmd/apps/init.go | 55 ++++++------------------------------------------ 1 file changed, 7 insertions(+), 48 deletions(-) diff --git a/cmd/apps/init.go b/cmd/apps/init.go index 1fae2475b8..aff4952597 100644 --- a/cmd/apps/init.go +++ b/cmd/apps/init.go @@ -32,36 +32,15 @@ const ( appkitRepoName = "appkit" ) -// fetchLatestRelease fetches the latest release tag from GitHub using gh CLI. -// Returns the tag name (e.g., "v0.1.0") or an error. -// If gh CLI is not found or fails, returns empty string (caller should fall back to latest). -func fetchLatestRelease(ctx context.Context) (string, error) { - cmd := exec.CommandContext(ctx, "gh", "release", "view", "--repo", appkitRepoOwner+"/"+appkitRepoName, "--json", "tagName", "-q", ".tagName") - output, err := cmd.Output() - if err != nil { - // If gh CLI is not installed, return empty string to signal fallback - if errors.Is(err, exec.ErrNotFound) { - return "", nil - } - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { - return "", fmt.Errorf("failed to fetch latest release: %s", string(exitErr.Stderr)) - } - return "", fmt.Errorf("failed to fetch latest release: %w", err) - } - tag := strings.TrimSpace(string(output)) - if tag == "" { - return "", errors.New("no releases found for appkit repository") - } - return tag, nil -} - // normalizeVersion ensures the version string has a "v" prefix if it looks like a semver. -// Examples: "0.3.0" -> "v0.3.0", "v0.3.0" -> "v0.3.0", "latest" -> "latest" +// Examples: "0.3.0" -> "v0.3.0", "v0.3.0" -> "v0.3.0", "latest" -> "main" func normalizeVersion(version string) string { - if version == "" || version == "latest" { + if version == "" { return version } + if version == "latest" { + return appkitDefaultBranch + } // If it starts with a digit, prepend "v" if len(version) > 0 && version[0] >= '0' && version[0] <= '9' { return "v" + version @@ -141,7 +120,6 @@ Environment variables: templatePath: templatePath, branch: branch, version: version, - versionChanged: cmd.Flags().Changed("version"), name: name, nameProvided: cmd.Flags().Changed("name"), warehouseID: warehouseID, @@ -175,7 +153,6 @@ type createOptions struct { templatePath string branch string version string - versionChanged bool // true if --version flag was explicitly set name string nameProvided bool // true if --name flag was explicitly set (enables "flags mode") warehouseID string @@ -503,29 +480,11 @@ func runCreate(ctx context.Context, opts createOptions) error { switch { case opts.branch != "": // --branch takes precedence (already set in gitRef) - case opts.version == "latest": - gitRef = appkitDefaultBranch case opts.version != "": gitRef = normalizeVersion(opts.version) default: - // Default: fetch latest release - var tag string - err := prompt.RunWithSpinnerCtx(ctx, "Fetching latest AppKit version...", func() error { - var fetchErr error - tag, fetchErr = fetchLatestRelease(ctx) - return fetchErr - }) - if err != nil { - return err - } - if tag == "" { - // gh CLI not found - fall back to main branch with warning - gitRef = appkitDefaultBranch - cmdio.LogString(ctx, "Warning: gh CLI not found, using main branch. Install gh CLI or use --version to pin to a specific release.") - } else { - gitRef = tag - log.Infof(ctx, "Using AppKit version %s", tag) - } + // Default: use main branch + gitRef = appkitDefaultBranch } templateSrc = appkitRepoURL } From f15093fad51ca16df2d9cc776abb1f8519b086c8 Mon Sep 17 00:00:00 2001 From: Arseny Kravchenko Date: Tue, 3 Feb 2026 17:23:20 +0100 Subject: [PATCH 5/6] Remove unused constants --- cmd/apps/init.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/cmd/apps/init.go b/cmd/apps/init.go index aff4952597..e815241ce9 100644 --- a/cmd/apps/init.go +++ b/cmd/apps/init.go @@ -28,8 +28,6 @@ const ( appkitRepoURL = "https://github.com/databricks/appkit" appkitTemplateDir = "template" appkitDefaultBranch = "main" - appkitRepoOwner = "databricks" - appkitRepoName = "appkit" ) // normalizeVersion ensures the version string has a "v" prefix if it looks like a semver. From dde1f6a539e2f9a4d664c7e258748cc3b964965e Mon Sep 17 00:00:00 2001 From: Arseny Kravchenko Date: Tue, 3 Feb 2026 18:11:00 +0100 Subject: [PATCH 6/6] Fix TestNormalizeVersion test --- cmd/apps/init_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/apps/init_test.go b/cmd/apps/init_test.go index 9fa2d6ecac..c21447cc16 100644 --- a/cmd/apps/init_test.go +++ b/cmd/apps/init_test.go @@ -197,7 +197,7 @@ func TestNormalizeVersion(t *testing.T) { {"1.0.0", "v1.0.0"}, {"v0.3.0", "v0.3.0"}, {"v1.0.0", "v1.0.0"}, - {"latest", "latest"}, + {"latest", "main"}, {"", ""}, {"main", "main"}, {"feat/something", "feat/something"},