From a5ae5782a7f023fab8e7b3c65d177cf7ae3ef4f7 Mon Sep 17 00:00:00 2001 From: dipto0321 Date: Mon, 29 Jun 2026 23:06:07 +0600 Subject: [PATCH 1/3] feat(packages): implement snapshot and restore for global npm packages - npm ls -g --json --depth=0 parsing into Package structs - Snapshot save to ~/.nodeup/snapshots/-.json - Restore packages via npm install -g - Skip bundled packages (npm, corepack, npx) See .puku/plans/nodeup_v1_build_plan_3481ccad.plan.md Phase 3. --- internal/cli/packages.go | 149 ++++++++++++++++++------ internal/packages/snapshot.go | 205 ++++++++++++++++++++++++++++++++++ 2 files changed, 321 insertions(+), 33 deletions(-) create mode 100644 internal/packages/snapshot.go diff --git a/internal/cli/packages.go b/internal/cli/packages.go index 8d5810e..cf6cba4 100644 --- a/internal/cli/packages.go +++ b/internal/cli/packages.go @@ -1,63 +1,146 @@ 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 { + cmd := &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 + return runSnapshot(cmd, args) }, - }) + } + return cmd +} - cmd.AddCommand(&cobra.Command{ +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) + } + + 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 { + cmd := &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 + return runPackagesList(cmd, args) }, - }) + } + return cmd +} + +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 + } - cmd.AddCommand(&cobra.Command{ - Use: "restore", + 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 { + cmd := &cobra.Command{ + Use: "restore ", Short: "Re-install packages from a snapshot", + Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { - cmd.Println("nodeup packages restore — not yet implemented (Phase 3)") - return nil + return runRestore(cmd, args) }, - }) + } + return cmd +} + +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.AddCommand(&cobra.Command{ - Use: "diff", + cmd.Printf("Restored packages for %s %s\n", managerName, versionStr) + return nil +} + +func newDiffCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "diff ", Short: "Diff two snapshots", RunE: func(cmd *cobra.Command, args []string) error { - cmd.Println("nodeup packages diff — not yet implemented (Phase 3)") - return nil + return fmt.Errorf("diff not yet implemented") }, - }) - + } return cmd -} +} \ No newline at end of file diff --git a/internal/packages/snapshot.go b/internal/packages/snapshot.go new file mode 100644 index 0000000..d4a5c15 --- /dev/null +++ b/internal/packages/snapshot.go @@ -0,0 +1,205 @@ +package packages + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/Masterminds/semver/v3" + + "github.com/dipto0321/nodeup/internal/platform" +) + +// Package represents a globally installed npm package. +type Package struct { + Name string + Version string +} + +// Snapshot captures all globally installed npm packages for a given Node version. +// It calls `npm ls -g --json --depth=0` and writes the result to disk. +func Snapshot(ctx context.Context, managerName string, version semver.Version) error { + output, err := runNpmListGlobal(ctx) + if err != nil { + return fmt.Errorf("list global packages: %w", err) + } + + var npmOutput struct { + Dependencies map[string]struct { + Version string `json:"version"` + } `json:"dependencies"` + } + if err := json.Unmarshal(output, &npmOutput); err != nil { + return fmt.Errorf("parse npm output: %w", err) + } + + pkgs := parsePackages(npmOutput.Dependencies) + snapshot := snapshot{ + Manager: managerName, + NodeVersion: version.String(), + Packages: pkgs, + } + + return saveSnapshot(snapshot) +} + +func runNpmListGlobal(ctx context.Context) ([]byte, error) { + res, err := platform.RunShell(ctx, "npm", "ls", "-g", "--json", "--depth=0") + if err != nil { + return nil, err + } + return []byte(res.Stdout), nil +} + +func parsePackages(deps map[string]struct { + Version string `json:"version"` +}) []Package { + pkgs := make([]Package, 0, len(deps)) + for name, info := range deps { + // Skip bundled packages + if shouldSkipPackage(name) { + continue + } + pkgs = append(pkgs, Package{Name: name, Version: info.Version}) + } + return pkgs +} + +// Default skip list matches docs/configuration.md +var skipPackages = map[string]bool{ + "npm": true, + "corepack": true, + "npx": true, +} + +func shouldSkipPackage(name string) bool { + return skipPackages[name] || strings.HasPrefix(name, "@npm:") +} + +// SnapshotData represents the on-disk snapshot format. +// Exported for CLI access. +type SnapshotData struct { + Manager string `json:"manager"` + NodeVersion string `json:"node_version"` + Packages []Package `json:"packages"` +} + +type snapshot struct { + Manager string `json:"manager"` + NodeVersion string `json:"node_version"` + Packages []Package `json:"packages"` +} + +func snapshotPath(managerName, version string) (string, error) { + dir, err := platform.SnapshotsDir() + if err != nil { + return "", err + } + filename := fmt.Sprintf("%s-%s.json", managerName, version) + return filepath.Join(dir, filename), nil +} + +func saveSnapshot(s snapshot) error { + path, err := snapshotPath(s.Manager, s.NodeVersion) + if err != nil { + return err + } + + data, err := json.MarshalIndent(s, "", " ") + if err != nil { + return fmt.Errorf("marshal snapshot: %w", err) + } + + return os.WriteFile(path, data, 0o644) +} + +// Restore reinstalls packages from a snapshot. +func Restore(ctx context.Context, managerName string, version semver.Version) error { + path, err := snapshotPath(managerName, version.String()) + if err != nil { + return err + } + + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read snapshot: %w", err) + } + + var s snapshot + if err := json.Unmarshal(data, &s); err != nil { + return fmt.Errorf("parse snapshot: %w", err) + } + + return installPackages(ctx, s.Packages) +} + +func installPackages(ctx context.Context, pkgs []Package) error { + for _, pkg := range pkgs { + _, err := platform.RunShell(ctx, "npm", "install", "-g", pkgSpec(pkg)) + if err != nil { + return fmt.Errorf("install %s: %w", pkg.Name, err) + } + } + return nil +} + +func pkgSpec(p Package) string { + if p.Version == "" { + return p.Name + } + return fmt.Sprintf("%s@%s", p.Name, p.Version) +} + +// LoadSnapshot reads a snapshot file without the parsed version. +func LoadSnapshot(managerName, version string) (snapshot, error) { + path, err := snapshotPath(managerName, version) + if err != nil { + return snapshot{}, err + } + + data, err := os.ReadFile(path) + if err != nil { + return snapshot{}, err + } + + var s snapshot + if err := json.Unmarshal(data, &s); err != nil { + return snapshot{}, fmt.Errorf("parse snapshot: %w", err) + } + + return s, nil +} + +// ListSnapshots returns all snapshot files in the snapshots directory. +func ListSnapshots() ([]snapshot, error) { + dir, err := platform.SnapshotsDir() + if err != nil { + return nil, err + } + + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + + var result []snapshot + for _, entry := range entries { + if !strings.HasSuffix(entry.Name(), ".json") { + continue + } + data, err := os.ReadFile(filepath.Join(dir, entry.Name())) + if err != nil { + continue + } + var s snapshot + if err := json.Unmarshal(data, &s); err != nil { + continue + } + result = append(result, s) + } + + return result, nil +} \ No newline at end of file From 4865ae54382f48cf39d404a0d4383ec604934285 Mon Sep 17 00:00:00 2001 From: dipto0321 Date: Mon, 29 Jun 2026 23:07:07 +0600 Subject: [PATCH 2/3] docs: update project status table after Phase 2 merge --- README.md | 2 +- internal/cli/packages.go | 26 ++++++++------------------ 2 files changed, 9 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 3aa2111..1f7b933 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/internal/cli/packages.go b/internal/cli/packages.go index cf6cba4..7ead49b 100644 --- a/internal/cli/packages.go +++ b/internal/cli/packages.go @@ -33,14 +33,11 @@ diff the set of globally installed packages per Node.js version.`, } func newSnapshotCmd() *cobra.Command { - cmd := &cobra.Command{ + return &cobra.Command{ Use: "snapshot", Short: "Snapshot the active version's global packages", - RunE: func(cmd *cobra.Command, args []string) error { - return runSnapshot(cmd, args) - }, + RunE: runSnapshot, } - return cmd } func runSnapshot(cmd *cobra.Command, args []string) error { @@ -75,14 +72,11 @@ func getCurrentVersion(m detector.Manager) (semver.Version, error) { } func newPackagesListCmd() *cobra.Command { - cmd := &cobra.Command{ + return &cobra.Command{ Use: "list", Short: "List packages from a snapshot", - RunE: func(cmd *cobra.Command, args []string) error { - return runPackagesList(cmd, args) - }, + RunE: runPackagesList, } - return cmd } func runPackagesList(cmd *cobra.Command, args []string) error { @@ -106,15 +100,12 @@ func runPackagesList(cmd *cobra.Command, args []string) error { } func newRestoreCmd() *cobra.Command { - cmd := &cobra.Command{ + return &cobra.Command{ Use: "restore ", Short: "Re-install packages from a snapshot", Args: cobra.ExactArgs(2), - RunE: func(cmd *cobra.Command, args []string) error { - return runRestore(cmd, args) - }, + RunE: runRestore, } - return cmd } func runRestore(cmd *cobra.Command, args []string) error { @@ -135,12 +126,11 @@ func runRestore(cmd *cobra.Command, args []string) error { } func newDiffCmd() *cobra.Command { - cmd := &cobra.Command{ + return &cobra.Command{ Use: "diff ", Short: "Diff two snapshots", RunE: func(cmd *cobra.Command, args []string) error { return fmt.Errorf("diff not yet implemented") }, } - return cmd -} \ No newline at end of file +} From 6003643cc297fee25d813f24083e5b2ed1f873fb Mon Sep 17 00:00:00 2001 From: dipto0321 Date: Tue, 30 Jun 2026 09:59:27 +0600 Subject: [PATCH 3/3] feat(packages): implement snapshot and restore for global npm packages - npm ls -g --json --depth=0 parsing into Package structs - SnapshotData persisted to ~/.nodeup/snapshots/-.json - Restore packages via npm install -g - Skip bundled packages (npm, corepack, npx) - Add diff subcommand with Added/Removed output --- internal/cli/packages.go | 35 +++++++++++++-- internal/packages/report.go | 63 +++++++++++++++++++++++++++ internal/packages/snapshot.go | 82 ++++++++++++++++++++++------------- 3 files changed, 145 insertions(+), 35 deletions(-) create mode 100644 internal/packages/report.go diff --git a/internal/cli/packages.go b/internal/cli/packages.go index 7ead49b..b003dd7 100644 --- a/internal/cli/packages.go +++ b/internal/cli/packages.go @@ -127,10 +127,37 @@ func runRestore(cmd *cobra.Command, args []string) error { func newDiffCmd() *cobra.Command { return &cobra.Command{ - Use: "diff ", + Use: "diff ", Short: "Diff two snapshots", - RunE: func(cmd *cobra.Command, args []string) error { - return fmt.Errorf("diff not yet implemented") - }, + Args: cobra.ExactArgs(3), + RunE: runDiff, + } +} + +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 } diff --git a/internal/packages/report.go b/internal/packages/report.go new file mode 100644 index 0000000..cb0c8e7 --- /dev/null +++ b/internal/packages/report.go @@ -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/.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) +} diff --git a/internal/packages/snapshot.go b/internal/packages/snapshot.go index d4a5c15..6973ede 100644 --- a/internal/packages/snapshot.go +++ b/internal/packages/snapshot.go @@ -19,6 +19,13 @@ type Package struct { Version string } +// SnapshotData represents the on-disk snapshot format. +type SnapshotData struct { + Manager string `json:"manager"` + NodeVersion string `json:"node_version"` + Packages []Package `json:"packages"` +} + // Snapshot captures all globally installed npm packages for a given Node version. // It calls `npm ls -g --json --depth=0` and writes the result to disk. func Snapshot(ctx context.Context, managerName string, version semver.Version) error { @@ -37,13 +44,13 @@ func Snapshot(ctx context.Context, managerName string, version semver.Version) e } pkgs := parsePackages(npmOutput.Dependencies) - snapshot := snapshot{ - Manager: managerName, - NodeVersion: version.String(), - Packages: pkgs, + snap := SnapshotData{ + Manager: managerName, + NodeVersion: version.String(), + Packages: pkgs, } - return saveSnapshot(snapshot) + return saveSnapshot(snap) } func runNpmListGlobal(ctx context.Context) ([]byte, error) { @@ -79,20 +86,6 @@ func shouldSkipPackage(name string) bool { return skipPackages[name] || strings.HasPrefix(name, "@npm:") } -// SnapshotData represents the on-disk snapshot format. -// Exported for CLI access. -type SnapshotData struct { - Manager string `json:"manager"` - NodeVersion string `json:"node_version"` - Packages []Package `json:"packages"` -} - -type snapshot struct { - Manager string `json:"manager"` - NodeVersion string `json:"node_version"` - Packages []Package `json:"packages"` -} - func snapshotPath(managerName, version string) (string, error) { dir, err := platform.SnapshotsDir() if err != nil { @@ -102,7 +95,7 @@ func snapshotPath(managerName, version string) (string, error) { return filepath.Join(dir, filename), nil } -func saveSnapshot(s snapshot) error { +func saveSnapshot(s SnapshotData) error { path, err := snapshotPath(s.Manager, s.NodeVersion) if err != nil { return err @@ -128,7 +121,7 @@ func Restore(ctx context.Context, managerName string, version semver.Version) er return fmt.Errorf("read snapshot: %w", err) } - var s snapshot + var s SnapshotData if err := json.Unmarshal(data, &s); err != nil { return fmt.Errorf("parse snapshot: %w", err) } @@ -153,28 +146,28 @@ func pkgSpec(p Package) string { return fmt.Sprintf("%s@%s", p.Name, p.Version) } -// LoadSnapshot reads a snapshot file without the parsed version. -func LoadSnapshot(managerName, version string) (snapshot, error) { +// LoadSnapshot reads a snapshot file. +func LoadSnapshot(managerName, version string) (SnapshotData, error) { path, err := snapshotPath(managerName, version) if err != nil { - return snapshot{}, err + return SnapshotData{}, err } data, err := os.ReadFile(path) if err != nil { - return snapshot{}, err + return SnapshotData{}, err } - var s snapshot + var s SnapshotData if err := json.Unmarshal(data, &s); err != nil { - return snapshot{}, fmt.Errorf("parse snapshot: %w", err) + return SnapshotData{}, fmt.Errorf("parse snapshot: %w", err) } return s, nil } // ListSnapshots returns all snapshot files in the snapshots directory. -func ListSnapshots() ([]snapshot, error) { +func ListSnapshots() ([]SnapshotData, error) { dir, err := platform.SnapshotsDir() if err != nil { return nil, err @@ -185,7 +178,7 @@ func ListSnapshots() ([]snapshot, error) { return nil, err } - var result []snapshot + var result []SnapshotData for _, entry := range entries { if !strings.HasSuffix(entry.Name(), ".json") { continue @@ -194,7 +187,7 @@ func ListSnapshots() ([]snapshot, error) { if err != nil { continue } - var s snapshot + var s SnapshotData if err := json.Unmarshal(data, &s); err != nil { continue } @@ -202,4 +195,31 @@ func ListSnapshots() ([]snapshot, error) { } return result, nil -} \ No newline at end of file +} + +// DiffSnapshots compares two snapshots and returns added/removed packages. +func DiffSnapshots(v1, v2 []Package) (added, removed []Package) { + v1Map := make(map[string]bool) + for _, p := range v1 { + v1Map[p.Name] = true + } + + for _, p := range v2 { + if !v1Map[p.Name] { + added = append(added, p) + } + } + + v2Map := make(map[string]bool) + for _, p := range v2 { + v2Map[p.Name] = true + } + + for _, p := range v1 { + if !v2Map[p.Name] { + removed = append(removed, p) + } + } + + return added, removed +}