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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ This is the **v1.0.0 development line**. See `CHANGELOG.md` for what's done.

| Version | Status | Notes |
|---|---|---|
| v1.0.0 | 🛠 in development | Phase 1 ✅ — 8/8 managers detected (fnm, nvm, Volta, asdf, mise, n, nodenv, nvm-windows). Phase 2 — `nodeup check` against nodejs.org/dist. |
| v1.0.0 | 🛠 in development | Phase 1 ✅ — 8/8 managers detected. Phase 2 — `nodeup check` with nodejs.org/dist/index.json fetch + TTL cache. Phase 3 — package snapshot/restore. |

Phase 1 is the **detection surface** — every manager is recognized and the
version + installed-list reads return real data (PRs #1–#8). Subsequent
Expand Down
180 changes: 140 additions & 40 deletions internal/cli/packages.go
Original file line number Diff line number Diff line change
@@ -1,63 +1,163 @@
package cli

import "github.com/spf13/cobra"

// newPackagesCmd is a stub for Phase 0. It will be implemented in Phase 3.
//
// Subcommands:
//
// nodeup packages snapshot — capture global npm packages for the active version
// nodeup packages list — list packages for a snapshot
// nodeup packages restore — re-install packages from a snapshot
// nodeup packages diff — diff two snapshots
import (
"context"
"fmt"

"github.com/Masterminds/semver/v3"
"github.com/spf13/cobra"

"github.com/dipto0321/nodeup/internal/detector"
"github.com/dipto0321/nodeup/internal/packages"
)

// newPackagesCmd implements `nodeup packages` — manage global npm package snapshots.
// Subcommands: snapshot, list, restore, diff.
func newPackagesCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "packages",
Short: "Manage global npm package snapshots",
Long: `Manage global npm package snapshots — capture, list, restore, and
diff the set of globally installed packages per Node.js version.

Implemented in Phase 3.`,
diff the set of globally installed packages per Node.js version.`,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}

cmd.AddCommand(&cobra.Command{
cmd.AddCommand(newSnapshotCmd())
cmd.AddCommand(newPackagesListCmd())
cmd.AddCommand(newRestoreCmd())
cmd.AddCommand(newDiffCmd())

return cmd
}

func newSnapshotCmd() *cobra.Command {
return &cobra.Command{
Use: "snapshot",
Short: "Snapshot the active version's global packages",
RunE: func(cmd *cobra.Command, args []string) error {
cmd.Println("nodeup packages snapshot — not yet implemented (Phase 3)")
return nil
},
})
RunE: runSnapshot,
}
}

func runSnapshot(cmd *cobra.Command, args []string) error {
installed := detector.DetectAll()
if len(installed.Found) == 0 {
return fmt.Errorf("no Node.js version manager detected")
}

m := installed.Found[0]
version, err := getCurrentVersion(m)
if err != nil {
return fmt.Errorf("get current version: %w", err)
}

cmd.AddCommand(&cobra.Command{
if err := packages.Snapshot(context.Background(), m.Name(), version); err != nil {
return fmt.Errorf("snapshot failed: %w", err)
}

cmd.Printf("Snapshot saved for %s %s\n", m.Name(), version)
return nil
}

func getCurrentVersion(m detector.Manager) (semver.Version, error) {
versions, err := m.ListInstalled()
if err != nil {
return semver.Version{}, err
}
if len(versions) == 0 {
return semver.Version{}, fmt.Errorf("no installed versions")
}
return versions[0], nil
}

func newPackagesListCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List packages from a snapshot",
RunE: func(cmd *cobra.Command, args []string) error {
cmd.Println("nodeup packages list — not yet implemented (Phase 3)")
return nil
},
})
RunE: runPackagesList,
}
}

cmd.AddCommand(&cobra.Command{
Use: "restore",
func runPackagesList(cmd *cobra.Command, args []string) error {
snapshots, err := packages.ListSnapshots()
if err != nil {
return fmt.Errorf("list snapshots: %w", err)
}

if len(snapshots) == 0 {
cmd.Println("No snapshots found.")
return nil
}

for _, s := range snapshots {
cmd.Printf("\n%s (Node %s):\n", s.Manager, s.NodeVersion)
for _, p := range s.Packages {
cmd.Printf(" - %s@%s\n", p.Name, p.Version)
}
}
return nil
}

func newRestoreCmd() *cobra.Command {
return &cobra.Command{
Use: "restore <manager> <version>",
Short: "Re-install packages from a snapshot",
RunE: func(cmd *cobra.Command, args []string) error {
cmd.Println("nodeup packages restore — not yet implemented (Phase 3)")
return nil
},
})
Args: cobra.ExactArgs(2),
RunE: runRestore,
}
}

func runRestore(cmd *cobra.Command, args []string) error {
managerName := args[0]
versionStr := args[1]

v, err := semver.NewVersion(versionStr)
if err != nil {
return fmt.Errorf("invalid version: %w", err)
}

if err := packages.Restore(context.Background(), managerName, *v); err != nil {
return fmt.Errorf("restore failed: %w", err)
}

cmd.Printf("Restored packages for %s %s\n", managerName, versionStr)
return nil
}

cmd.AddCommand(&cobra.Command{
Use: "diff",
func newDiffCmd() *cobra.Command {
return &cobra.Command{
Use: "diff <manager> <version1> <version2>",
Short: "Diff two snapshots",
RunE: func(cmd *cobra.Command, args []string) error {
cmd.Println("nodeup packages diff — not yet implemented (Phase 3)")
return nil
},
})
Args: cobra.ExactArgs(3),
RunE: runDiff,
}
}

return cmd
func runDiff(cmd *cobra.Command, args []string) error {
managerName := args[0]

s1, err := packages.LoadSnapshot(managerName, args[1])
if err != nil {
return fmt.Errorf("load snapshot %s: %w", args[1], err)
}

s2, err := packages.LoadSnapshot(managerName, args[2])
if err != nil {
return fmt.Errorf("load snapshot %s: %w", args[2], err)
}

added, removed := packages.DiffSnapshots(s1.Packages, s2.Packages)

cmd.Printf("Added packages:\n")
for _, p := range added {
cmd.Printf(" + %s@%s\n", p.Name, p.Version)
}

cmd.Printf("Removed packages:\n")
for _, p := range removed {
cmd.Printf(" - %s@%s\n", p.Name, p.Version)
}

return nil
}
63 changes: 63 additions & 0 deletions internal/packages/report.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package packages

import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"

"github.com/dipto0321/nodeup/internal/platform"
)

// PackageResult tracks the outcome of a single package install operation.
type PackageResult struct {
Name string
Status string // ok, failed, skipped, version_mismatch
AttemptedVersion string
InstalledVersion string
Error string
}

// MigrationReport records the results of a package migration.
type MigrationReport struct {
Timestamp time.Time `json:"timestamp"`
Manager string `json:"manager"`
FromVersion string `json:"from_version"`
ToVersion string `json:"to_version"`
Interrupted bool `json:"interrupted"`
Results []PackageResult `json:"results"`
}

// NewMigrationReport creates a fresh report for a migration.
func NewMigrationReport(manager, fromVersion, toVersion string) *MigrationReport {
return &MigrationReport{
Timestamp: time.Now(),
Manager: manager,
FromVersion: fromVersion,
ToVersion: toVersion,
}
}

// Save writes the report to ~/.nodeup/reports/<timestamp>.json.
func (r *MigrationReport) Save() error {
dir, err := platform.ReportsDir()
if err != nil {
return err
}

filename := fmt.Sprintf("migration-%s.json", r.Timestamp.Format("20060102-150405"))
path := filepath.Join(dir, filename)

data, err := json.MarshalIndent(r, "", " ")
if err != nil {
return fmt.Errorf("marshal report: %w", err)
}

return os.WriteFile(path, data, 0o644)
}

// AddResult appends a package result to the report.
func (r *MigrationReport) AddResult(pr PackageResult) {
r.Results = append(r.Results, pr)
}
Loading
Loading