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 8d5810e..b003dd7 100644 --- a/internal/cli/packages.go +++ b/internal/cli/packages.go @@ -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 ", 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 ", 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 } 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 new file mode 100644 index 0000000..6973ede --- /dev/null +++ b/internal/packages/snapshot.go @@ -0,0 +1,225 @@ +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 +} + +// 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 { + 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) + snap := SnapshotData{ + Manager: managerName, + NodeVersion: version.String(), + Packages: pkgs, + } + + return saveSnapshot(snap) +} + +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:") +} + +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 SnapshotData) 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 SnapshotData + 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. +func LoadSnapshot(managerName, version string) (SnapshotData, error) { + path, err := snapshotPath(managerName, version) + if err != nil { + return SnapshotData{}, err + } + + data, err := os.ReadFile(path) + if err != nil { + return SnapshotData{}, err + } + + var s SnapshotData + if err := json.Unmarshal(data, &s); err != nil { + return SnapshotData{}, fmt.Errorf("parse snapshot: %w", err) + } + + return s, nil +} + +// ListSnapshots returns all snapshot files in the snapshots directory. +func ListSnapshots() ([]SnapshotData, error) { + dir, err := platform.SnapshotsDir() + if err != nil { + return nil, err + } + + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + + var result []SnapshotData + 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 SnapshotData + if err := json.Unmarshal(data, &s); err != nil { + continue + } + result = append(result, s) + } + + return result, nil +} + +// 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 +}