From 312fce343d03b54be829e39328bc72e0c34d45ac Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 28 Jan 2026 19:33:55 +0000 Subject: [PATCH] feat(php): add package management for local development Commands: - core php packages link ... - link local packages - core php packages unlink ... - unlink packages - core php packages update - update linked packages - core php packages list - list linked packages Features: - Composer path repositories with symlink - Auto-detect package name from composer.json - Preserves existing composer.json fields Co-Authored-By: Claude Opus 4.5 --- cmd/core/cmd/php.go | 149 ++++++++++++++++++++++ pkg/php/packages.go | 304 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 453 insertions(+) create mode 100644 pkg/php/packages.go diff --git a/cmd/core/cmd/php.go b/cmd/core/cmd/php.go index 28482531..8356421c 100644 --- a/cmd/core/cmd/php.go +++ b/cmd/core/cmd/php.go @@ -66,6 +66,7 @@ func AddPHPCommands(parent *clir.Cli) { addPHPTestCommand(phpCmd) addPHPFmtCommand(phpCmd) addPHPAnalyseCommand(phpCmd) + addPHPPackagesCommands(phpCmd) } func addPHPDevCommand(parent *clir.Command) { @@ -1002,3 +1003,151 @@ func addPHPAnalyseCommand(parent *clir.Command) { return nil }) } + +func addPHPPackagesCommands(parent *clir.Command) { + packagesCmd := parent.NewSubCommand("packages", "Manage local PHP packages") + packagesCmd.LongDescription("Link and manage local PHP packages for development.\n\n" + + "Similar to npm link, this adds path repositories to composer.json\n" + + "for developing packages alongside your project.\n\n" + + "Commands:\n" + + " link - Link local packages by path\n" + + " unlink - Unlink packages by name\n" + + " update - Update linked packages\n" + + " list - List linked packages") + + addPHPPackagesLinkCommand(packagesCmd) + addPHPPackagesUnlinkCommand(packagesCmd) + addPHPPackagesUpdateCommand(packagesCmd) + addPHPPackagesListCommand(packagesCmd) +} + +func addPHPPackagesLinkCommand(parent *clir.Command) { + linkCmd := parent.NewSubCommand("link", "Link local packages") + linkCmd.LongDescription("Link local PHP packages for development.\n\n" + + "Adds path repositories to composer.json with symlink enabled.\n" + + "The package name is auto-detected from each path's composer.json.\n\n" + + "Examples:\n" + + " core php packages link ../my-package\n" + + " core php packages link ../pkg-a ../pkg-b") + + linkCmd.Action(func() error { + args := linkCmd.OtherArgs() + if len(args) == 0 { + return fmt.Errorf("at least one package path is required") + } + + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get working directory: %w", err) + } + + fmt.Printf("%s Linking packages...\n\n", dimStyle.Render("PHP:")) + + if err := php.LinkPackages(cwd, args); err != nil { + return fmt.Errorf("failed to link packages: %w", err) + } + + fmt.Printf("\n%s Packages linked. Run 'composer update' to install.\n", successStyle.Render("Done:")) + return nil + }) +} + +func addPHPPackagesUnlinkCommand(parent *clir.Command) { + unlinkCmd := parent.NewSubCommand("unlink", "Unlink packages") + unlinkCmd.LongDescription("Remove linked packages from composer.json.\n\n" + + "Removes path repositories by package name.\n\n" + + "Examples:\n" + + " core php packages unlink vendor/my-package\n" + + " core php packages unlink vendor/pkg-a vendor/pkg-b") + + unlinkCmd.Action(func() error { + args := unlinkCmd.OtherArgs() + if len(args) == 0 { + return fmt.Errorf("at least one package name is required") + } + + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get working directory: %w", err) + } + + fmt.Printf("%s Unlinking packages...\n\n", dimStyle.Render("PHP:")) + + if err := php.UnlinkPackages(cwd, args); err != nil { + return fmt.Errorf("failed to unlink packages: %w", err) + } + + fmt.Printf("\n%s Packages unlinked. Run 'composer update' to remove.\n", successStyle.Render("Done:")) + return nil + }) +} + +func addPHPPackagesUpdateCommand(parent *clir.Command) { + updateCmd := parent.NewSubCommand("update", "Update linked packages") + updateCmd.LongDescription("Run composer update for linked packages.\n\n" + + "If no packages specified, updates all packages.\n\n" + + "Examples:\n" + + " core php packages update\n" + + " core php packages update vendor/my-package") + + updateCmd.Action(func() error { + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get working directory: %w", err) + } + + args := updateCmd.OtherArgs() + + fmt.Printf("%s Updating packages...\n\n", dimStyle.Render("PHP:")) + + if err := php.UpdatePackages(cwd, args); err != nil { + return fmt.Errorf("composer update failed: %w", err) + } + + fmt.Printf("\n%s Packages updated\n", successStyle.Render("Done:")) + return nil + }) +} + +func addPHPPackagesListCommand(parent *clir.Command) { + listCmd := parent.NewSubCommand("list", "List linked packages") + listCmd.LongDescription("List all locally linked packages.\n\n" + + "Shows package name, path, and version for each linked package.") + + listCmd.Action(func() error { + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get working directory: %w", err) + } + + packages, err := php.ListLinkedPackages(cwd) + if err != nil { + return fmt.Errorf("failed to list packages: %w", err) + } + + if len(packages) == 0 { + fmt.Printf("%s No linked packages found\n", dimStyle.Render("PHP:")) + return nil + } + + fmt.Printf("%s Linked packages:\n\n", dimStyle.Render("PHP:")) + + for _, pkg := range packages { + name := pkg.Name + if name == "" { + name = "(unknown)" + } + version := pkg.Version + if version == "" { + version = "dev" + } + + fmt.Printf(" %s %s\n", successStyle.Render("*"), name) + fmt.Printf(" %s %s\n", dimStyle.Render("Path:"), pkg.Path) + fmt.Printf(" %s %s\n", dimStyle.Render("Version:"), version) + fmt.Println() + } + + return nil + }) +} diff --git a/pkg/php/packages.go b/pkg/php/packages.go new file mode 100644 index 00000000..e2c2df38 --- /dev/null +++ b/pkg/php/packages.go @@ -0,0 +1,304 @@ +package php + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" +) + +// LinkedPackage represents a linked local package. +type LinkedPackage struct { + Name string `json:"name"` + Path string `json:"path"` + Version string `json:"version"` +} + +// composerRepository represents a composer repository entry. +type composerRepository struct { + Type string `json:"type"` + URL string `json:"url,omitempty"` + Options map[string]any `json:"options,omitempty"` +} + +// readComposerJSON reads and parses composer.json from the given directory. +func readComposerJSON(dir string) (map[string]json.RawMessage, error) { + composerPath := filepath.Join(dir, "composer.json") + data, err := os.ReadFile(composerPath) + if err != nil { + return nil, fmt.Errorf("failed to read composer.json: %w", err) + } + + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("failed to parse composer.json: %w", err) + } + + return raw, nil +} + +// writeComposerJSON writes the composer.json to the given directory. +func writeComposerJSON(dir string, raw map[string]json.RawMessage) error { + composerPath := filepath.Join(dir, "composer.json") + + data, err := json.MarshalIndent(raw, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal composer.json: %w", err) + } + + // Add trailing newline + data = append(data, '\n') + + if err := os.WriteFile(composerPath, data, 0644); err != nil { + return fmt.Errorf("failed to write composer.json: %w", err) + } + + return nil +} + +// getRepositories extracts repositories from raw composer.json. +func getRepositories(raw map[string]json.RawMessage) ([]composerRepository, error) { + reposRaw, ok := raw["repositories"] + if !ok { + return []composerRepository{}, nil + } + + var repos []composerRepository + if err := json.Unmarshal(reposRaw, &repos); err != nil { + return nil, fmt.Errorf("failed to parse repositories: %w", err) + } + + return repos, nil +} + +// setRepositories sets repositories in raw composer.json. +func setRepositories(raw map[string]json.RawMessage, repos []composerRepository) error { + if len(repos) == 0 { + delete(raw, "repositories") + return nil + } + + reposData, err := json.Marshal(repos) + if err != nil { + return fmt.Errorf("failed to marshal repositories: %w", err) + } + + raw["repositories"] = reposData + return nil +} + +// getPackageInfo reads package name and version from a composer.json in the given path. +func getPackageInfo(packagePath string) (name, version string, err error) { + composerPath := filepath.Join(packagePath, "composer.json") + data, err := os.ReadFile(composerPath) + if err != nil { + return "", "", fmt.Errorf("failed to read package composer.json: %w", err) + } + + var pkg struct { + Name string `json:"name"` + Version string `json:"version"` + } + + if err := json.Unmarshal(data, &pkg); err != nil { + return "", "", fmt.Errorf("failed to parse package composer.json: %w", err) + } + + if pkg.Name == "" { + return "", "", fmt.Errorf("package name not found in composer.json") + } + + return pkg.Name, pkg.Version, nil +} + +// LinkPackages adds path repositories to composer.json for local package development. +func LinkPackages(dir string, packages []string) error { + if !IsPHPProject(dir) { + return fmt.Errorf("not a PHP project (missing composer.json)") + } + + raw, err := readComposerJSON(dir) + if err != nil { + return err + } + + repos, err := getRepositories(raw) + if err != nil { + return err + } + + for _, packagePath := range packages { + // Resolve absolute path + absPath, err := filepath.Abs(packagePath) + if err != nil { + return fmt.Errorf("failed to resolve path %s: %w", packagePath, err) + } + + // Verify the path exists and has a composer.json + if !IsPHPProject(absPath) { + return fmt.Errorf("not a PHP package (missing composer.json): %s", absPath) + } + + // Get package name for validation + pkgName, _, err := getPackageInfo(absPath) + if err != nil { + return fmt.Errorf("failed to get package info from %s: %w", absPath, err) + } + + // Check if already linked + alreadyLinked := false + for _, repo := range repos { + if repo.Type == "path" && repo.URL == absPath { + alreadyLinked = true + break + } + } + + if alreadyLinked { + continue + } + + // Add path repository + repos = append(repos, composerRepository{ + Type: "path", + URL: absPath, + Options: map[string]any{ + "symlink": true, + }, + }) + + fmt.Printf("Linked: %s -> %s\n", pkgName, absPath) + } + + if err := setRepositories(raw, repos); err != nil { + return err + } + + return writeComposerJSON(dir, raw) +} + +// UnlinkPackages removes path repositories from composer.json. +func UnlinkPackages(dir string, packages []string) error { + if !IsPHPProject(dir) { + return fmt.Errorf("not a PHP project (missing composer.json)") + } + + raw, err := readComposerJSON(dir) + if err != nil { + return err + } + + repos, err := getRepositories(raw) + if err != nil { + return err + } + + // Build set of packages to unlink + toUnlink := make(map[string]bool) + for _, pkg := range packages { + toUnlink[pkg] = true + } + + // Filter out unlinked packages + filtered := make([]composerRepository, 0, len(repos)) + for _, repo := range repos { + if repo.Type != "path" { + filtered = append(filtered, repo) + continue + } + + // Check if this repo should be unlinked + shouldUnlink := false + + // Try to get package name from the path + if IsPHPProject(repo.URL) { + pkgName, _, err := getPackageInfo(repo.URL) + if err == nil && toUnlink[pkgName] { + shouldUnlink = true + fmt.Printf("Unlinked: %s\n", pkgName) + } + } + + // Also check if path matches any of the provided names + for pkg := range toUnlink { + if repo.URL == pkg || filepath.Base(repo.URL) == pkg { + shouldUnlink = true + fmt.Printf("Unlinked: %s\n", repo.URL) + break + } + } + + if !shouldUnlink { + filtered = append(filtered, repo) + } + } + + if err := setRepositories(raw, filtered); err != nil { + return err + } + + return writeComposerJSON(dir, raw) +} + +// UpdatePackages runs composer update for specific packages. +func UpdatePackages(dir string, packages []string) error { + if !IsPHPProject(dir) { + return fmt.Errorf("not a PHP project (missing composer.json)") + } + + args := []string{"update"} + args = append(args, packages...) + + cmd := exec.Command("composer", args...) + cmd.Dir = dir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + return cmd.Run() +} + +// ListLinkedPackages returns all path repositories from composer.json. +func ListLinkedPackages(dir string) ([]LinkedPackage, error) { + if !IsPHPProject(dir) { + return nil, fmt.Errorf("not a PHP project (missing composer.json)") + } + + raw, err := readComposerJSON(dir) + if err != nil { + return nil, err + } + + repos, err := getRepositories(raw) + if err != nil { + return nil, err + } + + linked := make([]LinkedPackage, 0) + for _, repo := range repos { + if repo.Type != "path" { + continue + } + + pkg := LinkedPackage{ + Path: repo.URL, + } + + // Try to get package info + if IsPHPProject(repo.URL) { + name, version, err := getPackageInfo(repo.URL) + if err == nil { + pkg.Name = name + pkg.Version = version + } + } + + if pkg.Name == "" { + pkg.Name = filepath.Base(repo.URL) + } + + linked = append(linked, pkg) + } + + return linked, nil +}