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
48 changes: 47 additions & 1 deletion cli/commands/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,22 @@ import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"

"github.com/NimbleMarkets/ntcharts/linechart/timeserieslinechart"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"golang.org/x/term"

"miren.dev/runtime/api/app/app_v1alpha"
"miren.dev/runtime/appconfig"
"miren.dev/runtime/clientconfig"
"miren.dev/runtime/pkg/rpc/standard"
"miren.dev/runtime/pkg/ui"
"miren.dev/runtime/pkg/units"
)

Expand Down Expand Up @@ -49,7 +53,49 @@ func (a *AppCentric) Validate(glbl *GlobalFlags) error {
if a.config != nil && a.config.Name != "" {
a.App = a.config.Name
} else {
return fmt.Errorf("app is required")
// No app name from flag or config — try to help the user.
workDir := a.Dir
if workDir == "." || workDir == "" {
wd, err := os.Getwd()
if err != nil {
return fmt.Errorf("no app configuration found — run 'miren init' to get started, or pass -a <name>")
}
workDir = wd
} else {
absDir, err := filepath.Abs(workDir)
if err == nil {
workDir = absDir
}
}

appName := inferAppName(workDir)

noAppMsg := "no app configuration found — run 'miren init' to get started, or pass -a <name>"

if !term.IsTerminal(int(os.Stdin.Fd())) {
return fmt.Errorf("%s", noAppMsg)
}

confirmed, err := ui.Confirm(
ui.WithMessage(fmt.Sprintf("Looks like this directory isn't set up yet. Run 'miren init' to create app %q here?", appName)),
ui.WithDefault(true),
ui.WithIndent(" "),
)
if err != nil || !confirmed {
return fmt.Errorf("%s", noAppMsg)
}

if _, err := initApp(workDir, appName); err != nil {
return fmt.Errorf("failed to initialize app: %w", err)
}

// Reload the config we just created. workDir is already absolute.
a.config, err = appconfig.LoadAppConfigUnder(workDir)
if err != nil {
return fmt.Errorf("error loading %s: %w", appconfig.AppConfigPath, err)
}

a.App = appName
}
}

Expand Down
30 changes: 27 additions & 3 deletions cli/commands/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,27 @@ func writeAppToml(t *testing.T, dir, content string) {
}
}

func TestInferAppName(t *testing.T) {
tests := []struct {
dir string
want string
}{
{"/home/user/my-app", "my-app"},
{"/home/user/My App", "my-app"},
{"/home/user/my_app", "my-app"},
{"/home/user/MyApp", "myapp"},
{"/home/user/HELLO", "hello"},
}
for _, tt := range tests {
t.Run(tt.dir, func(t *testing.T) {
got := inferAppName(tt.dir)
if got != tt.want {
t.Errorf("inferAppName(%q) = %q, want %q", tt.dir, got, tt.want)
}
})
}
}

func TestAppCentricValidate(t *testing.T) {
t.Run("invalid TOML syntax returns parse error", func(t *testing.T) {
dir := t.TempDir()
Expand Down Expand Up @@ -73,16 +94,19 @@ command = ["foo", "bar"]
}
})

t.Run("no app.toml returns app is required", func(t *testing.T) {
t.Run("no app.toml returns helpful error mentioning miren init", func(t *testing.T) {
dir := t.TempDir()

a := AppCentric{Dir: dir}
err := a.Validate(&GlobalFlags{})
if err == nil {
t.Fatal("expected error when no app.toml exists")
}
if err.Error() != "app is required" {
t.Errorf("expected 'app is required', got: %v", err)
if !strings.Contains(err.Error(), "miren init") {
t.Errorf("expected error to mention 'miren init', got: %v", err)
}
if !strings.Contains(err.Error(), "-a") {
t.Errorf("expected error to mention '-a' flag, got: %v", err)
}
})

Expand Down
88 changes: 46 additions & 42 deletions cli/commands/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,54 @@ import (
"miren.dev/runtime/appconfig"
)

// inferAppName derives a sanitized app name from a directory path.
func inferAppName(dir string) string {
name := filepath.Base(dir)
name = strings.ToLower(name)
name = strings.ReplaceAll(name, " ", "-")
name = strings.ReplaceAll(name, "_", "-")
return name
}

// initApp creates a .miren/app.toml in dir with the given app name.
// It returns the path to the created file.
func initApp(dir, name string) (string, error) {
appTomlPath := filepath.Join(dir, appconfig.AppConfigPath)
runtimeDir := filepath.Dir(appTomlPath)

if _, err := os.Stat(appTomlPath); err != nil {
if !os.IsNotExist(err) {
return "", fmt.Errorf("failed to check for existing app.toml: %w", err)
}
} else {
return "", fmt.Errorf("app.toml already exists in %s - app already initialized", runtimeDir)
}

if err := os.MkdirAll(runtimeDir, 0755); err != nil {
return "", fmt.Errorf("failed to create .miren directory: %w", err)
}

appConfig := &appconfig.AppConfig{
Name: name,
}

content, err := toml.Marshal(appConfig)
if err != nil {
return "", fmt.Errorf("failed to marshal app config: %w", err)
}

if err := os.WriteFile(appTomlPath, content, 0644); err != nil {
return "", fmt.Errorf("failed to write app.toml: %w", err)
}

return appTomlPath, nil
}

func Init(ctx *Context, opts struct {
Name string `short:"n" long:"name" description:"Application name (defaults to directory name)"`
Dir string `short:"d" long:"dir" description:"Application directory (defaults to current directory)"`
ConfigCentric
}) error {
// Determine working directory
workDir := opts.Dir
if workDir == "" {
wd, err := os.Getwd()
Expand All @@ -24,59 +66,21 @@ func Init(ctx *Context, opts struct {
}
workDir = wd
} else {
// Convert to absolute path
absDir, err := filepath.Abs(workDir)
if err != nil {
return fmt.Errorf("failed to resolve directory path: %w", err)
}
workDir = absDir
}

// Determine app name
appName := opts.Name
if appName == "" {
// Use directory name as default
appName = filepath.Base(workDir)

// Sanitize the app name - replace spaces and special chars with hyphens
appName = strings.ToLower(appName)
appName = strings.ReplaceAll(appName, " ", "-")
appName = strings.ReplaceAll(appName, "_", "-")
}

// Check if already initialized
appTomlPath := filepath.Join(workDir, appconfig.AppConfigPath)
runtimeDir := filepath.Dir(appTomlPath)
if _, err := os.Stat(appTomlPath); err != nil {
if !os.IsNotExist(err) {
// Return unexpected errors (permission denied, IO errors, etc.)
return fmt.Errorf("failed to check for existing app.toml: %w", err)
}
// File doesn't exist, continue with initialization
} else {
// File exists
return fmt.Errorf("app.toml already exists in %s - app already initialized", runtimeDir)
appName = inferAppName(workDir)
}

// Create .miren directory
if err := os.MkdirAll(runtimeDir, 0755); err != nil {
return fmt.Errorf("failed to create .miren directory: %w", err)
}

// Create app config with just the name
appConfig := &appconfig.AppConfig{
Name: appName,
}

// Marshal to TOML
content, err := toml.Marshal(appConfig)
appTomlPath, err := initApp(workDir, appName)
if err != nil {
return fmt.Errorf("failed to marshal app config: %w", err)
}

// Write app.toml
if err := os.WriteFile(appTomlPath, content, 0644); err != nil {
return fmt.Errorf("failed to write app.toml: %w", err)
return err
}

ctx.Printf("Initialized Miren app '%s' in %s\n", appName, appTomlPath)
Expand Down
Loading