Skip to content

Commit e099717

Browse files
Release package (#5)
1 parent 1496f96 commit e099717

7 files changed

Lines changed: 848 additions & 0 deletions

File tree

internal/cli/resolve.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package cli
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"time"
7+
8+
"github.com/php-debugger/installer/internal/platform"
9+
"github.com/php-debugger/installer/internal/release"
10+
"github.com/spf13/cobra"
11+
)
12+
13+
// resolveOptions mirrors the install flags that affect asset selection.
14+
type resolveOptions struct {
15+
PHPVersion string
16+
ZTS bool
17+
ExtensionOnly bool
18+
}
19+
20+
// newResolveCmd is a hidden diagnostic command: it detects the host, fetches the
21+
// latest release, and prints the asset it would download — without installing
22+
// anything. Useful for verifying platform detection and asset selection.
23+
func newResolveCmd() *cobra.Command {
24+
opts := &resolveOptions{}
25+
26+
cmd := &cobra.Command{
27+
Use: "resolve",
28+
Short: "Print the release asset that would be used for this host (diagnostic)",
29+
Hidden: true,
30+
Args: cobra.NoArgs,
31+
RunE: func(cmd *cobra.Command, args []string) error {
32+
return runResolve(cmd, opts)
33+
},
34+
}
35+
36+
f := cmd.Flags()
37+
f.StringVarP(&opts.PHPVersion, "php", "p", "", "PHP version (default: latest available)")
38+
f.BoolVarP(&opts.ZTS, "zts", "z", false, "thread-safe build")
39+
f.BoolVarP(&opts.ExtensionOnly, "extension-only", "e", false, "resolve the extension instead of the interpreter")
40+
41+
return cmd
42+
}
43+
44+
func runResolve(cmd *cobra.Command, opts *resolveOptions) error {
45+
p, err := platform.Detect()
46+
if err != nil {
47+
return err
48+
}
49+
50+
kind := release.Interpreter
51+
if opts.ExtensionOnly {
52+
kind = release.Extension
53+
}
54+
55+
ctx, cancel := context.WithTimeout(cmd.Context(), 60*time.Second)
56+
defer cancel()
57+
58+
client := release.NewClient()
59+
rel, err := client.LatestRelease(ctx)
60+
if err != nil {
61+
return err
62+
}
63+
64+
series := opts.PHPVersion
65+
if series == "" {
66+
series, err = release.LatestSeries(rel.Assets, kind, opts.ZTS, p.OS, p.Arch)
67+
if err != nil {
68+
return err
69+
}
70+
}
71+
72+
asset, err := release.SelectAsset(rel.Assets, release.Selector{
73+
Kind: kind,
74+
Series: series,
75+
ZTS: opts.ZTS,
76+
OS: p.OS,
77+
Arch: p.Arch,
78+
})
79+
if err != nil {
80+
return err
81+
}
82+
83+
out := cmd.OutOrStdout()
84+
fmt.Fprintf(out, "host: %s\n", p)
85+
fmt.Fprintf(out, "release: %s\n", rel.TagName)
86+
fmt.Fprintf(out, "kind: %s\n", kind)
87+
fmt.Fprintf(out, "php: %s (zts=%t)\n", series, opts.ZTS)
88+
fmt.Fprintf(out, "asset: %s\n", asset.Name)
89+
fmt.Fprintf(out, "url: %s\n", asset.DownloadURL)
90+
if asset.Size > 0 {
91+
fmt.Fprintf(out, "size: %.1f MiB\n", float64(asset.Size)/(1024*1024))
92+
}
93+
return nil
94+
}

internal/cli/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ func newRootCmd() *cobra.Command {
4444
newUpdateCmd(),
4545
newUninstallCmd(),
4646
newSwitchCmd(),
47+
newResolveCmd(),
4748
)
4849

4950
return rootCmd

internal/release/naming.go

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
package release
2+
3+
import (
4+
"fmt"
5+
"regexp"
6+
"sort"
7+
"strconv"
8+
"strings"
9+
10+
"github.com/php-debugger/installer/internal/platform"
11+
)
12+
13+
// Kind distinguishes the two sorts of release asset the installer cares about.
14+
type Kind int
15+
16+
const (
17+
// Interpreter is a self-contained php binary with the debugger compiled in.
18+
Interpreter Kind = iota
19+
// Extension is the debugger loadable extension (.so/.dll).
20+
Extension
21+
)
22+
23+
func (k Kind) String() string {
24+
if k == Extension {
25+
return "extension"
26+
}
27+
return "interpreter"
28+
}
29+
30+
// ParsedAsset is the structured interpretation of a release asset filename.
31+
type ParsedAsset struct {
32+
Kind Kind
33+
Series string // PHP series as it appears in the name, e.g. "8.3"
34+
ZTS bool
35+
OS platform.OS
36+
Arch platform.Arch
37+
}
38+
39+
// Selector describes the asset the caller wants. An empty Series matches any and
40+
// selects the highest available.
41+
type Selector struct {
42+
Kind Kind
43+
Series string
44+
ZTS bool
45+
OS platform.OS
46+
Arch platform.Arch
47+
}
48+
49+
var seriesRe = regexp.MustCompile(`^\d+\.\d+(?:\.\d+)*$`)
50+
51+
// ParseAssetName interprets a release asset filename. It is deliberately
52+
// tolerant: rather than matching an exact template, it detects the asset kind
53+
// from the file suffix (.so/.dll = extension; .exe or none = interpreter) and
54+
// extracts the PHP series, threading model, OS and architecture from the
55+
// hyphen-separated tokens. This absorbs the fact that the two kinds use
56+
// different prefixes. It returns ok=false for anything it does not recognize
57+
// (checksums, source archives, …).
58+
//
59+
// Confirmed naming in release 0.1.0:
60+
// - interpreter: php-php{ver}-{nts|ts}-{os}-{arch}[.exe]
61+
// - extension: php-debugger-php{ver}-{nts|ts}-{os}-{arch}.{so|dll}
62+
//
63+
// This function is the single place that encodes the release naming convention;
64+
// adjust it here if the published names change.
65+
func ParseAssetName(name string) (ParsedAsset, bool) {
66+
lower := strings.ToLower(name)
67+
68+
kind := Interpreter
69+
base := name
70+
switch {
71+
case strings.HasSuffix(lower, ".so"):
72+
kind, base = Extension, name[:len(name)-len(".so")]
73+
case strings.HasSuffix(lower, ".dll"):
74+
kind, base = Extension, name[:len(name)-len(".dll")]
75+
case strings.HasSuffix(lower, ".exe"):
76+
kind, base = Interpreter, name[:len(name)-len(".exe")]
77+
}
78+
79+
var (
80+
p ParsedAsset
81+
haveVer, haveThread, haveOS, haveArch bool
82+
)
83+
p.Kind = kind
84+
85+
for _, tok := range strings.Split(strings.ToLower(base), "-") {
86+
switch {
87+
case strings.HasPrefix(tok, "php") && seriesRe.MatchString(tok[len("php"):]):
88+
p.Series, haveVer = tok[len("php"):], true
89+
case tok == "ts":
90+
p.ZTS, haveThread = true, true
91+
case tok == "nts":
92+
p.ZTS, haveThread = false, true
93+
case tok == "linux":
94+
p.OS, haveOS = platform.Linux, true
95+
case tok == "macos":
96+
p.OS, haveOS = platform.MacOS, true
97+
case tok == "windows":
98+
p.OS, haveOS = platform.Windows, true
99+
case tok == "x86_64":
100+
p.Arch, haveArch = platform.X8664, true
101+
case tok == "arm64":
102+
p.Arch, haveArch = platform.Arm64, true
103+
}
104+
}
105+
106+
if !(haveVer && haveThread && haveOS && haveArch) {
107+
return ParsedAsset{}, false
108+
}
109+
return p, true
110+
}
111+
112+
// SelectAsset returns the asset matching sel. When sel.Series is empty it selects
113+
// the highest available series among the matches. It returns a *NoMatchError
114+
// (listing what is available) if nothing matches.
115+
func SelectAsset(assets []Asset, sel Selector) (Asset, error) {
116+
type candidate struct {
117+
asset Asset
118+
parsed ParsedAsset
119+
}
120+
var matches []candidate
121+
for _, a := range assets {
122+
p, ok := ParseAssetName(a.Name)
123+
if !ok || p.Kind != sel.Kind || p.OS != sel.OS || p.Arch != sel.Arch || p.ZTS != sel.ZTS {
124+
continue
125+
}
126+
if sel.Series != "" && p.Series != sel.Series {
127+
continue
128+
}
129+
matches = append(matches, candidate{a, p})
130+
}
131+
if len(matches) == 0 {
132+
return Asset{}, &NoMatchError{Selector: sel, Available: availableVariants(assets, sel.Kind)}
133+
}
134+
best := matches[0]
135+
for _, m := range matches[1:] {
136+
if compareSeries(m.parsed.Series, best.parsed.Series) > 0 {
137+
best = m
138+
}
139+
}
140+
return best.asset, nil
141+
}
142+
143+
// LatestSeries returns the highest PHP series among assets matching the given
144+
// kind, threading, OS and architecture. It errors if none match.
145+
func LatestSeries(assets []Asset, kind Kind, zts bool, osID platform.OS, arch platform.Arch) (string, error) {
146+
var best string
147+
for _, a := range assets {
148+
p, ok := ParseAssetName(a.Name)
149+
if !ok || p.Kind != kind || p.OS != osID || p.Arch != arch || p.ZTS != zts {
150+
continue
151+
}
152+
if best == "" || compareSeries(p.Series, best) > 0 {
153+
best = p.Series
154+
}
155+
}
156+
if best == "" {
157+
return "", &NoMatchError{
158+
Selector: Selector{Kind: kind, ZTS: zts, OS: osID, Arch: arch},
159+
Available: availableVariants(assets, kind),
160+
}
161+
}
162+
return best, nil
163+
}
164+
165+
// compareSeries compares two dotted version strings numerically. Returns -1, 0
166+
// or 1. Non-numeric fields sort as 0.
167+
func compareSeries(a, b string) int {
168+
as, bs := strings.Split(a, "."), strings.Split(b, ".")
169+
for i := 0; i < len(as) || i < len(bs); i++ {
170+
var ai, bi int
171+
if i < len(as) {
172+
ai, _ = strconv.Atoi(as[i])
173+
}
174+
if i < len(bs) {
175+
bi, _ = strconv.Atoi(bs[i])
176+
}
177+
switch {
178+
case ai < bi:
179+
return -1
180+
case ai > bi:
181+
return 1
182+
}
183+
}
184+
return 0
185+
}
186+
187+
// NoMatchError is returned when no asset satisfies a selector. It lists the
188+
// available variants of the requested kind to help the user.
189+
type NoMatchError struct {
190+
Selector Selector
191+
Available []string
192+
}
193+
194+
func (e *NoMatchError) Error() string {
195+
sel := e.Selector
196+
threading := "nts"
197+
if sel.ZTS {
198+
threading = "ts"
199+
}
200+
want := fmt.Sprintf("%s php%s %s %s/%s", sel.Kind, orAny(sel.Series), threading, sel.OS, sel.Arch)
201+
if len(e.Available) == 0 {
202+
return fmt.Sprintf("no matching %s asset (wanted %s); none available in this release", sel.Kind, want)
203+
}
204+
return fmt.Sprintf("no matching %s asset (wanted %s); available: %s",
205+
sel.Kind, want, strings.Join(e.Available, ", "))
206+
}
207+
208+
func orAny(s string) string {
209+
if s == "" {
210+
return "(latest)"
211+
}
212+
return s
213+
}
214+
215+
// availableVariants lists the parseable assets of a kind as human-readable
216+
// variant strings, sorted and de-duplicated.
217+
func availableVariants(assets []Asset, kind Kind) []string {
218+
seen := map[string]bool{}
219+
var out []string
220+
for _, a := range assets {
221+
p, ok := ParseAssetName(a.Name)
222+
if !ok || p.Kind != kind {
223+
continue
224+
}
225+
threading := "nts"
226+
if p.ZTS {
227+
threading = "ts"
228+
}
229+
v := fmt.Sprintf("php%s %s %s/%s", p.Series, threading, p.OS, p.Arch)
230+
if !seen[v] {
231+
seen[v] = true
232+
out = append(out, v)
233+
}
234+
}
235+
sort.Strings(out)
236+
return out
237+
}

0 commit comments

Comments
 (0)