diff --git a/go.mod b/go.mod index b6b8b3d..e26843a 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/spf13/pflag v1.0.10 github.com/spf13/viper v1.21.0 github.com/verda-cloud/verdacloud-sdk-go v1.4.2 - github.com/verda-cloud/verdagostack v1.3.0 + github.com/verda-cloud/verdagostack v1.3.1 go.yaml.in/yaml/v3 v3.0.4 ) diff --git a/go.sum b/go.sum index ffa11ad..c61ef32 100644 --- a/go.sum +++ b/go.sum @@ -104,8 +104,8 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/verda-cloud/verdacloud-sdk-go v1.4.2 h1:oVb8fHVQOY+YPuuMYMee9gYCkPTwAw01LmkqxM21T/Y= github.com/verda-cloud/verdacloud-sdk-go v1.4.2/go.mod h1:pmlpiCL9fTSikZ3qWLJPpHOG0E8PKkQVUX5s4Z+SktY= -github.com/verda-cloud/verdagostack v1.3.0 h1:NxW5OaE79tbc9pemy/Zasjqw08IuvNr0ivlpe0VP91Q= -github.com/verda-cloud/verdagostack v1.3.0/go.mod h1:eWTGv3kbBUGVCjNKZYLzzK9+UwpNWoPN3B2vebN2otY= +github.com/verda-cloud/verdagostack v1.3.1 h1:OFDW1TMEwdspVmYZWnl5ONhZqllXOT6xQIiyLlw8KS4= +github.com/verda-cloud/verdagostack v1.3.1/go.mod h1:eWTGv3kbBUGVCjNKZYLzzK9+UwpNWoPN3B2vebN2otY= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= diff --git a/internal/skills/files/verda-cloud.md b/internal/skills/files/verda-cloud.md index bc1c317..a50e30e 100644 --- a/internal/skills/files/verda-cloud.md +++ b/internal/skills/files/verda-cloud.md @@ -37,7 +37,8 @@ description: Use when the user mentions Verda Cloud, GPU/CPU VMs, cloud instance ### Explore -- Available instances: `verda --agent instance-types [--gpu|--cpu] -o json` → present name, GPU, VRAM, RAM, price_per_hour sorted by price. **Stop.** +- What's available now: `verda --agent vm availability -o json` → shows what's **in stock** with location and pricing. Filter with `--kind gpu` or `--kind cpu` (NOT `--type gpu`). If result is empty or null, tell the user **nothing is in stock** for that kind — do NOT fall back to showing a different kind. **Stop.** +- Full catalog (all types, not just in stock): `verda --agent instance-types [--gpu|--cpu] -o json` → specs and pricing. **Stop.** - Overview/dashboard: `verda --agent status -o json` → instances, volumes, balance, burn rate. **Stop.** - Running costs: `verda --agent cost running -o json` → per-instance breakdown. **Stop.** @@ -50,8 +51,8 @@ Otherwise walk this chain. **ALWAYS** steps must run even if user specified valu 1. **Billing** *(skip if known)* — spot ("cheap", "testing") or on-demand (default) 2. **Compute** *(skip if known)* — GPU (ML/training/CUDA) or CPU (web/API/dev) 3. **Instance type** *(skip if user specified)* — `verda --agent instance-types [--gpu|--cpu] -o json`, present top 3 by price -4. **ALWAYS: Availability** — `verda --agent availability --type [--spot] -o json`. Location depends on availability, NOT the reverse -5. **ALWAYS: Images** — `verda --agent images --type -o json`. **NEVER guess slugs** — they vary by instance type +4. **ALWAYS: Availability** — `verda --agent vm availability --type [--spot] -o json`. Location depends on availability, NOT the reverse +5. **ALWAYS: Images** — `verda --agent images --type -o json`. Use `image_type` field for `--os` flag. **NEVER guess** — they vary by instance type 6. **ALWAYS: SSH keys** — `verda --agent ssh-key list -o json`. If user named a key, find its ID 7. **ALWAYS: Cost** — `verda --agent cost balance -o json` + `verda --agent cost estimate --type --os-volume 50 -o json`. Warn if runway < 24h 8. **Confirm** — show summary, wait for "yes" diff --git a/internal/skills/files/verda-reference.md b/internal/skills/files/verda-reference.md index ea7592b..6f78c9c 100644 --- a/internal/skills/files/verda-reference.md +++ b/internal/skills/files/verda-reference.md @@ -21,8 +21,8 @@ All commands: `--agent -o json` (except `verda ssh` and `verda auth show`). | "template", "saved config", "preset", "my templates" | `template list` (alias: `tmpl`) | | "deploy from template", "use template", "quick deploy" | `vm create --from ` | | "status", "overview", "dashboard", "summary" | `status` (alias: `dash`) | -| "what's available", "stock", "capacity" | `availability` | -| "instance types", "GPU types", "CPU types", "specs", "flavors" | `instance-types` | +| "what's available", "in stock", "can I get", "available right now" | `vm availability` (real-time stock + pricing by location) | +| "instance types", "GPU types", "CPU types", "specs", "flavors", "catalog" | `instance-types` (full catalog, not filtered by stock) | | "pricing", "how much", "cost per hour" | `instance-types` or `cost estimate` | | "images", "OS", "Ubuntu", "CUDA" | `images` (NOT `images list`) with `--type` (NOT `--instance-type`) | | "locations", "regions", "datacenters" | `locations` | @@ -41,8 +41,8 @@ All commands: `--agent -o json` (except `verda ssh` and `verda auth show`). |---------|-----------|---------------| | `verda locations -o json` | — | `code`, `city`, `country` | | `verda instance-types -o json` | `--gpu`, `--cpu`, `--spot` | `name`, `price_per_hour`, `spot_price`, `gpu.number_of_gpus`, `gpu_memory.size_in_gigabytes`, `memory.size_in_gigabytes` | -| `verda availability -o json` | `--type`, `--location`, `--spot` | `location_code`, `available` | -| `verda images -o json` | `--type` (NOT `--instance-type`) | `slug` (use in --os), `name`, `category` | +| `verda vm availability -o json` | `--kind` (gpu/cpu), `--type`, `--location`, `--spot`. Use `--kind gpu` NOT `--type gpu` | `location`, `instance_type`, `gpu`, `ram`, `cpu_cores`, `price_per_hour`, `spot_price` | +| `verda images -o json` | `--type` (instance type filter, NOT `--instance-type`), `--category` (e.g. ubuntu, pytorch) | `image_type` (use in --os), `name`, `category` | ## VM Create — Required Flags (`--agent` mode) @@ -50,30 +50,35 @@ All commands: `--agent -o json` (except `verda ssh` and `verda auth show`). |------|-------------------| | `--kind` | `gpu` or `cpu` — user intent | | `--instance-type` | `instance-types -o json` → `name` | -| `--os` | `images --type -o json` → `slug` | +| `--os` | `images --type -o json` → `image_type` field | | `--hostname` | User-provided or auto-generate | -**Optional flags:** `--location` (default FIN-01), `--ssh-key` (repeatable), `--is-spot`, `--os-volume-size` (default 50), `--storage-size`, `--storage-type` (NVMe/HDD), `--startup-script`, `--contract` (PAY_AS_YOU_GO/SPOT/LONG_TERM), `--from` (template), `--wait`, `--wait-timeout` (use 2m) +**Optional flags:** `--location` (default FIN-01), `--ssh-key` (repeatable, takes ID), `--is-spot`, `--os-volume-size` (GiB), `--storage-size` (GiB), `--storage-type` (NVMe/HDD), `--startup-script` (ID), `--contract` (PAY_AS_YOU_GO/SPOT/LONG_TERM), `--from` (template name), `--wait`, `--wait-timeout` (use 2m) ## VM Lifecycle | Command | Key Flags | |---------|-----------| -| `verda vm list -o json` | `--status` (running, offline, provisioning). Fields: `id`, `hostname`, `status`, `instance_type`, `location`, `ip`, `price_per_hour` | +| `verda vm list -o json` | `--status`, `--location`. Fields: `id`, `hostname`, `status`, `instance_type`, `location`, `ip`, `price_per_hour` | | `verda vm describe -o json` | — | | `verda vm start --wait` | `--yes` in agent mode | | `verda vm shutdown --wait` | `--yes` in agent mode. Alias: `stop` | | `verda vm hibernate --wait` | `--yes` in agent mode | -| `verda vm delete --wait` | `--yes` **required** in agent mode. Alias: `rm` | +| `verda vm delete --wait` | `--yes` **required**. `--with-volumes` to also delete attached volumes. Alias: `rm` | + +Batch operations: `--all` with `--status` and/or `--hostname` (glob pattern) to target multiple VMs. +Example: `verda --agent vm shutdown --all --status running --yes --wait -o json` + +Note: `shutdown` alias is `stop`. `delete` alias is `rm`. ## Status & Cost -| Command | Output Fields | -|---------|---------------| -| `verda status -o json` | `instances` (total, running, offline, spot), `volumes` (total, attached, detached, total_size_gb), `financials` (burn_rate_hourly, balance, runway_days), `locations[]` | -| `verda cost balance -o json` | `amount`, `currency` | -| `verda cost estimate -o json` | `total.hourly`, `instance.hourly`, `os_volume.hourly`. Flags: `--type`, `--os-volume`, `--storage`, `--spot` | -| `verda cost running -o json` | `instances[]` (each: `hostname`, `hourly`, `daily`, `monthly`), `total.hourly` | +| Command | Key Flags | Output Fields | +|---------|-----------|---------------| +| `verda status -o json` | — | `instances` (total, running, offline, spot), `volumes` (total, attached, detached, total_size_gb), `financials` (burn_rate_hourly, balance, runway_days), `locations[]` | +| `verda cost balance -o json` | — | `amount`, `currency` | +| `verda cost estimate -o json` | `--type` (required), `--os-volume`, `--storage`, `--storage-type`, `--spot`, `--location` | `total.hourly`, `instance.hourly`, `os_volume.hourly` | +| `verda cost running -o json` | — | `instances[]` (each: `hostname`, `hourly`, `daily`, `monthly`), `total.hourly` | ## SSH (Interactive — Do NOT Run) @@ -96,7 +101,7 @@ Tell user to run in their terminal: | Command | Notes | |---------|-------| -| `verda template list -o json` | Fields: `resource`, `name`, `description` | +| `verda template list -o json` | `--type` to filter (e.g. `--type vm`). Fields: `resource`, `name`, `description` | | `verda template show vm/ -o json` | Fields: `InstanceType`, `Location`, `Image`, `SSHKeys[]`, `HostnamePattern`, `Description`. Note: `vm/` prefix required | | `verda template delete vm/` | Confirm first | | `verda template create` | Interactive — tell user to run | @@ -141,8 +146,8 @@ Hostname patterns: `{random}` → random words, `{location}` → location code | Parameter | Source | Field | |-----------|--------|-------| | instance-type | `instance-types` | `name` | -| location | `availability --type ` | `location_code` | -| image/os | `images --type ` | `slug` | +| location | `vm availability --type ` | `location` | +| image/os | `images --type ` | `image_type` | | ssh-key ID | `ssh-key list` | `id` | | startup-script ID | `startup-script list` | `id` | | volume ID | `volume list` | `id` | diff --git a/internal/skills/manifest.json b/internal/skills/manifest.json index 8b6d6d0..445b4ae 100644 --- a/internal/skills/manifest.json +++ b/internal/skills/manifest.json @@ -9,7 +9,11 @@ "display_name": "Claude Code", "scope": "global", "target": "~/.claude/skills/", - "method": "copy" + "method": "copy", + "file_map": { + "verda-cloud.md": "verda-cloud/SKILL.md", + "verda-reference.md": "verda-reference/SKILL.md" + } }, "cursor": { "display_name": "Cursor", diff --git a/internal/verda-cli/cmd/skills/install.go b/internal/verda-cli/cmd/skills/install.go index f18dcb3..9c96383 100644 --- a/internal/verda-cli/cmd/skills/install.go +++ b/internal/verda-cli/cmd/skills/install.go @@ -309,6 +309,13 @@ func cleanupStaleFiles(dir string, agent *Agent, currentFiles map[string]string, // names and mapped names like verda-cloud.md → SKILL.md). oldDest := agent.DestName(old) if current[oldDest] { + // If file_map was added/changed, the raw source name may still + // exist as a flat file from a previous install without file_map. + // Clean it up (e.g. ~/.claude/skills/verda-cloud.md → now + // installed as ~/.claude/skills/verda-cloud/SKILL.md). + if oldDest != old { + _ = os.Remove(filepath.Join(dir, old)) // best-effort + } continue // still in current manifest } _ = os.Remove(filepath.Join(dir, oldDest)) // best-effort @@ -317,7 +324,15 @@ func cleanupStaleFiles(dir string, agent *Agent, currentFiles map[string]string, func installCopy(dir string, agent *Agent, skillFiles map[string]string) error { for name, content := range skillFiles { - path := filepath.Join(dir, agent.DestName(name)) + dest := agent.DestName(name) + path := filepath.Join(dir, dest) + // Create parent subdirectories for path-based file_map entries + // (e.g. "verda-cloud/SKILL.md" needs the "verda-cloud" dir). + if parent := filepath.Dir(path); parent != dir { + if err := os.MkdirAll(parent, 0o750); err != nil { + return fmt.Errorf("creating directory %s: %w", parent, err) + } + } if err := os.WriteFile(path, []byte(content), 0o644); err != nil { //nolint:gosec // non-sensitive skill files return fmt.Errorf("writing %s: %w", path, err) } diff --git a/internal/verda-cli/cmd/skills/install_test.go b/internal/verda-cli/cmd/skills/install_test.go index 433bbea..6849a34 100644 --- a/internal/verda-cli/cmd/skills/install_test.go +++ b/internal/verda-cli/cmd/skills/install_test.go @@ -112,6 +112,80 @@ func TestInstallCopy_FileMap(t *testing.T) { } } +func TestInstallCopy_SubdirFileMap(t *testing.T) { + t.Parallel() + dir := t.TempDir() + skillFiles := map[string]string{ + "verda-cloud.md": "# Verda Cloud\ncontent", + "verda-reference.md": "# Reference\ncontent", + } + agent := &Agent{ + Name: "claude-code", DisplayName: "Claude Code", + Scope: "global", Method: "copy", Target: dir, + FileMap: map[string]string{ + "verda-cloud.md": "verda-cloud/SKILL.md", + "verda-reference.md": "verda-reference/SKILL.md", + }, + } + if err := installForAgent(agent, skillFiles, nil); err != nil { + t.Fatalf("install error: %v", err) + } + // Both should be installed in subdirectories as SKILL.md + for _, sub := range []string{"verda-cloud", "verda-reference"} { + path := filepath.Join(dir, sub, "SKILL.md") + if _, err := os.Stat(path); err != nil { + t.Fatalf("expected %s/SKILL.md to exist", sub) + } + } + // Flat files should NOT exist + for _, flat := range []string{"verda-cloud.md", "verda-reference.md"} { + if _, err := os.Stat(filepath.Join(dir, flat)); !os.IsNotExist(err) { + t.Fatalf("%s should not exist as flat file", flat) + } + } +} + +func TestInstallCopy_CleansUpFlatFilesOnFileMapChange(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + // Simulate a previous install that wrote flat files (no file_map). + _ = os.WriteFile(filepath.Join(dir, "verda-cloud.md"), []byte("old"), 0o600) + _ = os.WriteFile(filepath.Join(dir, "verda-reference.md"), []byte("old"), 0o600) + + // Re-install with file_map that puts files into subdirectories. + newFiles := map[string]string{ + "verda-cloud.md": "# Cloud v2", + "verda-reference.md": "# Reference v2", + } + agent := &Agent{ + Name: "claude-code", DisplayName: "Claude Code", + Scope: "global", Method: "copy", Target: dir, + FileMap: map[string]string{ + "verda-cloud.md": "verda-cloud/SKILL.md", + "verda-reference.md": "verda-reference/SKILL.md", + }, + } + previousSkills := []string{"verda-cloud.md", "verda-reference.md"} + + if err := installForAgent(agent, newFiles, previousSkills); err != nil { + t.Fatalf("install error: %v", err) + } + + // New subdir files should exist. + for _, sub := range []string{"verda-cloud", "verda-reference"} { + if _, err := os.Stat(filepath.Join(dir, sub, "SKILL.md")); err != nil { + t.Fatalf("expected %s/SKILL.md to exist", sub) + } + } + // Old flat files should be cleaned up. + for _, flat := range []string{"verda-cloud.md", "verda-reference.md"} { + if _, err := os.Stat(filepath.Join(dir, flat)); !os.IsNotExist(err) { + t.Fatalf("old flat file %s should have been removed", flat) + } + } +} + func TestInstallCopy_CleansUpStaleFiles(t *testing.T) { t.Parallel() dir := t.TempDir() diff --git a/internal/verda-cli/cmd/skills/uninstall.go b/internal/verda-cli/cmd/skills/uninstall.go index d74e8dc..e873b33 100644 --- a/internal/verda-cli/cmd/skills/uninstall.go +++ b/internal/verda-cli/cmd/skills/uninstall.go @@ -219,10 +219,16 @@ func uninstallForAgent(agent *Agent, skillNames []string) error { func uninstallCopy(agent *Agent, skillNames []string) error { dir := agent.TargetDir() for _, name := range skillNames { - path := filepath.Join(dir, agent.DestName(name)) + dest := agent.DestName(name) + path := filepath.Join(dir, dest) if err := os.Remove(path); err != nil && !os.IsNotExist(err) { return fmt.Errorf("removing %s: %w", path, err) } + // Remove empty parent directory for subdirectory-based installs + // (e.g. verda-cloud/SKILL.md leaves an empty verda-cloud/ dir). + if parent := filepath.Dir(path); parent != dir { + _ = os.Remove(parent) // best-effort; only succeeds if empty + } } return nil } diff --git a/internal/verda-cli/cmd/skills/uninstall_test.go b/internal/verda-cli/cmd/skills/uninstall_test.go index 1e84fc5..535f2a3 100644 --- a/internal/verda-cli/cmd/skills/uninstall_test.go +++ b/internal/verda-cli/cmd/skills/uninstall_test.go @@ -33,6 +33,40 @@ func TestUninstallCopy(t *testing.T) { } } +func TestUninstallCopy_SubdirCleanup(t *testing.T) { + t.Parallel() + dir := t.TempDir() + // Create subdirectory-based skill files. + for _, sub := range []string{"verda-cloud", "verda-reference"} { + subDir := filepath.Join(dir, sub) + _ = os.MkdirAll(subDir, 0o750) + _ = os.WriteFile(filepath.Join(subDir, "SKILL.md"), []byte("test"), 0o600) + } + agent := &Agent{ + Name: "claude-code", Scope: "global", Method: "copy", + Target: dir, + FileMap: map[string]string{ + "verda-cloud.md": "verda-cloud/SKILL.md", + "verda-reference.md": "verda-reference/SKILL.md", + }, + } + if err := uninstallForAgent(agent, []string{"verda-cloud.md", "verda-reference.md"}); err != nil { + t.Fatalf("uninstall error: %v", err) + } + // SKILL.md files should be removed. + for _, sub := range []string{"verda-cloud", "verda-reference"} { + if _, err := os.Stat(filepath.Join(dir, sub, "SKILL.md")); !os.IsNotExist(err) { + t.Fatalf("expected %s/SKILL.md to be deleted", sub) + } + } + // Empty subdirectories should be removed. + for _, sub := range []string{"verda-cloud", "verda-reference"} { + if _, err := os.Stat(filepath.Join(dir, sub)); !os.IsNotExist(err) { + t.Fatalf("expected empty dir %s to be removed", sub) + } + } +} + func TestUninstallAppend(t *testing.T) { t.Parallel() dir := t.TempDir() diff --git a/internal/verda-cli/cmd/vm/create.go b/internal/verda-cli/cmd/vm/create.go index 12d30f7..34cf24c 100644 --- a/internal/verda-cli/cmd/vm/create.go +++ b/internal/verda-cli/cmd/vm/create.go @@ -268,7 +268,7 @@ func missingCreateFlags(opts *createOptions) []string { } func runWizard(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, opts *createOptions) error { - flow := buildCreateFlow(ctx, f.VerdaClient, opts, WizardModeDeploy, ioStreams.ErrOut) + flow := buildCreateFlow(ctx, f.VerdaClient, opts, WizardModeDeploy) engine := wizard.NewEngine(f.Prompter(), f.Status(), wizard.WithOutput(ioStreams.ErrOut), wizard.WithExitConfirmation()) return engine.Run(ctx, flow) } diff --git a/internal/verda-cli/cmd/vm/wizard.go b/internal/verda-cli/cmd/vm/wizard.go index 798c86e..fc9a7fd 100644 --- a/internal/verda-cli/cmd/vm/wizard.go +++ b/internal/verda-cli/cmd/vm/wizard.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "io" "strconv" "strings" @@ -78,7 +77,7 @@ func RunTemplateWizard(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil } func runTemplateWizardWithOpts(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, opts *createOptions) (*TemplateResult, error) { - flow := buildCreateFlow(ctx, f.VerdaClient, opts, WizardModeTemplate, ioStreams.ErrOut) + flow := buildCreateFlow(ctx, f.VerdaClient, opts, WizardModeTemplate) engine := wizard.NewEngine(f.Prompter(), f.Status(), wizard.WithOutput(ioStreams.ErrOut), wizard.WithExitConfirmation()) if err := engine.Run(ctx, flow); err != nil { return nil, err @@ -118,7 +117,7 @@ func optsToTemplateResult(opts *createOptions) *TemplateResult { // billing-type → contract → kind → instance-type → location → // image → os-volume-size → storage-size → ssh-keys → // startup-script → hostname → description -func buildCreateFlow(ctx context.Context, getClient clientFunc, opts *createOptions, mode WizardMode, errOut io.Writer) *wizard.Flow { +func buildCreateFlow(ctx context.Context, getClient clientFunc, opts *createOptions, mode WizardMode) *wizard.Flow { cache := &apiCache{} steps := []wizard.Step{ @@ -137,7 +136,7 @@ func buildCreateFlow(ctx context.Context, getClient clientFunc, opts *createOpti steps = append(steps, stepHostname(opts), stepDescription(opts), - stepConfirmDeploy(ctx, errOut, getClient, cache, opts), + stepConfirmDeploy(opts), ) } if mode == WizardModeTemplate { @@ -151,9 +150,9 @@ func buildCreateFlow(ctx context.Context, getClient clientFunc, opts *createOpti {ID: "hints", View: wizard.NewHintBarView(wizard.WithHintStyle(bubbletea.HintStyle()))}, } if mode == WizardModeDeploy { - // Prepend progress bar for deploy mode only layout = append([]wizard.ViewDef{ {ID: "progress", View: wizard.NewProgressView(wizard.WithProgressPercent())}, + {ID: "summary", View: newSummaryView(ctx, getClient, cache, opts)}, }, layout...) } @@ -787,17 +786,13 @@ func stepDescription(opts *createOptions) wizard.Step { // --- Step 13: Deployment Summary & Confirm --- -func stepConfirmDeploy(ctx context.Context, errOut io.Writer, getClient clientFunc, cache *apiCache, opts *createOptions) wizard.Step { +func stepConfirmDeploy(opts *createOptions) wizard.Step { return wizard.Step{ Name: "confirm-deploy", Description: "Deploy now?", Prompt: wizard.ConfirmPrompt, Required: true, Default: func(_ map[string]any) any { - // Ensure pricing data is available (may be missing when - // steps were skipped via --from template pre-fill). - ensurePricingCache(ctx, getClient, cache) - renderDeploymentSummary(errOut, opts, cache) return true }, Setter: func(v any) { diff --git a/internal/verda-cli/cmd/vm/wizard_summary.go b/internal/verda-cli/cmd/vm/wizard_summary.go index 2d6c8dd..7d3baad 100644 --- a/internal/verda-cli/cmd/vm/wizard_summary.go +++ b/internal/verda-cli/cmd/vm/wizard_summary.go @@ -1,16 +1,48 @@ package vm import ( + "context" "fmt" - "io" + "reflect" "strconv" "strings" "charm.land/lipgloss/v2" "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" + "github.com/verda-cloud/verdagostack/pkg/tui/wizard" ) -func renderDeploymentSummary(w io.Writer, opts *createOptions, cache *apiCache) { +// summaryView implements wizard.View and renders the deployment summary +// when the wizard reaches the confirm-deploy step. +type summaryView struct { + ctx context.Context + getClient clientFunc + cache *apiCache + opts *createOptions + last string +} + +func newSummaryView(ctx context.Context, getClient clientFunc, cache *apiCache, opts *createOptions) *summaryView { + return &summaryView{ctx: ctx, getClient: getClient, cache: cache, opts: opts} +} + +func (v *summaryView) Update(msg any) (render string, publish []any) { + if sc, ok := msg.(wizard.StepChangedMsg); ok { + if sc.StepName == "confirm-deploy" { + ensurePricingCache(v.ctx, v.getClient, v.cache) + v.last = renderDeploymentSummary(v.opts, v.cache) + } else { + v.last = "" + } + } + return v.last, nil +} + +func (v *summaryView) Subscribe() []reflect.Type { + return nil // receive all engine broadcasts +} + +func renderDeploymentSummary(opts *createOptions, cache *apiCache) string { bold := lipgloss.NewStyle().Bold(true) dim := lipgloss.NewStyle().Foreground(lipgloss.Color("8")) accent := lipgloss.NewStyle().Foreground(lipgloss.Color("14")) @@ -73,18 +105,20 @@ func renderDeploymentSummary(w io.Writer, opts *createOptions, cache *apiCache) volDetails = append(volDetails, volDetail{name, vType, size, unitP, hourly}) } + var b strings.Builder + // Print summary. - _, _ = fmt.Fprintf(w, "\n %s\n", bold.Render("Deployment Summary")) + fmt.Fprintf(&b, "\n %s\n", bold.Render("Deployment Summary")) billing := "On-Demand" if opts.IsSpot { billing = "Spot Instance" } - _, _ = fmt.Fprintf(w, " %s\n", dim.Render(billing)) - _, _ = fmt.Fprintf(w, " %s\n\n", dim.Render(strings.Repeat("─", 50))) + fmt.Fprintf(&b, " %s\n", dim.Render(billing)) + fmt.Fprintf(&b, " %s\n\n", dim.Render(strings.Repeat("─", 50))) // Instance. - _, _ = fmt.Fprintf(w, " %s\n", accent.Render("Instance")) + fmt.Fprintf(&b, " %s\n", accent.Render("Instance")) var computePriceStr string if instUnits > 1 { perUnit := computeHourly / float64(instUnits) @@ -92,36 +126,38 @@ func renderDeploymentSummary(w io.Writer, opts *createOptions, cache *apiCache) } else { computePriceStr = fmt.Sprintf("$%.4f/hr", computeHourly) } - _, _ = fmt.Fprintf(w, " %-40s %s\n", instLabel, priceStyle.Render(computePriceStr)) - _, _ = fmt.Fprintf(w, " %s\n\n", dim.Render(opts.LocationCode)) + fmt.Fprintf(&b, " %-40s %s\n", instLabel, priceStyle.Render(computePriceStr)) + fmt.Fprintf(&b, " %s\n\n", dim.Render(opts.LocationCode)) // OS. - _, _ = fmt.Fprintf(w, " %s\n", accent.Render("Operating System")) + fmt.Fprintf(&b, " %s\n", accent.Render("Operating System")) osLine := fmt.Sprintf("%s %dGB NVMe", opts.Image, opts.OSVolumeSize) osPrice := fmt.Sprintf("($%.2f/GB/mo) $%.4f/hr", osVolUnitPrice, osVolPrice) - _, _ = fmt.Fprintf(w, " %-40s %s\n\n", osLine, priceStyle.Render(osPrice)) + fmt.Fprintf(&b, " %-40s %s\n\n", osLine, priceStyle.Render(osPrice)) // Storage volumes. if len(volDetails) > 0 { - _, _ = fmt.Fprintf(w, " %s\n", accent.Render("Storage")) + fmt.Fprintf(&b, " %s\n", accent.Render("Storage")) for _, v := range volDetails { line := fmt.Sprintf("%s %dGB %s", v.name, v.size, v.volType) vPrice := fmt.Sprintf("($%.2f/GB/mo) $%.4f/hr", v.unitPrice, v.hourly) - _, _ = fmt.Fprintf(w, " %-40s %s\n", line, priceStyle.Render(vPrice)) + fmt.Fprintf(&b, " %-40s %s\n", line, priceStyle.Render(vPrice)) } - _, _ = fmt.Fprintln(w) + fmt.Fprintln(&b) } // SSH keys. if len(opts.SSHKeyIDs) > 0 { - _, _ = fmt.Fprintf(w, " %s %d key(s)\n\n", accent.Render("SSH Keys"), len(opts.SSHKeyIDs)) + fmt.Fprintf(&b, " %s %d key(s)\n\n", accent.Render("SSH Keys"), len(opts.SSHKeyIDs)) } // Cost breakdown. - _, _ = fmt.Fprintf(w, " %s\n", dim.Render(strings.Repeat("─", 50))) - _, _ = fmt.Fprintf(w, " %-40s %s\n", "Compute total", fmt.Sprintf("$%.4f/hr", computeHourly)) - _, _ = fmt.Fprintf(w, " %-40s %s\n", "Storage total", fmt.Sprintf("$%.4f/hr", storageHourly)) + fmt.Fprintf(&b, " %s\n", dim.Render(strings.Repeat("─", 50))) + fmt.Fprintf(&b, " %-40s %s\n", "Compute total", fmt.Sprintf("$%.4f/hr", computeHourly)) + fmt.Fprintf(&b, " %-40s %s\n", "Storage total", fmt.Sprintf("$%.4f/hr", storageHourly)) total := computeHourly + storageHourly - _, _ = fmt.Fprintf(w, " %s %s\n", bold.Render(fmt.Sprintf("%-40s", "Total")), bold.Render(fmt.Sprintf("$%.4f/hr", total))) - _, _ = fmt.Fprintf(w, " %s\n\n", dim.Render(strings.Repeat("─", 50))) + fmt.Fprintf(&b, " %s %s\n", bold.Render(fmt.Sprintf("%-40s", "Total")), bold.Render(fmt.Sprintf("$%.4f/hr", total))) + fmt.Fprintf(&b, " %s\n", dim.Render(strings.Repeat("─", 50))) + + return b.String() } diff --git a/internal/verda-cli/cmd/vm/wizard_test.go b/internal/verda-cli/cmd/vm/wizard_test.go index b60ea16..64ff56e 100644 --- a/internal/verda-cli/cmd/vm/wizard_test.go +++ b/internal/verda-cli/cmd/vm/wizard_test.go @@ -29,7 +29,7 @@ func TestBuildCreateFlowHappyPath(t *testing.T) { // hostname, description, confirm-deploy. ctx := context.Background() errClient := func() (*verda.Client, error) { return nil, errors.New("no client in test") } - flow := buildCreateFlow(ctx, errClient, opts, WizardModeDeploy, io.Discard) + flow := buildCreateFlow(ctx, errClient, opts, WizardModeDeploy) engine := wizard.NewEngine(nil, nil, wizard.WithOutput(io.Discard), wizard.WithTestResults( @@ -77,7 +77,7 @@ func TestBuildCreateFlowSpotSkipsContract(t *testing.T) { ctx := context.Background() errClient := func() (*verda.Client, error) { return nil, errors.New("no client in test") } - flow := buildCreateFlow(ctx, errClient, opts, WizardModeDeploy, io.Discard) + flow := buildCreateFlow(ctx, errClient, opts, WizardModeDeploy) engine := wizard.NewEngine(nil, nil, wizard.WithOutput(io.Discard), wizard.WithTestResults(