Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions internal/cli/install.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package cli

import "github.com/spf13/cobra"
import (
"github.com/php-debugger/installer/internal/installer"
"github.com/php-debugger/installer/internal/platform"
"github.com/spf13/cobra"
)

// installOptions holds flags specific to the install command.
type installOptions struct {
Expand All @@ -26,7 +30,17 @@ func newInstallCmd() *cobra.Command {
"into the currently active PHP.",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return errNotImplemented("install")
if opts.ExtensionOnly {
// Extension-only install is implemented in a later step.
return errNotImplemented("extension install")
}
return installer.InstallInterpreter(cmd.Context(), installer.Options{
Scope: platform.ScopeFromUserFlag(globalOpts.User),
PHPVersion: opts.PHPVersion,
ZTS: opts.ZTS,
AssumeYes: globalOpts.Yes,
Out: cmd.OutOrStdout(),
})
},
}

Expand Down
41 changes: 41 additions & 0 deletions internal/installer/fsutil.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package installer

import (
"fmt"
"io"
"os"
"path/filepath"
)

// installFile copies src to dst (creating parent directories) and marks it
// executable.
func installFile(src, dst string) error {
return copyFile(src, dst, 0o755)
}

// copyFile copies src to dst with the given permissions, creating parent
// directories as needed.
func copyFile(src, dst string, perm os.FileMode) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()

if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return fmt.Errorf("creating %s: %w", filepath.Dir(dst), err)
}
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
return err
}
if err := out.Close(); err != nil {
return err
}
// Ensure perms even if the file pre-existed with different mode.
return os.Chmod(dst, perm)
}
243 changes: 243 additions & 0 deletions internal/installer/install.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
// Package installer orchestrates installing, updating and removing the PHP
// debugger — wiring together platform detection, release resolution, download,
// verification, symlinking and the on-disk manifest, with rollback on failure.
package installer

import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"time"

"github.com/php-debugger/installer/internal/manifest"
"github.com/php-debugger/installer/internal/php"
"github.com/php-debugger/installer/internal/platform"
"github.com/php-debugger/installer/internal/release"
)

// Options configures an install.
type Options struct {
Scope platform.Scope
PHPVersion string // PHP series (e.g. "8.3"); empty means latest available
ZTS bool
AssumeYes bool

// Out receives human-readable progress output (may be nil).
Out io.Writer

// Client and Env are optional overrides for testing. When nil, real ones are
// constructed.
Client *release.Client
Env *platform.Env

now func() time.Time // optional clock override for tests
}

func (o Options) logf(format string, args ...any) {
if o.Out != nil {
fmt.Fprintf(o.Out, format+"\n", args...)
}
}

func (o Options) env() (platform.Env, error) {
if o.Env != nil {
return *o.Env, nil
}
return platform.CurrentEnv()
}

func (o Options) clock() time.Time {
if o.now != nil {
return o.now()
}
return time.Now().UTC()
}

// InstallInterpreter installs a self-contained PHP interpreter with the debugger
// compiled in, and activates it. This is the clean-host path: it does not yet
// detect or back up a pre-existing interpreter (that is layered on in a later
// step). On any failure after the first filesystem change, it rolls back.
func InstallInterpreter(ctx context.Context, opts Options) error {
env, err := opts.env()
if err != nil {
return err
}
p := platform.Platform{OS: env.OS, Arch: env.Arch}

layout, err := platform.Resolve(env, opts.Scope)
if err != nil {
return err
}
binDir, err := platform.SelectBinDir(layout.BinCandidates)
if err != nil {
return fmt.Errorf("%w\ntry --user for a per-user install, or re-run with elevated privileges", err)
}

client := opts.Client
if client == nil {
client = release.NewClient()
}

rel, err := client.LatestRelease(ctx)
if err != nil {
return err
}

series := opts.PHPVersion
if series == "" {
series, err = release.LatestSeries(rel.Assets, release.Interpreter, opts.ZTS, p.OS, p.Arch)
if err != nil {
return err
}
}

asset, err := release.SelectAsset(rel.Assets, release.Selector{
Kind: release.Interpreter,
Series: series,
ZTS: opts.ZTS,
OS: p.OS,
Arch: p.Arch,
})
if err != nil {
return err
}

opts.logf("Installing php-debugger interpreter: php %s (%s) %s, release %s",
series, threading(opts.ZTS), p, rel.TagName)

// --- download to a temp dir ---
tmpDir, err := os.MkdirTemp("", "php-debugger-dl-")
if err != nil {
return err
}
defer os.RemoveAll(tmpDir)

opts.logf("Downloading %s ...", asset.Name)
dlPath, err := client.Download(ctx, asset, tmpDir)
if err != nil {
return err
}
if err := os.Chmod(dlPath, 0o755); err != nil {
return err
}

// --- smoke test before touching anything on the system ---
opts.logf("Verifying the interpreter runs on this system ...")
if err := php.SmokeTest(ctx, dlPath); err != nil {
return smokeFailureError(err)
}
info, err := php.Query(ctx, dlPath)
if err != nil {
return fmt.Errorf("querying downloaded interpreter: %w", err)
}

// --- place into the versioned directory ---
rb := &rollback{}
versionDir := layout.VersionDir(series, opts.ZTS)
binTarget := filepath.Join(versionDir, "bin", phpBinaryName(p.OS))
if err := installFile(dlPath, binTarget); err != nil {
return fmt.Errorf("installing interpreter binary: %w", err)
}
rb.add(func() error { return os.RemoveAll(versionDir) })

// --- activate (symlink/shim into the bin dir) ---
prevTarget, _, hadPrev := platform.ReadActive(binDir, "php")
activePath, kind, err := platform.Activate(binDir, "php", binTarget)
if err != nil {
rb.run()
return fmt.Errorf("activating interpreter: %w", err)
}
rb.add(func() error {
if hadPrev {
_, _, e := platform.Activate(binDir, "php", prevTarget)
return e
}
return platform.RemoveActive(binDir, "php")
})

// --- post-verify via the activated entry ---
opts.logf("Confirming the installed interpreter works and has the debugger ...")
if err := php.SmokeTest(ctx, activePath); err != nil {
rb.run()
return fmt.Errorf("installed interpreter failed to run; rolled back: %w", err)
}
hasDebugger, err := php.HasModule(ctx, activePath, php.DebuggerModule)
if err != nil {
rb.run()
return fmt.Errorf("checking for debugger module; rolled back: %w", err)
}
if !hasDebugger {
rb.run()
return fmt.Errorf("installed interpreter does not report the %q module; rolled back",
php.DebuggerModule)
}

// --- record in the manifest ---
m, err := manifest.Load(layout.ManifestPath())
if err != nil {
rb.run()
return err
}
key := platform.VersionDirName(series, opts.ZTS)
m.InstallRoot = layout.Root
m.BinDir = binDir
m.SetInterpreter(key, manifest.Interpreter{
Series: series,
PHPVersion: info.Version,
ZTS: opts.ZTS,
ReleaseTag: rel.TagName,
Dir: versionDir,
InstalledAt: opts.clock(),
})
m.SetActive(key)
if err := m.Save(layout.ManifestPath()); err != nil {
rb.run()
return fmt.Errorf("saving manifest; rolled back: %w", err)
}

opts.logf("Installed php %s (%s). Active php -> %s", info.Version, kind, activePath)
warnIfNotOnPATH(opts, p.OS, binDir)
return nil
}

func threading(zts bool) string {
if zts {
return "zts"
}
return "nts"
}

func phpBinaryName(osID platform.OS) string {
if osID == platform.Windows {
return "php.exe"
}
return "php"
}

func smokeFailureError(err error) error {
return fmt.Errorf(`the downloaded interpreter failed to run on this system:

%w

You can instead install only the debugger extension:
php-debugger install --extension-only
or build from source following the instructions at
https://github.com/php-debugger/php-debugger`, err)
}

func warnIfNotOnPATH(opts Options, osID platform.OS, binDir string) {
if platform.IsOnPATH(osID, binDir, os.Getenv("PATH")) {
return
}
opts.logf("")
opts.logf("Note: %s is not on your PATH.", binDir)
if osID == platform.Windows {
opts.logf("Add it via System Properties > Environment Variables, or:")
opts.logf(` setx PATH "%s;%%PATH%%"`, binDir)
} else {
opts.logf("Add it to your shell profile, e.g.:")
opts.logf(` export PATH="%s:$PATH"`, binDir)
}
}
Loading
Loading