github.com/ungtb10d/cli/v2@v2.0.0-20221110210412-98537dd9d6a1/pkg/cmd/extension/manager.go (about)

     1  package extension
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	_ "embed"
     7  	"errors"
     8  	"fmt"
     9  	"io"
    10  	"io/fs"
    11  	"net/http"
    12  	"os"
    13  	"os/exec"
    14  	"path"
    15  	"path/filepath"
    16  	"runtime"
    17  	"strings"
    18  	"sync"
    19  
    20  	"github.com/ungtb10d/cli/v2/api"
    21  	"github.com/ungtb10d/cli/v2/git"
    22  	"github.com/ungtb10d/cli/v2/internal/config"
    23  	"github.com/ungtb10d/cli/v2/internal/ghrepo"
    24  	"github.com/ungtb10d/cli/v2/pkg/extensions"
    25  	"github.com/ungtb10d/cli/v2/pkg/findsh"
    26  	"github.com/ungtb10d/cli/v2/pkg/iostreams"
    27  	"github.com/cli/safeexec"
    28  	"gopkg.in/yaml.v3"
    29  )
    30  
    31  type Manager struct {
    32  	dataDir    func() string
    33  	lookPath   func(string) (string, error)
    34  	findSh     func() (string, error)
    35  	newCommand func(string, ...string) *exec.Cmd
    36  	platform   func() (string, string)
    37  	client     *http.Client
    38  	config     config.Config
    39  	io         *iostreams.IOStreams
    40  	dryRunMode bool
    41  }
    42  
    43  func NewManager(ios *iostreams.IOStreams) *Manager {
    44  	return &Manager{
    45  		dataDir:    config.DataDir,
    46  		lookPath:   safeexec.LookPath,
    47  		findSh:     findsh.Find,
    48  		newCommand: exec.Command,
    49  		platform: func() (string, string) {
    50  			ext := ""
    51  			if runtime.GOOS == "windows" {
    52  				ext = ".exe"
    53  			}
    54  			return fmt.Sprintf("%s-%s", runtime.GOOS, runtime.GOARCH), ext
    55  		},
    56  		io: ios,
    57  	}
    58  }
    59  
    60  func (m *Manager) SetConfig(cfg config.Config) {
    61  	m.config = cfg
    62  }
    63  
    64  func (m *Manager) SetClient(client *http.Client) {
    65  	m.client = client
    66  }
    67  
    68  func (m *Manager) EnableDryRunMode() {
    69  	m.dryRunMode = true
    70  }
    71  
    72  func (m *Manager) Dispatch(args []string, stdin io.Reader, stdout, stderr io.Writer) (bool, error) {
    73  	if len(args) == 0 {
    74  		return false, errors.New("too few arguments in list")
    75  	}
    76  
    77  	var exe string
    78  	extName := args[0]
    79  	forwardArgs := args[1:]
    80  
    81  	exts, _ := m.list(false)
    82  	var ext Extension
    83  	for _, e := range exts {
    84  		if e.Name() == extName {
    85  			ext = e
    86  			exe = ext.Path()
    87  			break
    88  		}
    89  	}
    90  	if exe == "" {
    91  		return false, nil
    92  	}
    93  
    94  	var externalCmd *exec.Cmd
    95  
    96  	if ext.IsBinary() || runtime.GOOS != "windows" {
    97  		externalCmd = m.newCommand(exe, forwardArgs...)
    98  	} else if runtime.GOOS == "windows" {
    99  		// Dispatch all extension calls through the `sh` interpreter to support executable files with a
   100  		// shebang line on Windows.
   101  		shExe, err := m.findSh()
   102  		if err != nil {
   103  			if errors.Is(err, exec.ErrNotFound) {
   104  				return true, errors.New("the `sh.exe` interpreter is required. Please install Git for Windows and try again")
   105  			}
   106  			return true, err
   107  		}
   108  		forwardArgs = append([]string{"-c", `command "$@"`, "--", exe}, forwardArgs...)
   109  		externalCmd = m.newCommand(shExe, forwardArgs...)
   110  	}
   111  	externalCmd.Stdin = stdin
   112  	externalCmd.Stdout = stdout
   113  	externalCmd.Stderr = stderr
   114  	return true, externalCmd.Run()
   115  }
   116  
   117  func (m *Manager) List() []extensions.Extension {
   118  	exts, _ := m.list(false)
   119  	r := make([]extensions.Extension, len(exts))
   120  	for i, v := range exts {
   121  		val := v
   122  		r[i] = &val
   123  	}
   124  	return r
   125  }
   126  
   127  func (m *Manager) list(includeMetadata bool) ([]Extension, error) {
   128  	dir := m.installDir()
   129  	entries, err := os.ReadDir(dir)
   130  	if err != nil {
   131  		return nil, err
   132  	}
   133  
   134  	var results []Extension
   135  	for _, f := range entries {
   136  		if !strings.HasPrefix(f.Name(), "gh-") {
   137  			continue
   138  		}
   139  		var ext Extension
   140  		var err error
   141  		if f.IsDir() {
   142  			ext, err = m.parseExtensionDir(f)
   143  			if err != nil {
   144  				return nil, err
   145  			}
   146  			results = append(results, ext)
   147  		} else {
   148  			ext, err = m.parseExtensionFile(f)
   149  			if err != nil {
   150  				return nil, err
   151  			}
   152  			results = append(results, ext)
   153  		}
   154  	}
   155  
   156  	if includeMetadata {
   157  		m.populateLatestVersions(results)
   158  	}
   159  
   160  	return results, nil
   161  }
   162  
   163  func (m *Manager) parseExtensionFile(fi fs.DirEntry) (Extension, error) {
   164  	ext := Extension{isLocal: true}
   165  	id := m.installDir()
   166  	exePath := filepath.Join(id, fi.Name(), fi.Name())
   167  	if !isSymlink(fi.Type()) {
   168  		// if this is a regular file, its contents is the local directory of the extension
   169  		p, err := readPathFromFile(filepath.Join(id, fi.Name()))
   170  		if err != nil {
   171  			return ext, err
   172  		}
   173  		exePath = filepath.Join(p, fi.Name())
   174  	}
   175  	ext.path = exePath
   176  	return ext, nil
   177  }
   178  
   179  func (m *Manager) parseExtensionDir(fi fs.DirEntry) (Extension, error) {
   180  	id := m.installDir()
   181  	if _, err := os.Stat(filepath.Join(id, fi.Name(), manifestName)); err == nil {
   182  		return m.parseBinaryExtensionDir(fi)
   183  	}
   184  
   185  	return m.parseGitExtensionDir(fi)
   186  }
   187  
   188  func (m *Manager) parseBinaryExtensionDir(fi fs.DirEntry) (Extension, error) {
   189  	id := m.installDir()
   190  	exePath := filepath.Join(id, fi.Name(), fi.Name())
   191  	ext := Extension{path: exePath, kind: BinaryKind}
   192  	manifestPath := filepath.Join(id, fi.Name(), manifestName)
   193  	manifest, err := os.ReadFile(manifestPath)
   194  	if err != nil {
   195  		return ext, fmt.Errorf("could not open %s for reading: %w", manifestPath, err)
   196  	}
   197  	var bm binManifest
   198  	err = yaml.Unmarshal(manifest, &bm)
   199  	if err != nil {
   200  		return ext, fmt.Errorf("could not parse %s: %w", manifestPath, err)
   201  	}
   202  	repo := ghrepo.NewWithHost(bm.Owner, bm.Name, bm.Host)
   203  	remoteURL := ghrepo.GenerateRepoURL(repo, "")
   204  	ext.url = remoteURL
   205  	ext.currentVersion = bm.Tag
   206  	ext.isPinned = bm.IsPinned
   207  	return ext, nil
   208  }
   209  
   210  func (m *Manager) parseGitExtensionDir(fi fs.DirEntry) (Extension, error) {
   211  	id := m.installDir()
   212  	exePath := filepath.Join(id, fi.Name(), fi.Name())
   213  	remoteUrl := m.getRemoteUrl(fi.Name())
   214  	currentVersion := m.getCurrentVersion(fi.Name())
   215  
   216  	var isPinned bool
   217  	pinPath := filepath.Join(id, fi.Name(), fmt.Sprintf(".pin-%s", currentVersion))
   218  	if _, err := os.Stat(pinPath); err == nil {
   219  		isPinned = true
   220  	}
   221  
   222  	return Extension{
   223  		path:           exePath,
   224  		url:            remoteUrl,
   225  		isLocal:        false,
   226  		currentVersion: currentVersion,
   227  		kind:           GitKind,
   228  		isPinned:       isPinned,
   229  	}, nil
   230  }
   231  
   232  // getCurrentVersion determines the current version for non-local git extensions.
   233  func (m *Manager) getCurrentVersion(extension string) string {
   234  	gitExe, err := m.lookPath("git")
   235  	if err != nil {
   236  		return ""
   237  	}
   238  	dir := m.installDir()
   239  	gitDir := "--git-dir=" + filepath.Join(dir, extension, ".git")
   240  	cmd := m.newCommand(gitExe, gitDir, "rev-parse", "HEAD")
   241  
   242  	localSha, err := cmd.Output()
   243  	if err != nil {
   244  		return ""
   245  	}
   246  	return string(bytes.TrimSpace(localSha))
   247  }
   248  
   249  // getRemoteUrl determines the remote URL for non-local git extensions.
   250  func (m *Manager) getRemoteUrl(extension string) string {
   251  	gitExe, err := m.lookPath("git")
   252  	if err != nil {
   253  		return ""
   254  	}
   255  	dir := m.installDir()
   256  	gitDir := "--git-dir=" + filepath.Join(dir, extension, ".git")
   257  	cmd := m.newCommand(gitExe, gitDir, "config", "remote.origin.url")
   258  	url, err := cmd.Output()
   259  	if err != nil {
   260  		return ""
   261  	}
   262  	return strings.TrimSpace(string(url))
   263  }
   264  
   265  func (m *Manager) populateLatestVersions(exts []Extension) {
   266  	size := len(exts)
   267  	type result struct {
   268  		index   int
   269  		version string
   270  	}
   271  	ch := make(chan result, size)
   272  	var wg sync.WaitGroup
   273  	wg.Add(size)
   274  	for idx, ext := range exts {
   275  		go func(i int, e Extension) {
   276  			defer wg.Done()
   277  			version, _ := m.getLatestVersion(e)
   278  			ch <- result{index: i, version: version}
   279  		}(idx, ext)
   280  	}
   281  	wg.Wait()
   282  	close(ch)
   283  	for r := range ch {
   284  		ext := &exts[r.index]
   285  		ext.latestVersion = r.version
   286  	}
   287  }
   288  
   289  func (m *Manager) getLatestVersion(ext Extension) (string, error) {
   290  	if ext.isLocal {
   291  		return "", localExtensionUpgradeError
   292  	}
   293  	if ext.IsBinary() {
   294  		repo, err := ghrepo.FromFullName(ext.url)
   295  		if err != nil {
   296  			return "", err
   297  		}
   298  		r, err := fetchLatestRelease(m.client, repo)
   299  		if err != nil {
   300  			return "", err
   301  		}
   302  		return r.Tag, nil
   303  	} else {
   304  		gitExe, err := m.lookPath("git")
   305  		if err != nil {
   306  			return "", err
   307  		}
   308  		extDir := filepath.Dir(ext.path)
   309  		gitDir := "--git-dir=" + filepath.Join(extDir, ".git")
   310  		cmd := m.newCommand(gitExe, gitDir, "ls-remote", "origin", "HEAD")
   311  		lsRemote, err := cmd.Output()
   312  		if err != nil {
   313  			return "", err
   314  		}
   315  		remoteSha := bytes.SplitN(lsRemote, []byte("\t"), 2)[0]
   316  		return string(remoteSha), nil
   317  	}
   318  }
   319  
   320  func (m *Manager) InstallLocal(dir string) error {
   321  	name := filepath.Base(dir)
   322  	targetLink := filepath.Join(m.installDir(), name)
   323  	if err := os.MkdirAll(filepath.Dir(targetLink), 0755); err != nil {
   324  		return err
   325  	}
   326  	return makeSymlink(dir, targetLink)
   327  }
   328  
   329  type binManifest struct {
   330  	Owner    string
   331  	Name     string
   332  	Host     string
   333  	Tag      string
   334  	IsPinned bool
   335  	// TODO I may end up not using this; just thinking ahead to local installs
   336  	Path string
   337  }
   338  
   339  // Install installs an extension from repo, and pins to commitish if provided
   340  func (m *Manager) Install(repo ghrepo.Interface, target string) error {
   341  	isBin, err := isBinExtension(m.client, repo)
   342  	if err != nil {
   343  		if errors.Is(err, releaseNotFoundErr) {
   344  			if ok, err := repoExists(m.client, repo); err != nil {
   345  				return err
   346  			} else if !ok {
   347  				return repositoryNotFoundErr
   348  			}
   349  		} else {
   350  			return fmt.Errorf("could not check for binary extension: %w", err)
   351  		}
   352  	}
   353  	if isBin {
   354  		return m.installBin(repo, target)
   355  	}
   356  
   357  	hs, err := hasScript(m.client, repo)
   358  	if err != nil {
   359  		return err
   360  	}
   361  	if !hs {
   362  		return errors.New("extension is not installable: missing executable")
   363  	}
   364  
   365  	return m.installGit(repo, target, m.io.Out, m.io.ErrOut)
   366  }
   367  
   368  func (m *Manager) installBin(repo ghrepo.Interface, target string) error {
   369  	var r *release
   370  	var err error
   371  	isPinned := target != ""
   372  	if isPinned {
   373  		r, err = fetchReleaseFromTag(m.client, repo, target)
   374  	} else {
   375  		r, err = fetchLatestRelease(m.client, repo)
   376  	}
   377  	if err != nil {
   378  		return err
   379  	}
   380  
   381  	platform, ext := m.platform()
   382  	isMacARM := platform == "darwin-arm64"
   383  	trueARMBinary := false
   384  
   385  	var asset *releaseAsset
   386  	for _, a := range r.Assets {
   387  		if strings.HasSuffix(a.Name, platform+ext) {
   388  			asset = &a
   389  			trueARMBinary = isMacARM
   390  			break
   391  		}
   392  	}
   393  
   394  	// if an arm64 binary is unavailable, fall back to amd64 if it can be executed through Rosetta 2
   395  	if asset == nil && isMacARM && hasRosetta() {
   396  		for _, a := range r.Assets {
   397  			if strings.HasSuffix(a.Name, "darwin-amd64") {
   398  				asset = &a
   399  				break
   400  			}
   401  		}
   402  	}
   403  
   404  	if asset == nil {
   405  		return fmt.Errorf(
   406  			"%[1]s unsupported for %[2]s. Open an issue: `gh issue create -R %[3]s/%[1]s -t'Support %[2]s'`",
   407  			repo.RepoName(), platform, repo.RepoOwner())
   408  	}
   409  
   410  	name := repo.RepoName()
   411  	targetDir := filepath.Join(m.installDir(), name)
   412  
   413  	// TODO clean this up if function errs?
   414  	if !m.dryRunMode {
   415  		err = os.MkdirAll(targetDir, 0755)
   416  		if err != nil {
   417  			return fmt.Errorf("failed to create installation directory: %w", err)
   418  		}
   419  	}
   420  
   421  	binPath := filepath.Join(targetDir, name)
   422  	binPath += ext
   423  
   424  	if !m.dryRunMode {
   425  		err = downloadAsset(m.client, *asset, binPath)
   426  		if err != nil {
   427  			return fmt.Errorf("failed to download asset %s: %w", asset.Name, err)
   428  		}
   429  		if trueARMBinary {
   430  			if err := codesignBinary(binPath); err != nil {
   431  				return fmt.Errorf("failed to codesign downloaded binary: %w", err)
   432  			}
   433  		}
   434  	}
   435  
   436  	manifest := binManifest{
   437  		Name:     name,
   438  		Owner:    repo.RepoOwner(),
   439  		Host:     repo.RepoHost(),
   440  		Path:     binPath,
   441  		Tag:      r.Tag,
   442  		IsPinned: isPinned,
   443  	}
   444  
   445  	bs, err := yaml.Marshal(manifest)
   446  	if err != nil {
   447  		return fmt.Errorf("failed to serialize manifest: %w", err)
   448  	}
   449  
   450  	if !m.dryRunMode {
   451  		manifestPath := filepath.Join(targetDir, manifestName)
   452  
   453  		f, err := os.OpenFile(manifestPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
   454  		if err != nil {
   455  			return fmt.Errorf("failed to open manifest for writing: %w", err)
   456  		}
   457  		defer f.Close()
   458  
   459  		_, err = f.Write(bs)
   460  		if err != nil {
   461  			return fmt.Errorf("failed write manifest file: %w", err)
   462  		}
   463  	}
   464  
   465  	return nil
   466  }
   467  
   468  func (m *Manager) installGit(repo ghrepo.Interface, target string, stdout, stderr io.Writer) error {
   469  	protocol, _ := m.config.GetOrDefault(repo.RepoHost(), "git_protocol")
   470  	cloneURL := ghrepo.FormatRemoteURL(repo, protocol)
   471  
   472  	exe, err := m.lookPath("git")
   473  	if err != nil {
   474  		return err
   475  	}
   476  
   477  	var commitSHA string
   478  	if target != "" {
   479  		commitSHA, err = fetchCommitSHA(m.client, repo, target)
   480  		if err != nil {
   481  			return err
   482  		}
   483  	}
   484  
   485  	name := strings.TrimSuffix(path.Base(cloneURL), ".git")
   486  	targetDir := filepath.Join(m.installDir(), name)
   487  
   488  	externalCmd := m.newCommand(exe, "clone", cloneURL, targetDir)
   489  	externalCmd.Stdout = stdout
   490  	externalCmd.Stderr = stderr
   491  	if err := externalCmd.Run(); err != nil {
   492  		return err
   493  	}
   494  	if commitSHA == "" {
   495  		return nil
   496  	}
   497  
   498  	checkoutCmd := m.newCommand(exe, "-C", targetDir, "checkout", commitSHA)
   499  	checkoutCmd.Stdout = stdout
   500  	checkoutCmd.Stderr = stderr
   501  	if err := checkoutCmd.Run(); err != nil {
   502  		return err
   503  	}
   504  
   505  	pinPath := filepath.Join(targetDir, fmt.Sprintf(".pin-%s", commitSHA))
   506  	f, err := os.OpenFile(pinPath, os.O_WRONLY|os.O_CREATE, 0600)
   507  	if err != nil {
   508  		return fmt.Errorf("failed to create pin file in directory: %w", err)
   509  	}
   510  	return f.Close()
   511  }
   512  
   513  var pinnedExtensionUpgradeError = errors.New("pinned extensions can not be upgraded")
   514  var localExtensionUpgradeError = errors.New("local extensions can not be upgraded")
   515  var upToDateError = errors.New("already up to date")
   516  var noExtensionsInstalledError = errors.New("no extensions installed")
   517  
   518  func (m *Manager) Upgrade(name string, force bool) error {
   519  	// Fetch metadata during list only when upgrading all extensions.
   520  	// This is a performance improvement so that we don't make a
   521  	// bunch of unnecessary network requests when trying to upgrade a single extension.
   522  	fetchMetadata := name == ""
   523  	exts, _ := m.list(fetchMetadata)
   524  	if len(exts) == 0 {
   525  		return noExtensionsInstalledError
   526  	}
   527  	if name == "" {
   528  		return m.upgradeExtensions(exts, force)
   529  	}
   530  	for _, f := range exts {
   531  		if f.Name() != name {
   532  			continue
   533  		}
   534  		var err error
   535  		// For single extensions manually retrieve latest version since we forgo
   536  		// doing it during list.
   537  		f.latestVersion, err = m.getLatestVersion(f)
   538  		if err != nil {
   539  			return err
   540  		}
   541  		return m.upgradeExtension(f, force)
   542  	}
   543  	return fmt.Errorf("no extension matched %q", name)
   544  }
   545  
   546  func (m *Manager) upgradeExtensions(exts []Extension, force bool) error {
   547  	var failed bool
   548  	for _, f := range exts {
   549  		fmt.Fprintf(m.io.Out, "[%s]: ", f.Name())
   550  		err := m.upgradeExtension(f, force)
   551  		if err != nil {
   552  			if !errors.Is(err, localExtensionUpgradeError) &&
   553  				!errors.Is(err, upToDateError) &&
   554  				!errors.Is(err, pinnedExtensionUpgradeError) {
   555  				failed = true
   556  			}
   557  			fmt.Fprintf(m.io.Out, "%s\n", err)
   558  			continue
   559  		}
   560  		currentVersion := displayExtensionVersion(&f, f.currentVersion)
   561  		latestVersion := displayExtensionVersion(&f, f.latestVersion)
   562  		if m.dryRunMode {
   563  			fmt.Fprintf(m.io.Out, "would have upgraded from %s to %s\n", currentVersion, latestVersion)
   564  		} else {
   565  			fmt.Fprintf(m.io.Out, "upgraded from %s to %s\n", currentVersion, latestVersion)
   566  		}
   567  	}
   568  	if failed {
   569  		return errors.New("some extensions failed to upgrade")
   570  	}
   571  	return nil
   572  }
   573  
   574  func (m *Manager) upgradeExtension(ext Extension, force bool) error {
   575  	if ext.isLocal {
   576  		return localExtensionUpgradeError
   577  	}
   578  	if !force && ext.IsPinned() {
   579  		return pinnedExtensionUpgradeError
   580  	}
   581  	if !ext.UpdateAvailable() {
   582  		return upToDateError
   583  	}
   584  	var err error
   585  	if ext.IsBinary() {
   586  		err = m.upgradeBinExtension(ext)
   587  	} else {
   588  		// Check if git extension has changed to a binary extension
   589  		var isBin bool
   590  		repo, repoErr := repoFromPath(filepath.Join(ext.Path(), ".."))
   591  		if repoErr == nil {
   592  			isBin, _ = isBinExtension(m.client, repo)
   593  		}
   594  		if isBin {
   595  			if err := m.Remove(ext.Name()); err != nil {
   596  				return fmt.Errorf("failed to migrate to new precompiled extension format: %w", err)
   597  			}
   598  			return m.installBin(repo, "")
   599  		}
   600  		err = m.upgradeGitExtension(ext, force)
   601  	}
   602  	return err
   603  }
   604  
   605  func (m *Manager) upgradeGitExtension(ext Extension, force bool) error {
   606  	exe, err := m.lookPath("git")
   607  	if err != nil {
   608  		return err
   609  	}
   610  	dir := filepath.Dir(ext.path)
   611  	if m.dryRunMode {
   612  		return nil
   613  	}
   614  	if force {
   615  		if err := m.newCommand(exe, "-C", dir, "fetch", "origin", "HEAD").Run(); err != nil {
   616  			return err
   617  		}
   618  		return m.newCommand(exe, "-C", dir, "reset", "--hard", "origin/HEAD").Run()
   619  	}
   620  	return m.newCommand(exe, "-C", dir, "pull", "--ff-only").Run()
   621  }
   622  
   623  func (m *Manager) upgradeBinExtension(ext Extension) error {
   624  	repo, err := ghrepo.FromFullName(ext.url)
   625  	if err != nil {
   626  		return fmt.Errorf("failed to parse URL %s: %w", ext.url, err)
   627  	}
   628  	return m.installBin(repo, "")
   629  }
   630  
   631  func (m *Manager) Remove(name string) error {
   632  	targetDir := filepath.Join(m.installDir(), "gh-"+name)
   633  	if _, err := os.Lstat(targetDir); os.IsNotExist(err) {
   634  		return fmt.Errorf("no extension found: %q", targetDir)
   635  	}
   636  	if m.dryRunMode {
   637  		return nil
   638  	}
   639  	return os.RemoveAll(targetDir)
   640  }
   641  
   642  func (m *Manager) installDir() string {
   643  	return filepath.Join(m.dataDir(), "extensions")
   644  }
   645  
   646  //go:embed ext_tmpls/goBinMain.go.txt
   647  var mainGoTmpl string
   648  
   649  //go:embed ext_tmpls/goBinWorkflow.yml
   650  var goBinWorkflow []byte
   651  
   652  //go:embed ext_tmpls/otherBinWorkflow.yml
   653  var otherBinWorkflow []byte
   654  
   655  //go:embed ext_tmpls/script.sh
   656  var scriptTmpl string
   657  
   658  //go:embed ext_tmpls/buildScript.sh
   659  var buildScript []byte
   660  
   661  func (m *Manager) Create(name string, tmplType extensions.ExtTemplateType) error {
   662  	exe, err := m.lookPath("git")
   663  	if err != nil {
   664  		return err
   665  	}
   666  
   667  	if err := m.newCommand(exe, "init", "--quiet", name).Run(); err != nil {
   668  		return err
   669  	}
   670  
   671  	if tmplType == extensions.GoBinTemplateType {
   672  		return m.goBinScaffolding(exe, name)
   673  	} else if tmplType == extensions.OtherBinTemplateType {
   674  		return m.otherBinScaffolding(exe, name)
   675  	}
   676  
   677  	script := fmt.Sprintf(scriptTmpl, name)
   678  	if err := writeFile(filepath.Join(name, name), []byte(script), 0755); err != nil {
   679  		return err
   680  	}
   681  
   682  	return m.newCommand(exe, "-C", name, "add", name, "--chmod=+x").Run()
   683  }
   684  
   685  func (m *Manager) otherBinScaffolding(gitExe, name string) error {
   686  	if err := writeFile(filepath.Join(name, ".github", "workflows", "release.yml"), otherBinWorkflow, 0644); err != nil {
   687  		return err
   688  	}
   689  	buildScriptPath := filepath.Join("script", "build.sh")
   690  	if err := writeFile(filepath.Join(name, buildScriptPath), buildScript, 0755); err != nil {
   691  		return err
   692  	}
   693  	if err := m.newCommand(gitExe, "-C", name, "add", buildScriptPath, "--chmod=+x").Run(); err != nil {
   694  		return err
   695  	}
   696  	return m.newCommand(gitExe, "-C", name, "add", ".").Run()
   697  }
   698  
   699  func (m *Manager) goBinScaffolding(gitExe, name string) error {
   700  	goExe, err := m.lookPath("go")
   701  	if err != nil {
   702  		return fmt.Errorf("go is required for creating Go extensions: %w", err)
   703  	}
   704  
   705  	if err := writeFile(filepath.Join(name, ".github", "workflows", "release.yml"), goBinWorkflow, 0644); err != nil {
   706  		return err
   707  	}
   708  
   709  	mainGo := fmt.Sprintf(mainGoTmpl, name)
   710  	if err := writeFile(filepath.Join(name, "main.go"), []byte(mainGo), 0644); err != nil {
   711  		return err
   712  	}
   713  
   714  	host, _ := m.config.DefaultHost()
   715  
   716  	currentUser, err := api.CurrentLoginName(api.NewClientFromHTTP(m.client), host)
   717  	if err != nil {
   718  		return err
   719  	}
   720  
   721  	goCmds := [][]string{
   722  		{"mod", "init", fmt.Sprintf("%s/%s/%s", host, currentUser, name)},
   723  		{"mod", "tidy"},
   724  		{"build"},
   725  	}
   726  
   727  	ignore := fmt.Sprintf("/%[1]s\n/%[1]s.exe\n", name)
   728  	if err := writeFile(filepath.Join(name, ".gitignore"), []byte(ignore), 0644); err != nil {
   729  		return err
   730  	}
   731  
   732  	for _, args := range goCmds {
   733  		goCmd := m.newCommand(goExe, args...)
   734  		goCmd.Dir = name
   735  		if err := goCmd.Run(); err != nil {
   736  			return fmt.Errorf("failed to set up go module: %w", err)
   737  		}
   738  	}
   739  
   740  	return m.newCommand(gitExe, "-C", name, "add", ".").Run()
   741  }
   742  
   743  func isSymlink(m os.FileMode) bool {
   744  	return m&os.ModeSymlink != 0
   745  }
   746  
   747  func writeFile(p string, contents []byte, mode os.FileMode) error {
   748  	if dir := filepath.Dir(p); dir != "." {
   749  		if err := os.MkdirAll(dir, 0755); err != nil {
   750  			return err
   751  		}
   752  	}
   753  	return os.WriteFile(p, contents, mode)
   754  }
   755  
   756  // reads the product of makeSymlink on Windows
   757  func readPathFromFile(path string) (string, error) {
   758  	f, err := os.Open(path)
   759  	if err != nil {
   760  		return "", err
   761  	}
   762  	defer f.Close()
   763  	b := make([]byte, 1024)
   764  	n, err := f.Read(b)
   765  	return strings.TrimSpace(string(b[:n])), err
   766  }
   767  
   768  func isBinExtension(client *http.Client, repo ghrepo.Interface) (isBin bool, err error) {
   769  	var r *release
   770  	r, err = fetchLatestRelease(client, repo)
   771  	if err != nil {
   772  		return
   773  	}
   774  
   775  	for _, a := range r.Assets {
   776  		dists := possibleDists()
   777  		for _, d := range dists {
   778  			suffix := d
   779  			if strings.HasPrefix(d, "windows") {
   780  				suffix += ".exe"
   781  			}
   782  			if strings.HasSuffix(a.Name, suffix) {
   783  				isBin = true
   784  				break
   785  			}
   786  		}
   787  	}
   788  
   789  	return
   790  }
   791  
   792  func repoFromPath(path string) (ghrepo.Interface, error) {
   793  	gitClient := &git.Client{RepoDir: path}
   794  	remotes, err := gitClient.Remotes(context.Background())
   795  	if err != nil {
   796  		return nil, err
   797  	}
   798  
   799  	if len(remotes) == 0 {
   800  		return nil, fmt.Errorf("no remotes configured for %s", path)
   801  	}
   802  
   803  	var remote *git.Remote
   804  
   805  	for _, r := range remotes {
   806  		if r.Name == "origin" {
   807  			remote = r
   808  			break
   809  		}
   810  	}
   811  
   812  	if remote == nil {
   813  		remote = remotes[0]
   814  	}
   815  
   816  	return ghrepo.FromURL(remote.FetchURL)
   817  }
   818  
   819  func possibleDists() []string {
   820  	return []string{
   821  		"aix-ppc64",
   822  		"android-386",
   823  		"android-amd64",
   824  		"android-arm",
   825  		"android-arm64",
   826  		"darwin-amd64",
   827  		"darwin-arm64",
   828  		"dragonfly-amd64",
   829  		"freebsd-386",
   830  		"freebsd-amd64",
   831  		"freebsd-arm",
   832  		"freebsd-arm64",
   833  		"illumos-amd64",
   834  		"ios-amd64",
   835  		"ios-arm64",
   836  		"js-wasm",
   837  		"linux-386",
   838  		"linux-amd64",
   839  		"linux-arm",
   840  		"linux-arm64",
   841  		"linux-mips",
   842  		"linux-mips64",
   843  		"linux-mips64le",
   844  		"linux-mipsle",
   845  		"linux-ppc64",
   846  		"linux-ppc64le",
   847  		"linux-riscv64",
   848  		"linux-s390x",
   849  		"netbsd-386",
   850  		"netbsd-amd64",
   851  		"netbsd-arm",
   852  		"netbsd-arm64",
   853  		"openbsd-386",
   854  		"openbsd-amd64",
   855  		"openbsd-arm",
   856  		"openbsd-arm64",
   857  		"openbsd-mips64",
   858  		"plan9-386",
   859  		"plan9-amd64",
   860  		"plan9-arm",
   861  		"solaris-amd64",
   862  		"windows-386",
   863  		"windows-amd64",
   864  		"windows-arm",
   865  		"windows-arm64",
   866  	}
   867  }
   868  
   869  func hasRosetta() bool {
   870  	_, err := os.Stat("/Library/Apple/usr/libexec/oah/libRosettaRuntime")
   871  	return err == nil
   872  }
   873  
   874  func codesignBinary(binPath string) error {
   875  	codesignExe, err := safeexec.LookPath("codesign")
   876  	if err != nil {
   877  		return err
   878  	}
   879  	cmd := exec.Command(codesignExe, "--sign", "-", "--force", "--preserve-metadata=entitlements,requirements,flags,runtime", binPath)
   880  	return cmd.Run()
   881  }