Skip to content

Commit 58ff56f

Browse files
committed
add ghapp config command for non-interactive setup
set/get/path subcommands for scripted config management.
1 parent 4506227 commit 58ff56f

5 files changed

Lines changed: 366 additions & 0 deletions

File tree

internal/auth/keyring.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,10 @@ func LoadPrivateKey() ([]byte, error) {
3737
func DeletePrivateKey() error {
3838
return ring.Delete(keyringService, keyringUser)
3939
}
40+
41+
// SetKeyringProvider replaces the keyring provider and returns a restore function.
42+
func SetKeyringProvider(p KeyringProvider) (restore func()) {
43+
old := ring
44+
ring = p
45+
return func() { ring = old }
46+
}

internal/cmd/cmd_test.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"testing"
1111
"time"
1212

13+
"github.com/spf13/pflag"
1314
"github.com/stretchr/testify/assert"
1415
"github.com/stretchr/testify/require"
1516

@@ -59,6 +60,10 @@ func saveRestore(t *testing.T) {
5960
origGhAuth := ghAuthFlag
6061
origRemoveKey := removeKey
6162
origShellOverride := shellinit.ShellOverride
63+
origSetAppID := setAppID
64+
origSetInstallID := setInstallationID
65+
origSetKeyPath := setPrivateKeyPath
66+
origSetImportKey := setImportKey
6267
// Isolate token cache to temp dir
6368
tokencache.DirOverride = t.TempDir()
6469
selfupdate.DirOverride = t.TempDir()
@@ -72,6 +77,14 @@ func saveRestore(t *testing.T) {
7277
ghAuthFlag = origGhAuth
7378
removeKey = origRemoveKey
7479
shellinit.ShellOverride = origShellOverride
80+
setAppID = origSetAppID
81+
setInstallationID = origSetInstallID
82+
setPrivateKeyPath = origSetKeyPath
83+
setImportKey = origSetImportKey
84+
// Reset cobra Changed state so flags don't leak between tests
85+
configSetCmd.Flags().VisitAll(func(f *pflag.Flag) {
86+
f.Changed = false
87+
})
7588
})
7689
}
7790

internal/cmd/config.go

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
package cmd
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"os"
7+
8+
"github.com/spf13/cobra"
9+
10+
"github.com/operator-kit/ghapp-cli/internal/auth"
11+
"github.com/operator-kit/ghapp-cli/internal/config"
12+
)
13+
14+
var (
15+
setAppID int64
16+
setInstallationID int64
17+
setPrivateKeyPath string
18+
setImportKey string
19+
)
20+
21+
var configCmd = &cobra.Command{
22+
Use: "config",
23+
Short: "View and modify configuration",
24+
}
25+
26+
var configSetCmd = &cobra.Command{
27+
Use: "set",
28+
Short: "Set config values",
29+
RunE: runConfigSet,
30+
}
31+
32+
var configGetCmd = &cobra.Command{
33+
Use: "get [key]",
34+
Short: "Print config values",
35+
Args: cobra.MaximumNArgs(1),
36+
RunE: runConfigGet,
37+
}
38+
39+
var configPathCmd = &cobra.Command{
40+
Use: "path",
41+
Short: "Print config file location",
42+
RunE: runConfigPath,
43+
}
44+
45+
func init() {
46+
configSetCmd.Flags().Int64Var(&setAppID, "app-id", 0, "GitHub App ID")
47+
configSetCmd.Flags().Int64Var(&setInstallationID, "installation-id", 0, "GitHub App installation ID")
48+
configSetCmd.Flags().StringVar(&setPrivateKeyPath, "private-key-path", "", "path to private key PEM file")
49+
configSetCmd.Flags().StringVar(&setImportKey, "import-key", "", "import private key into OS keyring from PEM file")
50+
51+
configCmd.AddCommand(configSetCmd)
52+
configCmd.AddCommand(configGetCmd)
53+
configCmd.AddCommand(configPathCmd)
54+
}
55+
56+
func configFilePath() (string, error) {
57+
if cfgPath != "" {
58+
return cfgPath, nil
59+
}
60+
return config.DefaultPath()
61+
}
62+
63+
func runConfigSet(cmd *cobra.Command, args []string) error {
64+
if cmd.Flags().Changed("private-key-path") && cmd.Flags().Changed("import-key") {
65+
return errors.New("--private-key-path and --import-key are mutually exclusive")
66+
}
67+
68+
path, err := configFilePath()
69+
if err != nil {
70+
return err
71+
}
72+
73+
// Load existing or start fresh
74+
existing, err := config.Load(path)
75+
if err != nil {
76+
existing = &config.Config{}
77+
}
78+
79+
if cmd.Flags().Changed("app-id") {
80+
existing.AppID = setAppID
81+
}
82+
if cmd.Flags().Changed("installation-id") {
83+
existing.InstallationID = setInstallationID
84+
}
85+
if cmd.Flags().Changed("private-key-path") {
86+
existing.PrivateKeyPath = setPrivateKeyPath
87+
existing.KeyInKeyring = false
88+
}
89+
if cmd.Flags().Changed("import-key") {
90+
pemData, err := os.ReadFile(setImportKey)
91+
if err != nil {
92+
return fmt.Errorf("read key file: %w", err)
93+
}
94+
if err := auth.StorePrivateKey(pemData); err != nil {
95+
return fmt.Errorf("store key in keyring: %w", err)
96+
}
97+
existing.KeyInKeyring = true
98+
existing.PrivateKeyPath = ""
99+
}
100+
101+
if err := config.Save(path, existing); err != nil {
102+
return err
103+
}
104+
fmt.Fprintf(cmd.OutOrStdout(), "Config saved to %s\n", path)
105+
return nil
106+
}
107+
108+
func runConfigGet(cmd *cobra.Command, args []string) error {
109+
path, err := configFilePath()
110+
if err != nil {
111+
return err
112+
}
113+
114+
c, err := config.Load(path)
115+
if err != nil {
116+
return fmt.Errorf("load config: %w", err)
117+
}
118+
119+
out := cmd.OutOrStdout()
120+
121+
if len(args) == 0 {
122+
fmt.Fprintf(out, "app-id: %d\n", c.AppID)
123+
fmt.Fprintf(out, "installation-id: %d\n", c.InstallationID)
124+
fmt.Fprintf(out, "private-key-path: %s\n", c.PrivateKeyPath)
125+
fmt.Fprintf(out, "key-in-keyring: %v\n", c.KeyInKeyring)
126+
return nil
127+
}
128+
129+
switch args[0] {
130+
case "app-id":
131+
fmt.Fprintf(out, "%d\n", c.AppID)
132+
case "installation-id":
133+
fmt.Fprintf(out, "%d\n", c.InstallationID)
134+
case "private-key-path":
135+
fmt.Fprintf(out, "%s\n", c.PrivateKeyPath)
136+
case "key-in-keyring":
137+
fmt.Fprintf(out, "%v\n", c.KeyInKeyring)
138+
default:
139+
return fmt.Errorf("unknown config key: %s", args[0])
140+
}
141+
return nil
142+
}
143+
144+
func runConfigPath(cmd *cobra.Command, args []string) error {
145+
path, err := configFilePath()
146+
if err != nil {
147+
return err
148+
}
149+
fmt.Fprintln(cmd.OutOrStdout(), path)
150+
return nil
151+
}

internal/cmd/config_test.go

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
package cmd
2+
3+
import (
4+
"bytes"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
"testing"
9+
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
13+
"github.com/operator-kit/ghapp-cli/internal/auth"
14+
"github.com/operator-kit/ghapp-cli/internal/config"
15+
)
16+
17+
// cmdMockKeyring implements auth.KeyringProvider for cmd-level tests.
18+
type cmdMockKeyring struct {
19+
store map[string]string
20+
}
21+
22+
func newCmdMockKeyring() *cmdMockKeyring {
23+
return &cmdMockKeyring{store: make(map[string]string)}
24+
}
25+
26+
func (m *cmdMockKeyring) Set(service, user, password string) error {
27+
m.store[service+"/"+user] = password
28+
return nil
29+
}
30+
31+
func (m *cmdMockKeyring) Get(service, user string) (string, error) {
32+
v, ok := m.store[service+"/"+user]
33+
if !ok {
34+
return "", fmt.Errorf("not found")
35+
}
36+
return v, nil
37+
}
38+
39+
func (m *cmdMockKeyring) Delete(service, user string) error {
40+
delete(m.store, service+"/"+user)
41+
return nil
42+
}
43+
44+
func TestConfigSet(t *testing.T) {
45+
saveRestore(t)
46+
47+
dir := t.TempDir()
48+
cfgFile := filepath.Join(dir, "config.yaml")
49+
cfgPath = cfgFile
50+
51+
buf := new(bytes.Buffer)
52+
rootCmd.SetOut(buf)
53+
rootCmd.SetArgs([]string{"config", "set", "--app-id", "123", "--installation-id", "456", "--private-key-path", "/path/to/key.pem"})
54+
require.NoError(t, rootCmd.Execute())
55+
56+
assert.Contains(t, buf.String(), "Config saved")
57+
58+
loaded, err := config.Load(cfgFile)
59+
require.NoError(t, err)
60+
assert.Equal(t, int64(123), loaded.AppID)
61+
assert.Equal(t, int64(456), loaded.InstallationID)
62+
assert.Equal(t, "/path/to/key.pem", loaded.PrivateKeyPath)
63+
}
64+
65+
func TestConfigSet_Partial(t *testing.T) {
66+
saveRestore(t)
67+
68+
dir := t.TempDir()
69+
cfgFile := filepath.Join(dir, "config.yaml")
70+
cfgPath = cfgFile
71+
72+
// Pre-populate
73+
require.NoError(t, config.Save(cfgFile, &config.Config{
74+
AppID: 111,
75+
InstallationID: 222,
76+
PrivateKeyPath: "/existing/key.pem",
77+
}))
78+
79+
buf := new(bytes.Buffer)
80+
rootCmd.SetOut(buf)
81+
rootCmd.SetArgs([]string{"config", "set", "--app-id", "999"})
82+
require.NoError(t, rootCmd.Execute())
83+
84+
loaded, err := config.Load(cfgFile)
85+
require.NoError(t, err)
86+
assert.Equal(t, int64(999), loaded.AppID)
87+
assert.Equal(t, int64(222), loaded.InstallationID)
88+
assert.Equal(t, "/existing/key.pem", loaded.PrivateKeyPath)
89+
}
90+
91+
func TestConfigSet_ImportKey(t *testing.T) {
92+
saveRestore(t)
93+
94+
mk := newCmdMockKeyring()
95+
restore := auth.SetKeyringProvider(mk)
96+
t.Cleanup(restore)
97+
98+
dir := t.TempDir()
99+
cfgFile := filepath.Join(dir, "config.yaml")
100+
cfgPath = cfgFile
101+
102+
pemFile := filepath.Join(dir, "test.pem")
103+
require.NoError(t, os.WriteFile(pemFile, []byte("fake-pem-data"), 0o600))
104+
105+
buf := new(bytes.Buffer)
106+
rootCmd.SetOut(buf)
107+
rootCmd.SetArgs([]string{"config", "set", "--app-id", "123", "--import-key", pemFile})
108+
require.NoError(t, rootCmd.Execute())
109+
110+
loaded, err := config.Load(cfgFile)
111+
require.NoError(t, err)
112+
assert.True(t, loaded.KeyInKeyring)
113+
assert.Empty(t, loaded.PrivateKeyPath)
114+
assert.Equal(t, int64(123), loaded.AppID)
115+
116+
// Verify key stored in mock keyring
117+
assert.Equal(t, "fake-pem-data", mk.store["ghapp-cli/private-key"])
118+
}
119+
120+
func TestConfigSet_MutualExclusion(t *testing.T) {
121+
saveRestore(t)
122+
cfgPath = filepath.Join(t.TempDir(), "config.yaml")
123+
124+
buf := new(bytes.Buffer)
125+
rootCmd.SetOut(buf)
126+
rootCmd.SetArgs([]string{"config", "set", "--private-key-path", "/a.pem", "--import-key", "/b.pem"})
127+
err := rootCmd.Execute()
128+
require.Error(t, err)
129+
assert.Contains(t, err.Error(), "mutually exclusive")
130+
}
131+
132+
func TestConfigGet(t *testing.T) {
133+
saveRestore(t)
134+
135+
dir := t.TempDir()
136+
cfgFile := filepath.Join(dir, "config.yaml")
137+
cfgPath = cfgFile
138+
139+
require.NoError(t, config.Save(cfgFile, &config.Config{
140+
AppID: 123,
141+
InstallationID: 456,
142+
PrivateKeyPath: "/path/to/key.pem",
143+
}))
144+
145+
buf := new(bytes.Buffer)
146+
rootCmd.SetOut(buf)
147+
rootCmd.SetArgs([]string{"config", "get"})
148+
require.NoError(t, rootCmd.Execute())
149+
150+
output := buf.String()
151+
assert.Contains(t, output, "app-id: 123")
152+
assert.Contains(t, output, "installation-id: 456")
153+
assert.Contains(t, output, "private-key-path: /path/to/key.pem")
154+
assert.Contains(t, output, "key-in-keyring: false")
155+
}
156+
157+
func TestConfigGet_SingleKey(t *testing.T) {
158+
saveRestore(t)
159+
160+
dir := t.TempDir()
161+
cfgFile := filepath.Join(dir, "config.yaml")
162+
cfgPath = cfgFile
163+
164+
require.NoError(t, config.Save(cfgFile, &config.Config{
165+
AppID: 123,
166+
InstallationID: 456,
167+
}))
168+
169+
buf := new(bytes.Buffer)
170+
rootCmd.SetOut(buf)
171+
rootCmd.SetArgs([]string{"config", "get", "app-id"})
172+
require.NoError(t, rootCmd.Execute())
173+
174+
assert.Equal(t, "123\n", buf.String())
175+
}
176+
177+
func TestConfigPath(t *testing.T) {
178+
saveRestore(t)
179+
180+
cfgFile := filepath.Join(t.TempDir(), "config.yaml")
181+
cfgPath = cfgFile
182+
183+
buf := new(bytes.Buffer)
184+
rootCmd.SetOut(buf)
185+
rootCmd.SetArgs([]string{"config", "path"})
186+
require.NoError(t, rootCmd.Execute())
187+
188+
assert.Equal(t, cfgFile+"\n", buf.String())
189+
}

0 commit comments

Comments
 (0)