Skip to content

Commit a07e89c

Browse files
committed
feat(extensions): implement gh-style extension system for gpd
Add a complete extension system that allows third-party developers to ship installable subcommands for gpd, similar to GitHub CLI's extension mechanism. Features: - Extension install from GitHub repos (owner/repo format) - Local path installation for development - GitHub Release artifact download with platform detection - Git clone fallback for script extensions - Pinned extension support (--pin flag) - Extension list, remove, upgrade commands - Direct extension execution (gpd <extension-name>) - Built-in command conflict detection - Full test coverage (50+ tests) Commands added: - gpd extension install <source> - gpd extension list [--output json|table] - gpd extension remove <name> - gpd extension upgrade [<name>|--all] - gpd extension exec <name> [args...] Files added: - internal/extensions/*.go - Core extension management - internal/cli/kong_extensions.go - CLI commands - internal/cli/extension_runner*.go - Platform-specific execution - *_test.go - Comprehensive test coverage Refs: gh-style extension system
1 parent fb58ddc commit a07e89c

10 files changed

Lines changed: 2906 additions & 0 deletions

internal/cli/extension_runner.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package cli
2+
3+
import (
4+
"os"
5+
"os/exec"
6+
7+
"github.com/dl-alexandre/Google-Play-Developer-CLI/internal/extensions"
8+
"github.com/dl-alexandre/Google-Play-Developer-CLI/internal/logging"
9+
)
10+
11+
// tryRunExtension attempts to execute an extension if the command is an extension name.
12+
// Returns true if an extension was found and executed, false otherwise.
13+
func tryRunExtension(args []string) bool {
14+
if len(args) == 0 {
15+
return false
16+
}
17+
18+
cmdName := args[0]
19+
20+
// Skip if it's a known global flag or built-in command
21+
if isGlobalFlag(cmdName) || extensions.IsBuiltInCommand(cmdName) {
22+
return false
23+
}
24+
25+
// Check if this is an installed extension
26+
if !extensions.IsInstalled(cmdName) {
27+
return false
28+
}
29+
30+
// Get the extension executable path
31+
execPath, err := extensions.GetExecutablePath(cmdName)
32+
if err != nil {
33+
logging.Debug("Failed to get extension executable path",
34+
logging.String("extension", cmdName),
35+
logging.String("error", err.Error()),
36+
)
37+
return false
38+
}
39+
40+
// Verify the executable exists
41+
if _, err := os.Stat(execPath); os.IsNotExist(err) {
42+
logging.Debug("Extension executable not found",
43+
logging.String("extension", cmdName),
44+
logging.String("path", execPath),
45+
)
46+
return false
47+
}
48+
49+
// Execute the extension with remaining arguments
50+
// Use syscall.Exec on Unix systems for proper signal handling
51+
// Fall back to os/exec on Windows
52+
extArgs := args[1:]
53+
54+
logging.Debug("Executing extension",
55+
logging.String("extension", cmdName),
56+
logging.String("executable", execPath),
57+
logging.Int("arg_count", len(extArgs)),
58+
)
59+
60+
// Try execve on Unix for proper signal handling
61+
if syscallExecAvailable() {
62+
err := execExtension(execPath, extArgs)
63+
if err != nil {
64+
logging.Debug("syscall.Exec failed, falling back to os/exec",
65+
logging.String("error", err.Error()),
66+
)
67+
// Fall through to os/exec
68+
}
69+
}
70+
71+
// Use os/exec for Windows or as fallback
72+
cmd := exec.Command(execPath, extArgs...)
73+
cmd.Stdin = os.Stdin
74+
cmd.Stdout = os.Stdout
75+
cmd.Stderr = os.Stderr
76+
77+
if err := cmd.Run(); err != nil {
78+
if exitErr, ok := err.(*exec.ExitError); ok {
79+
// Propagate the exit code
80+
if exitErr.ExitCode() != 0 {
81+
os.Exit(exitErr.ExitCode())
82+
}
83+
} else {
84+
logging.Debug("Extension execution failed",
85+
logging.String("extension", cmdName),
86+
logging.String("error", err.Error()),
87+
)
88+
os.Exit(1)
89+
}
90+
}
91+
92+
os.Exit(0)
93+
return true // Should never reach here
94+
}
95+
96+
// isGlobalFlag checks if the argument is a global flag.
97+
func isGlobalFlag(arg string) bool {
98+
globalFlags := []string{
99+
"-h", "--help",
100+
"-v", "--version",
101+
"--verbose",
102+
"--package",
103+
"--output",
104+
"--pretty",
105+
"--timeout",
106+
"--key",
107+
"--profile",
108+
}
109+
110+
for _, flag := range globalFlags {
111+
if arg == flag {
112+
return true
113+
}
114+
}
115+
return false
116+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
//go:build !windows
2+
// +build !windows
3+
4+
package cli
5+
6+
import "syscall"
7+
8+
// syscallExecAvailable returns true on Unix systems where syscall.Exec is available.
9+
func syscallExecAvailable() bool {
10+
// syscall.Exec is always available on Unix systems (Linux, macOS, BSD, etc.)
11+
return true
12+
}
13+
14+
// execExtension executes the extension using syscall.Exec on Unix systems.
15+
func execExtension(execPath string, args []string) error {
16+
return syscall.Exec(execPath, append([]string{execPath}, args...), syscall.Environ())
17+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
//go:build windows
2+
// +build windows
3+
4+
package cli
5+
6+
// syscallExecAvailable returns false on Windows since syscall.Exec is not available.
7+
func syscallExecAvailable() bool {
8+
// Windows does not support syscall.Exec
9+
// We always use os/exec.Command on Windows
10+
return false
11+
}
12+
13+
// execExtension is a no-op on Windows since we always use os/exec.
14+
// This function exists for API compatibility but should never be called on Windows.
15+
func execExtension(execPath string, args []string) error {
16+
// On Windows, we use exec.Command instead
17+
// This function should not be called on Windows
18+
panic("execExtension should not be called on Windows - use exec.Command instead")
19+
}

0 commit comments

Comments
 (0)