From 1dc2066af5124730efeb066b1dc33ebddca8e863 Mon Sep 17 00:00:00 2001 From: "Calvin A. Allen" Date: Tue, 10 Mar 2026 13:01:42 -0400 Subject: [PATCH 1/2] feat(lifecycle): add Node.js lifecycle status to list-all command Display lifecycle labels (Current, Active LTS, Maintenance LTS, EOL) in the Status column of `list-all node` output, computed from the embedded Node.js release schedule. The generic lifecycle package allows other providers to opt in later. Closes #237 --- src/cmd/list.go | 13 +++ src/cmd/list_test.go | 64 +++++++++++ src/cmd/listall.go | 8 +- src/internal/lifecycle/lifecycle.go | 30 ++++++ src/internal/runtime/version.go | 8 +- src/runtimes/node/data/schedule.json | 153 +++++++++++++++++++++++++++ src/runtimes/node/lifecycle.go | 135 +++++++++++++++++++++++ src/runtimes/node/lifecycle_test.go | 148 ++++++++++++++++++++++++++ src/runtimes/node/provider.go | 7 +- src/runtimes/python/provider.go | 1 - src/runtimes/ruby/provider.go | 1 - 11 files changed, 556 insertions(+), 12 deletions(-) create mode 100644 src/cmd/list_test.go create mode 100644 src/internal/lifecycle/lifecycle.go create mode 100644 src/runtimes/node/data/schedule.json create mode 100644 src/runtimes/node/lifecycle.go create mode 100644 src/runtimes/node/lifecycle_test.go diff --git a/src/cmd/list.go b/src/cmd/list.go index 51ce4f7..6d1bbdb 100644 --- a/src/cmd/list.go +++ b/src/cmd/list.go @@ -160,6 +160,19 @@ func getVersionStatus(version, globalVersion, localVersion string) string { return status } +// getVersionStatusWithLifecycle returns a status string that combines the +// global/local indicators with an optional lifecycle label (e.g., "Active LTS"). +func getVersionStatusWithLifecycle(version, globalVersion, localVersion, lifecycleStatus string) string { + base := getVersionStatus(version, globalVersion, localVersion) + if lifecycleStatus == "" { + return base + } + if base == "" { + return lifecycleStatus + } + return base + " · " + lifecycleStatus +} + // isVersionActive returns true if this version is the currently active one func isVersionActive(version, globalVersion, localVersion string) bool { isGlobal := version == globalVersion diff --git a/src/cmd/list_test.go b/src/cmd/list_test.go new file mode 100644 index 0000000..ea251c4 --- /dev/null +++ b/src/cmd/list_test.go @@ -0,0 +1,64 @@ +package cmd + +import "testing" + +func TestGetVersionStatusWithLifecycle(t *testing.T) { + tests := []struct { + name string + version string + globalVersion string + localVersion string + lifecycleStatus string + want string + }{ + { + name: "lifecycle only", + version: "22.14.0", + globalVersion: "", + localVersion: "", + lifecycleStatus: "Active LTS", + want: "Active LTS", + }, + { + name: "global only", + version: "22.14.0", + globalVersion: "22.14.0", + localVersion: "", + lifecycleStatus: "", + want: globalIndicator + " global", + }, + { + name: "global with lifecycle", + version: "22.14.0", + globalVersion: "22.14.0", + localVersion: "", + lifecycleStatus: "Active LTS", + want: globalIndicator + " global" + " · " + "Active LTS", + }, + { + name: "local with lifecycle", + version: "22.14.0", + globalVersion: "", + localVersion: "22.14.0", + lifecycleStatus: "Maintenance LTS", + want: localIndicator + " local" + " · " + "Maintenance LTS", + }, + { + name: "no status at all", + version: "22.14.0", + globalVersion: "", + localVersion: "", + lifecycleStatus: "", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := getVersionStatusWithLifecycle(tt.version, tt.globalVersion, tt.localVersion, tt.lifecycleStatus) + if got != tt.want { + t.Errorf("getVersionStatusWithLifecycle() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/src/cmd/listall.go b/src/cmd/listall.go index 17a5aed..9e95507 100644 --- a/src/cmd/listall.go +++ b/src/cmd/listall.go @@ -99,7 +99,7 @@ Examples: } // Create table for this page - table := tui.NewTable("", "Version", "Status", "Notes") + table := tui.NewTable("", "Version", "Status") table.SetTitle(provider.DisplayName()) for i := 0; i < pageSize; i++ { @@ -112,10 +112,10 @@ Examples: marker = tui.CheckMark } - // Get status (global/local indicators) - status := getVersionStatus(version, globalVersion, localVersion) + // Build status: combine global/local indicators with lifecycle + status := getVersionStatusWithLifecycle(version, globalVersion, localVersion, v.LifecycleStatus) - table.AddRow(marker, version, status, v.Notes) + table.AddRow(marker, version, status) } fmt.Println() diff --git a/src/internal/lifecycle/lifecycle.go b/src/internal/lifecycle/lifecycle.go new file mode 100644 index 0000000..c29a780 --- /dev/null +++ b/src/internal/lifecycle/lifecycle.go @@ -0,0 +1,30 @@ +// Package lifecycle provides types for runtime version lifecycle status. +// Providers can implement StatusProvider to surface lifecycle information +// (e.g., LTS, EOL) in version listings. +package lifecycle + +// Status represents a version's position in its runtime's release lifecycle. +type Status string + +const ( + // Current indicates an actively developed release line (typically the latest). + Current Status = "Current" + + // ActiveLTS indicates a release receiving active long-term support. + ActiveLTS Status = "Active LTS" + + // MaintenanceLTS indicates a release receiving only critical fixes. + MaintenanceLTS Status = "Maintenance LTS" + + // EOL indicates a release that has reached end of life. + EOL Status = "EOL" +) + +// StatusProvider returns lifecycle status for a given version string. +// Providers that track release schedules (e.g., Node.js) can implement +// this interface so list-all displays lifecycle information. +type StatusProvider interface { + // VersionStatus returns the lifecycle status label for the given version, + // or an empty string if the status is unknown. + VersionStatus(version string) string +} diff --git a/src/internal/runtime/version.go b/src/internal/runtime/version.go index 97bb57a..021c66b 100644 --- a/src/internal/runtime/version.go +++ b/src/internal/runtime/version.go @@ -53,10 +53,10 @@ func (iv InstalledVersion) String() string { // AvailableVersion represents a version available for installation type AvailableVersion struct { Version - DownloadURL string - Size int64 - Checksum string - Notes string // Optional notes (e.g., "LTS", "Latest", "Stable") + DownloadURL string + Size int64 + Checksum string + LifecycleStatus string // Optional lifecycle label (e.g., "Active LTS", "EOL") } // DetectedVersion represents a runtime version found on the system diff --git a/src/runtimes/node/data/schedule.json b/src/runtimes/node/data/schedule.json new file mode 100644 index 0000000..f945d37 --- /dev/null +++ b/src/runtimes/node/data/schedule.json @@ -0,0 +1,153 @@ +{ + "v0.8": { + "start": "2012-06-25", + "end": "2014-07-31" + }, + "v0.10": { + "start": "2013-03-11", + "end": "2016-10-31" + }, + "v0.12": { + "start": "2015-02-06", + "end": "2016-12-31" + }, + "v4": { + "start": "2015-09-08", + "lts": "2015-10-12", + "maintenance": "2017-04-01", + "end": "2018-04-30", + "codename": "Argon" + }, + "v5": { + "start": "2015-10-29", + "maintenance": "2016-04-30", + "end": "2016-06-30" + }, + "v6": { + "start": "2016-04-26", + "lts": "2016-10-18", + "maintenance": "2018-04-30", + "end": "2019-04-30", + "codename": "Boron" + }, + "v7": { + "start": "2016-10-25", + "maintenance": "2017-04-30", + "end": "2017-06-30" + }, + "v8": { + "start": "2017-05-30", + "lts": "2017-10-31", + "maintenance": "2019-01-01", + "end": "2019-12-31", + "codename": "Carbon" + }, + "v9": { + "start": "2017-10-01", + "maintenance": "2018-04-01", + "end": "2018-06-30" + }, + "v10": { + "start": "2018-04-24", + "lts": "2018-10-30", + "maintenance": "2020-05-19", + "end": "2021-04-30", + "codename": "Dubnium" + }, + "v11": { + "start": "2018-10-23", + "maintenance": "2019-04-22", + "end": "2019-06-01" + }, + "v12": { + "start": "2019-04-23", + "lts": "2019-10-21", + "maintenance": "2020-11-30", + "end": "2022-04-30", + "codename": "Erbium" + }, + "v13": { + "start": "2019-10-22", + "maintenance": "2020-04-01", + "end": "2020-06-01" + }, + "v14": { + "start": "2020-04-21", + "lts": "2020-10-27", + "maintenance": "2021-10-19", + "end": "2023-04-30", + "codename": "Fermium" + }, + "v15": { + "start": "2020-10-20", + "maintenance": "2021-04-01", + "end": "2021-06-01" + }, + "v16": { + "start": "2021-04-20", + "lts": "2021-10-26", + "maintenance": "2022-10-18", + "end": "2023-09-11", + "codename": "Gallium" + }, + "v17": { + "start": "2021-10-19", + "maintenance": "2022-04-01", + "end": "2022-06-01" + }, + "v18": { + "start": "2022-04-19", + "lts": "2022-10-25", + "maintenance": "2023-10-18", + "end": "2025-04-30", + "codename": "Hydrogen" + }, + "v19": { + "start": "2022-10-18", + "maintenance": "2023-04-01", + "end": "2023-06-01" + }, + "v20": { + "start": "2023-04-18", + "lts": "2023-10-24", + "maintenance": "2024-10-22", + "end": "2026-04-30", + "codename": "Iron" + }, + "v21": { + "start": "2023-10-17", + "maintenance": "2024-04-01", + "end": "2024-06-01" + }, + "v22": { + "start": "2024-04-24", + "lts": "2024-10-29", + "maintenance": "2025-10-21", + "end": "2027-04-30", + "codename": "Jod" + }, + "v23": { + "start": "2024-10-16", + "maintenance": "2025-04-01", + "end": "2025-06-01" + }, + "v24": { + "start": "2025-05-06", + "lts": "2025-10-28", + "maintenance": "2026-10-20", + "end": "2028-04-30", + "codename": "Krypton" + }, + "v25": { + "start": "2025-10-15", + "maintenance": "2026-04-01", + "end": "2026-06-01" + }, + "v26": { + "start": "2026-04-22", + "lts": "2026-10-28", + "maintenance": "2027-10-20", + "end": "2029-04-30", + "codename": "" + } +} diff --git a/src/runtimes/node/lifecycle.go b/src/runtimes/node/lifecycle.go new file mode 100644 index 0000000..735b569 --- /dev/null +++ b/src/runtimes/node/lifecycle.go @@ -0,0 +1,135 @@ +package node + +import ( + "embed" + "encoding/json" + "strconv" + "strings" + "time" + + "github.com/CodingWithCalvin/dtvem.cli/src/internal/lifecycle" + "github.com/CodingWithCalvin/dtvem.cli/src/internal/ui" +) + +//go:embed data/schedule.json +var scheduleData embed.FS + +// scheduleEntry represents a single major-version entry from the Node.js +// release schedule (https://github.com/nodejs/Release/blob/main/schedule.json). +type scheduleEntry struct { + Start string `json:"start"` + LTS string `json:"lts,omitempty"` + Maintenance string `json:"maintenance,omitempty"` + End string `json:"end"` + Codename string `json:"codename,omitempty"` +} + +// lifecycleProvider computes Node.js lifecycle status from the embedded +// release schedule. It implements lifecycle.StatusProvider. +type lifecycleProvider struct { + schedule map[string]scheduleEntry + now time.Time +} + +// Ensure lifecycleProvider satisfies the interface at compile time. +var _ lifecycle.StatusProvider = (*lifecycleProvider)(nil) + +// newLifecycleProvider parses the embedded schedule and returns a provider +// that resolves lifecycle status relative to the current date. +func newLifecycleProvider() *lifecycleProvider { + return newLifecycleProviderAt(time.Now()) +} + +// newLifecycleProviderAt is a test-friendly variant that accepts an explicit +// reference date instead of using the wall clock. +func newLifecycleProviderAt(now time.Time) *lifecycleProvider { + data, err := scheduleData.ReadFile("data/schedule.json") + if err != nil { + ui.Debug("lifecycle: failed to read embedded schedule: %v", err) + return &lifecycleProvider{now: now} + } + + var schedule map[string]scheduleEntry + if err := json.Unmarshal(data, &schedule); err != nil { + ui.Debug("lifecycle: failed to parse schedule: %v", err) + return &lifecycleProvider{now: now} + } + + return &lifecycleProvider{schedule: schedule, now: now} +} + +// VersionStatus returns the lifecycle label for a Node.js version string +// (e.g., "22.14.0" → "Active LTS"). Returns "" if unknown. +func (lp *lifecycleProvider) VersionStatus(version string) string { + major := extractMajor(version) + if major < 0 { + return "" + } + + key := "v" + strconv.Itoa(major) + entry, ok := lp.schedule[key] + if !ok { + return "" + } + + return lp.resolve(entry) +} + +// resolve determines the lifecycle status of a schedule entry relative to lp.now. +func (lp *lifecycleProvider) resolve(e scheduleEntry) string { + today := lp.now + + end := parseDate(e.End) + if end.IsZero() { + return "" + } + if !today.Before(end) { + return string(lifecycle.EOL) + } + + if maint := parseDate(e.Maintenance); !maint.IsZero() && !today.Before(maint) { + if e.LTS != "" { + return string(lifecycle.MaintenanceLTS) + } + // Odd releases enter "maintenance" before EOL but are not LTS. + return string(lifecycle.EOL) + } + + if lts := parseDate(e.LTS); !lts.IsZero() && !today.Before(lts) { + return string(lifecycle.ActiveLTS) + } + + start := parseDate(e.Start) + if !start.IsZero() && !today.Before(start) { + return string(lifecycle.Current) + } + + return "" +} + +// extractMajor returns the major version from a version string like "22.14.0". +// Returns -1 if parsing fails. +func extractMajor(version string) int { + version = strings.TrimPrefix(version, "v") + parts := strings.SplitN(version, ".", 2) + if len(parts) == 0 { + return -1 + } + major, err := strconv.Atoi(parts[0]) + if err != nil { + return -1 + } + return major +} + +// parseDate parses a "YYYY-MM-DD" date string. Returns zero time on failure. +func parseDate(s string) time.Time { + if s == "" { + return time.Time{} + } + t, err := time.Parse("2006-01-02", s) + if err != nil { + return time.Time{} + } + return t +} diff --git a/src/runtimes/node/lifecycle_test.go b/src/runtimes/node/lifecycle_test.go new file mode 100644 index 0000000..a14ed5f --- /dev/null +++ b/src/runtimes/node/lifecycle_test.go @@ -0,0 +1,148 @@ +package node + +import ( + "testing" + "time" + + "github.com/CodingWithCalvin/dtvem.cli/src/internal/lifecycle" +) + +func mustParseDate(s string) time.Time { + t, err := time.Parse("2006-01-02", s) + if err != nil { + panic(err) + } + return t +} + +func TestLifecycleProvider_VersionStatus(t *testing.T) { + tests := []struct { + name string + now string + version string + want string + }{ + // v22: start 2024-04-24, lts 2024-10-29, maintenance 2025-10-21, end 2027-04-30 + { + name: "v22 before LTS is Current", + now: "2024-06-15", + version: "22.3.0", + want: string(lifecycle.Current), + }, + { + name: "v22 after LTS date is Active LTS", + now: "2025-01-15", + version: "22.14.0", + want: string(lifecycle.ActiveLTS), + }, + { + name: "v22 after maintenance is Maintenance LTS", + now: "2026-01-15", + version: "22.14.0", + want: string(lifecycle.MaintenanceLTS), + }, + { + name: "v22 after end is EOL", + now: "2027-05-01", + version: "22.14.0", + want: string(lifecycle.EOL), + }, + // v23: start 2024-10-16, maintenance 2025-04-01, end 2025-06-01 (no LTS) + { + name: "v23 during active period is Current", + now: "2025-01-15", + version: "23.5.0", + want: string(lifecycle.Current), + }, + { + name: "v23 odd version in maintenance is EOL (not LTS)", + now: "2025-04-15", + version: "23.5.0", + want: string(lifecycle.EOL), + }, + { + name: "v23 after end is EOL", + now: "2025-07-01", + version: "23.5.0", + want: string(lifecycle.EOL), + }, + // Edge cases + { + name: "unknown major returns empty", + now: "2025-01-15", + version: "99.0.0", + want: "", + }, + { + name: "invalid version returns empty", + now: "2025-01-15", + version: "abc", + want: "", + }, + { + name: "version before start returns empty", + now: "2024-04-01", + version: "22.0.0", + want: "", + }, + { + name: "version with v prefix works", + now: "2025-01-15", + version: "v22.14.0", + want: string(lifecycle.ActiveLTS), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + lp := newLifecycleProviderAt(mustParseDate(tt.now)) + got := lp.VersionStatus(tt.version) + if got != tt.want { + t.Errorf("VersionStatus(%q) at %s = %q, want %q", tt.version, tt.now, got, tt.want) + } + }) + } +} + +func TestExtractMajor(t *testing.T) { + tests := []struct { + version string + want int + }{ + {"22.14.0", 22}, + {"v22.14.0", 22}, + {"18.16.0", 18}, + {"0.12.0", 0}, + {"abc", -1}, + {"", -1}, + } + + for _, tt := range tests { + t.Run(tt.version, func(t *testing.T) { + got := extractMajor(tt.version) + if got != tt.want { + t.Errorf("extractMajor(%q) = %d, want %d", tt.version, got, tt.want) + } + }) + } +} + +func TestParseDate(t *testing.T) { + tests := []struct { + input string + zero bool + }{ + {"2024-04-24", false}, + {"", true}, + {"invalid", true}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := parseDate(tt.input) + if got.IsZero() != tt.zero { + t.Errorf("parseDate(%q).IsZero() = %v, want %v", tt.input, got.IsZero(), tt.zero) + } + }) + } +} diff --git a/src/runtimes/node/provider.go b/src/runtimes/node/provider.go index f843a8d..688a0ab 100644 --- a/src/runtimes/node/provider.go +++ b/src/runtimes/node/provider.go @@ -215,12 +215,15 @@ func (p *Provider) ListAvailable() ([]runtime.AvailableVersion, error) { platform := manifest.CurrentPlatform() versionStrings := m.ListAvailableVersions(platform) + // Build lifecycle provider for status labels + lp := newLifecycleProvider() + // Convert to AvailableVersion format and sort by semantic version (newest first) versions := make([]runtime.AvailableVersion, 0, len(versionStrings)) for _, v := range versionStrings { versions = append(versions, runtime.AvailableVersion{ - Version: runtime.NewVersion(v), - Notes: "", + Version: runtime.NewVersion(v), + LifecycleStatus: lp.VersionStatus(v), }) } diff --git a/src/runtimes/python/provider.go b/src/runtimes/python/provider.go index 168529f..10086c8 100644 --- a/src/runtimes/python/provider.go +++ b/src/runtimes/python/provider.go @@ -380,7 +380,6 @@ func (p *Provider) ListAvailable() ([]runtime.AvailableVersion, error) { for _, v := range versionStrings { versions = append(versions, runtime.AvailableVersion{ Version: runtime.NewVersion(v), - Notes: "", }) } diff --git a/src/runtimes/ruby/provider.go b/src/runtimes/ruby/provider.go index 997d98b..3730463 100644 --- a/src/runtimes/ruby/provider.go +++ b/src/runtimes/ruby/provider.go @@ -300,7 +300,6 @@ func (p *Provider) ListAvailable() ([]runtime.AvailableVersion, error) { for _, v := range versionStrings { versions = append(versions, runtime.AvailableVersion{ Version: runtime.NewVersion(v), - Notes: "", }) } From 927318121ef57a00e0ecbf80b7f932b9f693a9fc Mon Sep 17 00:00:00 2001 From: "Calvin A. Allen" Date: Tue, 10 Mar 2026 13:20:04 -0400 Subject: [PATCH 2/2] feat(lifecycle): color-code lifecycle status labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Current → Cyan, Active LTS → Green (bold), Maintenance LTS → Yellow, EOL → Red. Applied via tui.RenderLifecycleStatus so the coloring is centralized and reusable. --- src/cmd/list.go | 9 +++++---- src/cmd/list_test.go | 12 ++++++++---- src/internal/tui/styles.go | 23 +++++++++++++++++++++++ 3 files changed, 36 insertions(+), 8 deletions(-) diff --git a/src/cmd/list.go b/src/cmd/list.go index 6d1bbdb..3b3bb47 100644 --- a/src/cmd/list.go +++ b/src/cmd/list.go @@ -161,16 +161,17 @@ func getVersionStatus(version, globalVersion, localVersion string) string { } // getVersionStatusWithLifecycle returns a status string that combines the -// global/local indicators with an optional lifecycle label (e.g., "Active LTS"). +// global/local indicators with a color-coded lifecycle label (e.g., "Active LTS"). func getVersionStatusWithLifecycle(version, globalVersion, localVersion, lifecycleStatus string) string { base := getVersionStatus(version, globalVersion, localVersion) - if lifecycleStatus == "" { + colored := tui.RenderLifecycleStatus(lifecycleStatus) + if colored == "" { return base } if base == "" { - return lifecycleStatus + return colored } - return base + " · " + lifecycleStatus + return base + " · " + colored } // isVersionActive returns true if this version is the currently active one diff --git a/src/cmd/list_test.go b/src/cmd/list_test.go index ea251c4..19f477b 100644 --- a/src/cmd/list_test.go +++ b/src/cmd/list_test.go @@ -1,6 +1,10 @@ package cmd -import "testing" +import ( + "testing" + + "github.com/CodingWithCalvin/dtvem.cli/src/internal/tui" +) func TestGetVersionStatusWithLifecycle(t *testing.T) { tests := []struct { @@ -17,7 +21,7 @@ func TestGetVersionStatusWithLifecycle(t *testing.T) { globalVersion: "", localVersion: "", lifecycleStatus: "Active LTS", - want: "Active LTS", + want: tui.RenderLifecycleStatus("Active LTS"), }, { name: "global only", @@ -33,7 +37,7 @@ func TestGetVersionStatusWithLifecycle(t *testing.T) { globalVersion: "22.14.0", localVersion: "", lifecycleStatus: "Active LTS", - want: globalIndicator + " global" + " · " + "Active LTS", + want: globalIndicator + " global" + " · " + tui.RenderLifecycleStatus("Active LTS"), }, { name: "local with lifecycle", @@ -41,7 +45,7 @@ func TestGetVersionStatusWithLifecycle(t *testing.T) { globalVersion: "", localVersion: "22.14.0", lifecycleStatus: "Maintenance LTS", - want: localIndicator + " local" + " · " + "Maintenance LTS", + want: localIndicator + " local" + " · " + tui.RenderLifecycleStatus("Maintenance LTS"), }, { name: "no status at all", diff --git a/src/internal/tui/styles.go b/src/internal/tui/styles.go index d5253a2..a6753ef 100644 --- a/src/internal/tui/styles.go +++ b/src/internal/tui/styles.go @@ -201,3 +201,26 @@ func GetCrossMark() string { initStyles() return CrossMark } + +// RenderLifecycleStatus renders a lifecycle status label with color coding. +// Current → Cyan, Active LTS → Green, Maintenance LTS → Yellow, EOL → Red. +// Unknown labels are returned unstyled. +func RenderLifecycleStatus(status string) string { + if status == "" { + return "" + } + initStyles() + + switch status { + case "Current": + return lipgloss.NewStyle().Foreground(colorPrimary).Render(status) + case "Active LTS": + return lipgloss.NewStyle().Foreground(colorSuccess).Bold(true).Render(status) + case "Maintenance LTS": + return lipgloss.NewStyle().Foreground(colorWarning).Render(status) + case "EOL": + return lipgloss.NewStyle().Foreground(colorError).Render(status) + default: + return status + } +}