Add TUI add and preview commands

Use charmbracelet/huh for an interactive add flow. Add a preview
subcommand to enter/exit per-project pre-release tags. Move stamp.toml
into .stamp/ and add ConfigPath helper. Prefer --snapshot then project
PreTag when computing versions and promote bumps to pre-release when
appropriate. Export CurrentVersion and add required TUI deps to go.mod.
This commit is contained in:
Thomas
2026-03-12 22:59:20 +01:00
parent fb347eaa54
commit 8049c505a0
12 changed files with 323 additions and 87 deletions

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"path/filepath"
"github.com/charmbracelet/huh"
"github.com/urfave/cli/v3"
"git.thokra.dev/thokra/stamp/internal/changeset"
@@ -101,46 +102,61 @@ func addCmd() *cli.Command {
}
}
// promptAdd runs an interactive prompt to collect add parameters.
// promptAdd runs an interactive TUI prompt to collect add parameters.
func promptAdd(cfg *config.Config) ([]string, changeset.BumpType, string, error) {
projectNames := cfg.ProjectNames()
fmt.Println("Which projects are affected? (enter comma-separated names)")
for _, name := range projectNames {
fmt.Printf(" - %s\n", name)
}
fmt.Print("> ")
var input string
if _, err := fmt.Scanln(&input); err != nil {
return nil, "", "", fmt.Errorf("reading input: %w", err)
}
var projects []string
for _, p := range splitComma(input) {
if cfg.FindProject(p) == nil {
return nil, "", "", fmt.Errorf("project %q not found in stamp.toml", p)
projectOptions := make([]huh.Option[string], len(cfg.Projects))
for i, p := range cfg.Projects {
label := p.Name
if p.PreTag != "" {
label += " (preview: " + p.PreTag + ")"
}
projects = append(projects, p)
}
if len(projects) == 0 {
return nil, "", "", fmt.Errorf("at least one project must be specified")
projectOptions[i] = huh.NewOption(label, p.Name)
}
fmt.Println("Bump type? (major, minor, patch, premajor, preminor, prepatch, prerelease)")
fmt.Print("> ")
var selectedProjects []string
var bumpStr string
if _, err := fmt.Scanln(&bumpStr); err != nil {
return nil, "", "", fmt.Errorf("reading input: %w", err)
}
bumpType := changeset.BumpType(bumpStr)
fmt.Println("Description of the change:")
fmt.Print("> ")
var message string
if _, err := fmt.Scanln(&message); err != nil {
return nil, "", "", fmt.Errorf("reading input: %w", err)
form := huh.NewForm(
huh.NewGroup(
huh.NewMultiSelect[string]().
Title("Which projects are affected?").
Options(projectOptions...).
Validate(func(v []string) error {
if len(v) == 0 {
return fmt.Errorf("select at least one project")
}
return nil
}).
Value(&selectedProjects),
),
huh.NewGroup(
huh.NewSelect[string]().
Title("Bump type").
Options(
huh.NewOption("patch — bug fix (1.2.3 → 1.2.4)", string(changeset.BumpPatch)),
huh.NewOption("minor — new feature (1.2.3 → 1.3.0)", string(changeset.BumpMinor)),
huh.NewOption("major — breaking change (1.2.3 → 2.0.0)", string(changeset.BumpMajor)),
).
Value(&bumpStr),
),
huh.NewGroup(
huh.NewText().
Title("Describe the change").
CharLimit(500).
Validate(func(v string) error {
if len(v) == 0 {
return fmt.Errorf("description is required")
}
return nil
}).
Value(&message),
),
)
if err := form.Run(); err != nil {
return nil, "", "", err
}
return projects, bumpType, message, nil
return selectedProjects, changeset.BumpType(bumpStr), message, nil
}

98
cmd/stamp/cmd_preview.go Normal file
View File

@@ -0,0 +1,98 @@
package main
import (
"context"
"fmt"
"github.com/urfave/cli/v3"
"git.thokra.dev/thokra/stamp/internal/config"
)
func previewCmd() *cli.Command {
return &cli.Command{
Name: "preview",
Usage: "manage pre-release mode for a project",
Commands: []*cli.Command{
{
Name: "enter",
Usage: "put a project into pre-release mode",
ArgsUsage: "<project> <tag>",
Action: func(ctx context.Context, cmd *cli.Command) error {
if cmd.Args().Len() < 2 {
return fmt.Errorf("usage: stamp preview enter <project> <tag>")
}
projectName := cmd.Args().Get(0)
tag := cmd.Args().Get(1)
repoRoot, err := findRepoRoot(".")
if err != nil {
return err
}
cfg, err := config.Load(repoRoot)
if err != nil {
return err
}
project := cfg.FindProject(projectName)
if project == nil {
return fmt.Errorf("project %q not found in stamp.toml", projectName)
}
if project.PreTag != "" {
return fmt.Errorf("project %q is already in pre-release mode with tag %q — run `stamp preview exit %s` first",
projectName, project.PreTag, projectName)
}
project.PreTag = tag
if err := config.Save(repoRoot, cfg); err != nil {
return fmt.Errorf("saving stamp.toml: %w", err)
}
fmt.Printf("🔬 %s is now in pre-release mode (tag: %s).\n", projectName, tag)
fmt.Printf(" `stamp version` will produce versions like 1.2.3-%s.0\n", tag)
return nil
},
},
{
Name: "exit",
Usage: "take a project out of pre-release mode",
ArgsUsage: "<project>",
Action: func(ctx context.Context, cmd *cli.Command) error {
if cmd.Args().Len() == 0 {
return fmt.Errorf("usage: stamp preview exit <project>")
}
projectName := cmd.Args().First()
repoRoot, err := findRepoRoot(".")
if err != nil {
return err
}
cfg, err := config.Load(repoRoot)
if err != nil {
return err
}
project := cfg.FindProject(projectName)
if project == nil {
return fmt.Errorf("project %q not found in stamp.toml", projectName)
}
if project.PreTag == "" {
return fmt.Errorf("project %q is not in pre-release mode", projectName)
}
project.PreTag = ""
if err := config.Save(repoRoot, cfg); err != nil {
return fmt.Errorf("saving stamp.toml: %w", err)
}
fmt.Printf("✓ %s is no longer in pre-release mode. The next `stamp version` will produce a normal release.\n", projectName)
return nil
},
},
},
}
}

View File

@@ -50,7 +50,7 @@ func versionCmd() *cli.Command {
return nil
}
preID := cmd.String("snapshot")
snapshotID := cmd.String("snapshot")
projectBumps := semver.ProjectBumps(changesets)
now := time.Now()
@@ -65,8 +65,32 @@ func versionCmd() *cli.Command {
}
highest := semver.HighestBump(bumps)
// Determine the effective pre-release identifier, in priority order:
// 1. --snapshot flag (repo-wide, one-off)
// 2. project.PreTag set by `stamp preview enter` (persistent, per-project)
preID := snapshotID
if preID == "" {
preID = project.PreTag
}
if preID != "" {
highest = changeset.BumpPreRelease
// If the project is already on a pre-release version, just
// increment it. Otherwise promote the highest regular bump
// to its pre-release equivalent so we don't skip a version.
v, _ := semver.CurrentVersion(project.Version)
if v != nil && v.Prerelease() != "" {
highest = changeset.BumpPreRelease
} else {
switch highest {
case changeset.BumpMajor:
highest = changeset.BumpPreMajor
case changeset.BumpMinor:
highest = changeset.BumpPreMinor
default: // patch or anything lower
highest = changeset.BumpPrePatch
}
}
}
nextVer, err := semver.Bump(project.Version, highest, preID)
@@ -105,7 +129,7 @@ func versionCmd() *cli.Command {
}
// Update stamp.toml with new versions.
cfgPath := filepath.Join(repoRoot, config.ConfigFileName)
cfgPath := config.ConfigPath(repoRoot)
if err := config.Save(repoRoot, cfg); err != nil {
return fmt.Errorf("saving stamp.toml: %w", err)
}

View File

@@ -19,6 +19,7 @@ func main() {
versionCmd(),
publishCmd(),
commentCmd(),
previewCmd(),
},
}