diff --git a/pkg/cmd/extension/command.go b/pkg/cmd/extension/command.go index 88a3e59b3..0b4e5fdd7 100644 --- a/pkg/cmd/extension/command.go +++ b/pkg/cmd/extension/command.go @@ -141,9 +141,43 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command { return nil }, }, + &cobra.Command{ + Use: "create ", + Short: "Create a new extension", + Args: cmdutil.ExactArgs(1, "must specify a name for the extension"), + RunE: func(cmd *cobra.Command, args []string) error { + extName := args[0] + if !strings.HasPrefix(extName, "gh-") { + extName = "gh-" + extName + } + if err := m.Create(extName); err != nil { + return err + } + if !io.IsStdoutTTY() { + return nil + } + link := "https://docs.github.com/github-cli/github-cli/creating-github-cli-extensions" + cs := io.ColorScheme() + out := heredoc.Docf(` + %[1]s Created directory %[2]s + %[1]s Initialized git repository + %[1]s Set up extension scaffolding + + %[2]s is ready for development + + Install locally with: cd %[2]s && gh extension install . + + Publish to GitHub with: gh repo create %[2]s + + For more information on writing extensions: + %[3]s + `, cs.SuccessIcon(), extName, link) + fmt.Fprint(io.Out, out) + return nil + }, + }, ) - extCmd.Hidden = true return &extCmd } diff --git a/pkg/cmd/extension/command_test.go b/pkg/cmd/extension/command_test.go index b9e8dce86..30833b723 100644 --- a/pkg/cmd/extension/command_test.go +++ b/pkg/cmd/extension/command_test.go @@ -7,6 +7,7 @@ import ( "strings" "testing" + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/internal/config" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/extensions" @@ -221,6 +222,51 @@ func TestNewCmdExtension(t *testing.T) { }, wantStdout: "gh test\tcli/gh-test\t\ngh test2\tcli/gh-test2\tUpgrade available\n", }, + { + name: "create extension tty", + args: []string{"create", "test"}, + managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) { + em.CreateFunc = func(name string) error { + return nil + } + return func(t *testing.T) { + calls := em.CreateCalls() + assert.Equal(t, 1, len(calls)) + assert.Equal(t, "gh-test", calls[0].Name) + } + }, + isTTY: true, + wantStdout: heredoc.Doc(` + ✓ Created directory gh-test + ✓ Initialized git repository + ✓ Set up extension scaffolding + + gh-test is ready for development + + Install locally with: cd gh-test && gh extension install . + + Publish to GitHub with: gh repo create gh-test + + For more information on writing extensions: + https://docs.github.com/github-cli/github-cli/creating-github-cli-extensions + `), + }, + { + name: "create extension notty", + args: []string{"create", "gh-test"}, + managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) { + em.CreateFunc = func(name string) error { + return nil + } + return func(t *testing.T) { + calls := em.CreateCalls() + assert.Equal(t, 1, len(calls)) + assert.Equal(t, "gh-test", calls[0].Name) + } + }, + isTTY: false, + wantStdout: "", + }, } for _, tt := range tests { diff --git a/pkg/cmd/extension/manager.go b/pkg/cmd/extension/manager.go index e6fb8b751..42cd19041 100644 --- a/pkg/cmd/extension/manager.go +++ b/pkg/cmd/extension/manager.go @@ -13,6 +13,7 @@ import ( "runtime" "strings" + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/internal/config" "github.com/cli/cli/pkg/extensions" "github.com/cli/cli/pkg/findsh" @@ -249,6 +250,76 @@ func (m *Manager) installDir() string { return filepath.Join(m.dataDir(), "extensions") } +func (m *Manager) Create(name string) error { + exe, err := m.lookPath("git") + if err != nil { + return err + } + + err = os.Mkdir(name, 0755) + if err != nil { + return err + } + + initCmd := m.newCommand(exe, "init", "--quiet", name) + err = initCmd.Run() + if err != nil { + return err + } + + fileTmpl := heredoc.Docf(` + #!/bin/bash + set -e + + echo "Hello %[1]s!" + + # Snippets to help get started: + + # Determine if an executable is in the PATH + # if ! type -p ruby >/dev/null; then + # echo "Ruby not found on the system" >&2 + # exit 1 + # fi + + # Pass arguments through to another command + # gh issue list "$@" -R cli/cli + + # Using the gh api command to retrieve and format information + # QUERY=' + # query($endCursor: String) { + # viewer { + # repositories(first: 100, after: $endCursor) { + # nodes { + # nameWithOwner + # stargazerCount + # } + # } + # } + # } + # ' + # TEMPLATE=' + # {{- range $repo := .data.viewer.repositories.nodes -}} + # {{- printf "name: %[2]s - stargazers: %[3]s\n" $repo.nameWithOwner $repo.stargazerCount -}} + # {{- end -}} + # ' + # exec gh api graphql -f query="${QUERY}" --paginate --template="${TEMPLATE}" + `, name, "%s", "%v") + filePath := filepath.Join(name, name) + err = ioutil.WriteFile(filePath, []byte(fileTmpl), 0755) + if err != nil { + return err + } + + wd, err := os.Getwd() + if err != nil { + return err + } + dir := filepath.Join(wd, name) + addCmd := m.newCommand(exe, "-C", dir, "--git-dir="+filepath.Join(dir, ".git"), "add", name, "--chmod=+x") + err = addCmd.Run() + return err +} + func runCmds(cmds []*exec.Cmd, stdout, stderr io.Writer) error { for _, cmd := range cmds { cmd.Stdout = stdout diff --git a/pkg/cmd/extension/manager_test.go b/pkg/cmd/extension/manager_test.go index a5e335636..2fd458bf1 100644 --- a/pkg/cmd/extension/manager_test.go +++ b/pkg/cmd/extension/manager_test.go @@ -202,6 +202,26 @@ func TestManager_Install(t *testing.T) { assert.Equal(t, "", stderr.String()) } +func TestManager_Create(t *testing.T) { + tempDir := t.TempDir() + oldWd, _ := os.Getwd() + assert.NoError(t, os.Chdir(tempDir)) + t.Cleanup(func() { _ = os.Chdir(oldWd) }) + m := newTestManager(tempDir) + err := m.Create("gh-test") + assert.NoError(t, err) + files, err := ioutil.ReadDir(filepath.Join(tempDir, "gh-test")) + assert.NoError(t, err) + assert.Equal(t, 1, len(files)) + extFile := files[0] + assert.Equal(t, "gh-test", extFile.Name()) + if runtime.GOOS == "windows" { + assert.Equal(t, os.FileMode(0666), extFile.Mode()) + } else { + assert.Equal(t, os.FileMode(0755), extFile.Mode()) + } +} + func stubExtension(path string) error { if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return err diff --git a/pkg/extensions/extension.go b/pkg/extensions/extension.go index e6e351877..54379ec7f 100644 --- a/pkg/extensions/extension.go +++ b/pkg/extensions/extension.go @@ -21,4 +21,5 @@ type ExtensionManager interface { Upgrade(name string, force bool, stdout, stderr io.Writer) error Remove(name string) error Dispatch(args []string, stdin io.Reader, stdout, stderr io.Writer) (bool, error) + Create(name string) error } diff --git a/pkg/extensions/manager_mock.go b/pkg/extensions/manager_mock.go index 31b4800cf..157262698 100644 --- a/pkg/extensions/manager_mock.go +++ b/pkg/extensions/manager_mock.go @@ -18,6 +18,9 @@ var _ ExtensionManager = &ExtensionManagerMock{} // // // make and configure a mocked ExtensionManager // mockedExtensionManager := &ExtensionManagerMock{ +// CreateFunc: func(name string) error { +// panic("mock out the Create method") +// }, // DispatchFunc: func(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) (bool, error) { // panic("mock out the Dispatch method") // }, @@ -43,6 +46,9 @@ var _ ExtensionManager = &ExtensionManagerMock{} // // } type ExtensionManagerMock struct { + // CreateFunc mocks the Create method. + CreateFunc func(name string) error + // DispatchFunc mocks the Dispatch method. DispatchFunc func(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) (bool, error) @@ -63,6 +69,11 @@ type ExtensionManagerMock struct { // calls tracks calls to the methods. calls struct { + // Create holds details about calls to the Create method. + Create []struct { + // Name is the name argument value. + Name string + } // Dispatch holds details about calls to the Dispatch method. Dispatch []struct { // Args is the args argument value. @@ -110,6 +121,7 @@ type ExtensionManagerMock struct { Stderr io.Writer } } + lockCreate sync.RWMutex lockDispatch sync.RWMutex lockInstall sync.RWMutex lockInstallLocal sync.RWMutex @@ -118,6 +130,37 @@ type ExtensionManagerMock struct { lockUpgrade sync.RWMutex } +// Create calls CreateFunc. +func (mock *ExtensionManagerMock) Create(name string) error { + if mock.CreateFunc == nil { + panic("ExtensionManagerMock.CreateFunc: method is nil but ExtensionManager.Create was just called") + } + callInfo := struct { + Name string + }{ + Name: name, + } + mock.lockCreate.Lock() + mock.calls.Create = append(mock.calls.Create, callInfo) + mock.lockCreate.Unlock() + return mock.CreateFunc(name) +} + +// CreateCalls gets all the calls that were made to Create. +// Check the length with: +// len(mockedExtensionManager.CreateCalls()) +func (mock *ExtensionManagerMock) CreateCalls() []struct { + Name string +} { + var calls []struct { + Name string + } + mock.lockCreate.RLock() + calls = mock.calls.Create + mock.lockCreate.RUnlock() + return calls +} + // Dispatch calls DispatchFunc. func (mock *ExtensionManagerMock) Dispatch(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) (bool, error) { if mock.DispatchFunc == nil {