Skip to content

Commit ccdf4cf

Browse files
Installer package (#8)
1 parent a810c72 commit ccdf4cf

7 files changed

Lines changed: 592 additions & 4 deletions

File tree

‎internal/cli/install.go‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
package cli
22

3-
import "github.com/spf13/cobra"
3+
import (
4+
"github.com/php-debugger/installer/internal/installer"
5+
"github.com/php-debugger/installer/internal/platform"
6+
"github.com/spf13/cobra"
7+
)
48

59
// installOptions holds flags specific to the install command.
610
type installOptions struct {
@@ -26,7 +30,17 @@ func newInstallCmd() *cobra.Command {
2630
"into the currently active PHP.",
2731
Args: cobra.NoArgs,
2832
RunE: func(cmd *cobra.Command, args []string) error {
29-
return errNotImplemented("install")
33+
if opts.ExtensionOnly {
34+
// Extension-only install is implemented in a later step.
35+
return errNotImplemented("extension install")
36+
}
37+
return installer.InstallInterpreter(cmd.Context(), installer.Options{
38+
Scope: platform.ScopeFromUserFlag(globalOpts.User),
39+
PHPVersion: opts.PHPVersion,
40+
ZTS: opts.ZTS,
41+
AssumeYes: globalOpts.Yes,
42+
Out: cmd.OutOrStdout(),
43+
})
3044
},
3145
}
3246

‎internal/installer/fsutil.go‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package installer
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"os"
7+
"path/filepath"
8+
)
9+
10+
// installFile copies src to dst (creating parent directories) and marks it
11+
// executable.
12+
func installFile(src, dst string) error {
13+
return copyFile(src, dst, 0o755)
14+
}
15+
16+
// copyFile copies src to dst with the given permissions, creating parent
17+
// directories as needed.
18+
func copyFile(src, dst string, perm os.FileMode) error {
19+
in, err := os.Open(src)
20+
if err != nil {
21+
return err
22+
}
23+
defer in.Close()
24+
25+
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
26+
return fmt.Errorf("creating %s: %w", filepath.Dir(dst), err)
27+
}
28+
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm)
29+
if err != nil {
30+
return err
31+
}
32+
if _, err := io.Copy(out, in); err != nil {
33+
out.Close()
34+
return err
35+
}
36+
if err := out.Close(); err != nil {
37+
return err
38+
}
39+
// Ensure perms even if the file pre-existed with different mode.
40+
return os.Chmod(dst, perm)
41+
}

‎internal/installer/install.go‎

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
// Package installer orchestrates installing, updating and removing the PHP
2+
// debugger — wiring together platform detection, release resolution, download,
3+
// verification, symlinking and the on-disk manifest, with rollback on failure.
4+
package installer
5+
6+
import (
7+
"context"
8+
"fmt"
9+
"io"
10+
"os"
11+
"path/filepath"
12+
"time"
13+
14+
"github.com/php-debugger/installer/internal/manifest"
15+
"github.com/php-debugger/installer/internal/php"
16+
"github.com/php-debugger/installer/internal/platform"
17+
"github.com/php-debugger/installer/internal/release"
18+
)
19+
20+
// Options configures an install.
21+
type Options struct {
22+
Scope platform.Scope
23+
PHPVersion string // PHP series (e.g. "8.3"); empty means latest available
24+
ZTS bool
25+
AssumeYes bool
26+
27+
// Out receives human-readable progress output (may be nil).
28+
Out io.Writer
29+
30+
// Client and Env are optional overrides for testing. When nil, real ones are
31+
// constructed.
32+
Client *release.Client
33+
Env *platform.Env
34+
35+
now func() time.Time // optional clock override for tests
36+
}
37+
38+
func (o Options) logf(format string, args ...any) {
39+
if o.Out != nil {
40+
fmt.Fprintf(o.Out, format+"\n", args...)
41+
}
42+
}
43+
44+
func (o Options) env() (platform.Env, error) {
45+
if o.Env != nil {
46+
return *o.Env, nil
47+
}
48+
return platform.CurrentEnv()
49+
}
50+
51+
func (o Options) clock() time.Time {
52+
if o.now != nil {
53+
return o.now()
54+
}
55+
return time.Now().UTC()
56+
}
57+
58+
// InstallInterpreter installs a self-contained PHP interpreter with the debugger
59+
// compiled in, and activates it. This is the clean-host path: it does not yet
60+
// detect or back up a pre-existing interpreter (that is layered on in a later
61+
// step). On any failure after the first filesystem change, it rolls back.
62+
func InstallInterpreter(ctx context.Context, opts Options) error {
63+
env, err := opts.env()
64+
if err != nil {
65+
return err
66+
}
67+
p := platform.Platform{OS: env.OS, Arch: env.Arch}
68+
69+
layout, err := platform.Resolve(env, opts.Scope)
70+
if err != nil {
71+
return err
72+
}
73+
binDir, err := platform.SelectBinDir(layout.BinCandidates)
74+
if err != nil {
75+
return fmt.Errorf("%w\ntry --user for a per-user install, or re-run with elevated privileges", err)
76+
}
77+
78+
client := opts.Client
79+
if client == nil {
80+
client = release.NewClient()
81+
}
82+
83+
rel, err := client.LatestRelease(ctx)
84+
if err != nil {
85+
return err
86+
}
87+
88+
series := opts.PHPVersion
89+
if series == "" {
90+
series, err = release.LatestSeries(rel.Assets, release.Interpreter, opts.ZTS, p.OS, p.Arch)
91+
if err != nil {
92+
return err
93+
}
94+
}
95+
96+
asset, err := release.SelectAsset(rel.Assets, release.Selector{
97+
Kind: release.Interpreter,
98+
Series: series,
99+
ZTS: opts.ZTS,
100+
OS: p.OS,
101+
Arch: p.Arch,
102+
})
103+
if err != nil {
104+
return err
105+
}
106+
107+
opts.logf("Installing php-debugger interpreter: php %s (%s) %s, release %s",
108+
series, threading(opts.ZTS), p, rel.TagName)
109+
110+
// --- download to a temp dir ---
111+
tmpDir, err := os.MkdirTemp("", "php-debugger-dl-")
112+
if err != nil {
113+
return err
114+
}
115+
defer os.RemoveAll(tmpDir)
116+
117+
opts.logf("Downloading %s ...", asset.Name)
118+
dlPath, err := client.Download(ctx, asset, tmpDir)
119+
if err != nil {
120+
return err
121+
}
122+
if err := os.Chmod(dlPath, 0o755); err != nil {
123+
return err
124+
}
125+
126+
// --- smoke test before touching anything on the system ---
127+
opts.logf("Verifying the interpreter runs on this system ...")
128+
if err := php.SmokeTest(ctx, dlPath); err != nil {
129+
return smokeFailureError(err)
130+
}
131+
info, err := php.Query(ctx, dlPath)
132+
if err != nil {
133+
return fmt.Errorf("querying downloaded interpreter: %w", err)
134+
}
135+
136+
// --- place into the versioned directory ---
137+
rb := &rollback{}
138+
versionDir := layout.VersionDir(series, opts.ZTS)
139+
binTarget := filepath.Join(versionDir, "bin", phpBinaryName(p.OS))
140+
if err := installFile(dlPath, binTarget); err != nil {
141+
return fmt.Errorf("installing interpreter binary: %w", err)
142+
}
143+
rb.add(func() error { return os.RemoveAll(versionDir) })
144+
145+
// --- activate (symlink/shim into the bin dir) ---
146+
prevTarget, _, hadPrev := platform.ReadActive(binDir, "php")
147+
activePath, kind, err := platform.Activate(binDir, "php", binTarget)
148+
if err != nil {
149+
rb.run()
150+
return fmt.Errorf("activating interpreter: %w", err)
151+
}
152+
rb.add(func() error {
153+
if hadPrev {
154+
_, _, e := platform.Activate(binDir, "php", prevTarget)
155+
return e
156+
}
157+
return platform.RemoveActive(binDir, "php")
158+
})
159+
160+
// --- post-verify via the activated entry ---
161+
opts.logf("Confirming the installed interpreter works and has the debugger ...")
162+
if err := php.SmokeTest(ctx, activePath); err != nil {
163+
rb.run()
164+
return fmt.Errorf("installed interpreter failed to run; rolled back: %w", err)
165+
}
166+
hasDebugger, err := php.HasModule(ctx, activePath, php.DebuggerModule)
167+
if err != nil {
168+
rb.run()
169+
return fmt.Errorf("checking for debugger module; rolled back: %w", err)
170+
}
171+
if !hasDebugger {
172+
rb.run()
173+
return fmt.Errorf("installed interpreter does not report the %q module; rolled back",
174+
php.DebuggerModule)
175+
}
176+
177+
// --- record in the manifest ---
178+
m, err := manifest.Load(layout.ManifestPath())
179+
if err != nil {
180+
rb.run()
181+
return err
182+
}
183+
key := platform.VersionDirName(series, opts.ZTS)
184+
m.InstallRoot = layout.Root
185+
m.BinDir = binDir
186+
m.SetInterpreter(key, manifest.Interpreter{
187+
Series: series,
188+
PHPVersion: info.Version,
189+
ZTS: opts.ZTS,
190+
ReleaseTag: rel.TagName,
191+
Dir: versionDir,
192+
InstalledAt: opts.clock(),
193+
})
194+
m.SetActive(key)
195+
if err := m.Save(layout.ManifestPath()); err != nil {
196+
rb.run()
197+
return fmt.Errorf("saving manifest; rolled back: %w", err)
198+
}
199+
200+
opts.logf("Installed php %s (%s). Active php -> %s", info.Version, kind, activePath)
201+
warnIfNotOnPATH(opts, p.OS, binDir)
202+
return nil
203+
}
204+
205+
func threading(zts bool) string {
206+
if zts {
207+
return "zts"
208+
}
209+
return "nts"
210+
}
211+
212+
func phpBinaryName(osID platform.OS) string {
213+
if osID == platform.Windows {
214+
return "php.exe"
215+
}
216+
return "php"
217+
}
218+
219+
func smokeFailureError(err error) error {
220+
return fmt.Errorf(`the downloaded interpreter failed to run on this system:
221+
222+
%w
223+
224+
You can instead install only the debugger extension:
225+
php-debugger install --extension-only
226+
or build from source following the instructions at
227+
https://github.com/php-debugger/php-debugger`, err)
228+
}
229+
230+
func warnIfNotOnPATH(opts Options, osID platform.OS, binDir string) {
231+
if platform.IsOnPATH(osID, binDir, os.Getenv("PATH")) {
232+
return
233+
}
234+
opts.logf("")
235+
opts.logf("Note: %s is not on your PATH.", binDir)
236+
if osID == platform.Windows {
237+
opts.logf("Add it via System Properties > Environment Variables, or:")
238+
opts.logf(` setx PATH "%s;%%PATH%%"`, binDir)
239+
} else {
240+
opts.logf("Add it to your shell profile, e.g.:")
241+
opts.logf(` export PATH="%s:$PATH"`, binDir)
242+
}
243+
}

0 commit comments

Comments
 (0)