Skip to content

Commit 6a80aa2

Browse files
authored
Merge pull request #226 from platformsh/config-install
Add a config:install command
2 parents dcb2da5 + 657da2a commit 6a80aa2

27 files changed

+1484
-73
lines changed

.goreleaser.vendor.yaml.tpl

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,11 @@ builds:
2626
- -s -w
2727
- -X "github.com/platformsh/cli/internal/legacy.PHPVersion={{.Env.PHP_VERSION}}"
2828
- -X "github.com/platformsh/cli/internal/legacy.LegacyCLIVersion={{.Env.LEGACY_CLI_VERSION}}"
29-
- -X "github.com/platformsh/cli/commands.version={{.Version}}"
30-
- -X "github.com/platformsh/cli/commands.commit={{.Commit}}"
31-
- -X "github.com/platformsh/cli/commands.date={{.Date}}"
32-
- -X "github.com/platformsh/cli/commands.vendor=${VENDOR_BINARY}"
33-
- -X "github.com/platformsh/cli/commands.builtBy=goreleaser"
29+
- -X "github.com/platformsh/cli/internal/config.Version={{.Version}}"
30+
- -X "github.com/platformsh/cli/internal/config.Commit={{.Commit}}"
31+
- -X "github.com/platformsh/cli/internal/config.Date={{.Date}}"
32+
- -X "github.com/platformsh/cli/internal/config.Vendor=${VENDOR_BINARY}"
33+
- -X "github.com/platformsh/cli/internal/config.BuiltBy=goreleaser"
3434
main: ./cmd/platform
3535
- binary: ${VENDOR_BINARY}
3636
id: ${VENDOR_BINARY}-macos

.goreleaser.yaml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,10 @@ builds:
2424
- -s -w
2525
- -X "github.com/platformsh/cli/internal/legacy.PHPVersion={{.Env.PHP_VERSION}}"
2626
- -X "github.com/platformsh/cli/internal/legacy.LegacyCLIVersion={{.Env.LEGACY_CLI_VERSION}}"
27-
- -X "github.com/platformsh/cli/commands.version={{.Version}}"
28-
- -X "github.com/platformsh/cli/commands.commit={{.Commit}}"
29-
- -X "github.com/platformsh/cli/commands.date={{.Date}}"
30-
- -X "github.com/platformsh/cli/commands.builtBy=goreleaser"
27+
- -X "github.com/platformsh/cli/internal/config.Version={{.Version}}"
28+
- -X "github.com/platformsh/cli/internal/config.Commit={{.Commit}}"
29+
- -X "github.com/platformsh/cli/internal/config.Date={{.Date}}"
30+
- -X "github.com/platformsh/cli/internal/config.BuiltBy=goreleaser"
3131
main: ./cmd/platform
3232
- binary: platform
3333
id: platform-macos

cmd/platform/main.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77

88
"github.com/spf13/cobra"
99
"github.com/spf13/viper"
10+
"github.com/symfony-cli/terminal"
1011

1112
"github.com/platformsh/cli/commands"
1213
"github.com/platformsh/cli/internal/config"
@@ -30,6 +31,13 @@ func main() {
3031
viper.SetEnvPrefix(strings.TrimSuffix(cnf.Application.EnvPrefix, "_"))
3132
viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))
3233
viper.AutomaticEnv()
34+
35+
if os.Getenv(cnf.Application.EnvPrefix+"NO_INTERACTION") == "1" {
36+
viper.Set("no-interaction", true)
37+
}
38+
if viper.GetBool("no-interaction") {
39+
terminal.Stdin.SetInteractive(false)
40+
}
3341
})
3442

3543
if err := commands.Execute(cnf); err != nil {

commands/config_install.go

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
package commands
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"io/fs"
7+
"os"
8+
"os/exec"
9+
"path/filepath"
10+
"runtime"
11+
"strings"
12+
13+
"github.com/fatih/color"
14+
"github.com/spf13/cobra"
15+
"github.com/symfony-cli/terminal"
16+
17+
"github.com/platformsh/cli/internal/config"
18+
"github.com/platformsh/cli/internal/config/alt"
19+
)
20+
21+
func newConfigInstallCommand() *cobra.Command {
22+
cmd := &cobra.Command{
23+
Use: "config:install [flags] [url]",
24+
Short: "Installs an alternative CLI, downloading new configuration from a URL",
25+
Args: cobra.ExactArgs(1),
26+
RunE: runConfigInstall,
27+
}
28+
cmd.Flags().String("bin-dir", "", "Install the executable in the given directory")
29+
cmd.Flags().String("config-dir", "", "Install the configuration in the given directory")
30+
cmd.Flags().Bool("absolute", false,
31+
"Use the absolute path to the current executable, instead of the configured name")
32+
cmd.Flags().BoolP("force", "f", false, "Force installation even if a duplicate executable exists")
33+
return cmd
34+
}
35+
36+
func runConfigInstall(cmd *cobra.Command, args []string) error {
37+
cnf := config.FromContext(cmd.Context())
38+
39+
// Validate input.
40+
executableDir, err := getExecutableDir(cmd)
41+
if err != nil {
42+
return err
43+
}
44+
configDir, err := getConfigDir(cmd)
45+
if err != nil {
46+
return err
47+
}
48+
target, err := getExecutableTarget(cmd, cnf)
49+
if err != nil {
50+
return err
51+
}
52+
53+
cmd.PrintErrln("Downloading and validating new CLI configuration...")
54+
cmd.PrintErrln()
55+
56+
urlStr := args[0]
57+
if !strings.Contains(urlStr, "://") {
58+
urlStr = "https://" + urlStr
59+
}
60+
newCnfNode, newCnfStruct, err := alt.FetchConfig(cmd.Context(), urlStr)
61+
if err != nil {
62+
return err
63+
}
64+
newExecutable := newCnfStruct.Application.Executable
65+
if newExecutable == cnf.Application.Executable {
66+
return fmt.Errorf("cannot install config for same executable name as this program: %s", newExecutable)
67+
}
68+
69+
configFilePath := filepath.Join(configDir, newExecutable) + ".yaml"
70+
executableFilePath := filepath.Join(executableDir, newExecutable) + alt.GetExecutableFileExtension()
71+
72+
pathVariableName := "PATH"
73+
if runtime.GOOS == "windows" {
74+
pathVariableName = "Path"
75+
}
76+
77+
// Check for duplicates.
78+
{
79+
force, err := cmd.Flags().GetBool("force")
80+
if err != nil {
81+
return err
82+
}
83+
if !force {
84+
if path, err := exec.LookPath(newExecutable); err == nil && path != executableFilePath {
85+
cmd.PrintErrln("An executable with the same name already exists at another location.")
86+
cmd.PrintErrf(
87+
"Use %s to ignore this check. "+
88+
"You would need to verify the %s precedence manually.\n",
89+
color.RedString("--force"),
90+
pathVariableName,
91+
)
92+
return fmt.Errorf("install failed due to duplicate executable with the name %s at: %s", newExecutable, path)
93+
}
94+
}
95+
}
96+
97+
formatPath := pathFormatter()
98+
99+
cmd.PrintErrln("The following files will be created or overwritten:")
100+
cmd.PrintErrf(" Configuration file: %s\n", color.CyanString(formatPath(configFilePath)))
101+
cmd.PrintErrf(" Executable: %s\n", color.CyanString(formatPath(executableFilePath)))
102+
cmd.PrintErrf("The executable runs %s with the new configuration.\n",
103+
color.CyanString(formatPath(target)))
104+
cmd.PrintErrln()
105+
if terminal.Stdin.IsInteractive() {
106+
if !terminal.AskConfirmation("Are you sure you want to continue?", true) {
107+
os.Exit(1)
108+
}
109+
cmd.PrintErrln()
110+
}
111+
112+
// Create the files.
113+
a := alt.New(
114+
executableFilePath,
115+
fmt.Sprintf("Automatically generated by the %s", cnf.Application.Name),
116+
target,
117+
configFilePath,
118+
newCnfNode,
119+
)
120+
if err := a.GenerateAndSave(); err != nil {
121+
return err
122+
}
123+
124+
cmd.PrintErrln("The files have been saved successfully.")
125+
cmd.PrintErrln()
126+
127+
if alt.InPath(executableDir) {
128+
cmd.PrintErrln("Run the new CLI with:", color.GreenString(newExecutable))
129+
} else {
130+
cmd.PrintErrf(
131+
"Add the following directory to your %s: %s\n",
132+
pathVariableName,
133+
color.YellowString(formatPath(executableDir)),
134+
)
135+
cmd.PrintErrln()
136+
cmd.PrintErrln("Then you will be able to run the new CLI with:", color.YellowString(newExecutable))
137+
}
138+
139+
return nil
140+
}
141+
142+
// Returns a formatter for displaying file and directory names.
143+
func pathFormatter() func(string) string {
144+
sub := "~"
145+
if runtime.GOOS == "windows" {
146+
sub = "%USERPROFILE%"
147+
}
148+
hd, err := os.UserHomeDir()
149+
return func(p string) string {
150+
if err == nil && strings.HasPrefix(p, hd) {
151+
return sub + strings.TrimPrefix(p, hd)
152+
}
153+
return p
154+
}
155+
}
156+
157+
func getExecutableTarget(cmd *cobra.Command, cnf *config.Config) (string, error) {
158+
abs, err := cmd.Flags().GetBool("absolute")
159+
if err != nil {
160+
return "", err
161+
}
162+
if abs {
163+
return os.Executable()
164+
}
165+
return cnf.Application.Executable, nil
166+
}
167+
168+
func getConfigDir(cmd *cobra.Command) (string, error) {
169+
configDirOpt, err := cmd.Flags().GetString("config-dir")
170+
if err != nil {
171+
return "", err
172+
}
173+
if configDirOpt != "" {
174+
return validateUserProvidedDir(configDirOpt)
175+
}
176+
return alt.FindConfigDir()
177+
}
178+
179+
func getExecutableDir(cmd *cobra.Command) (string, error) {
180+
binDirOpt, err := cmd.Flags().GetString("bin-dir")
181+
if err != nil {
182+
return "", err
183+
}
184+
if binDirOpt != "" {
185+
return validateUserProvidedDir(binDirOpt)
186+
}
187+
return alt.FindBinDir()
188+
}
189+
190+
func validateUserProvidedDir(path string) (normalized string, err error) {
191+
normalized, err = filepath.Abs(path)
192+
if err != nil {
193+
return
194+
}
195+
196+
lstat, err := os.Lstat(normalized)
197+
if err != nil {
198+
if errors.Is(err, fs.ErrNotExist) {
199+
err = fmt.Errorf("directory not found: %s", normalized)
200+
}
201+
return
202+
}
203+
if !lstat.IsDir() {
204+
err = fmt.Errorf("not a directory: %s", normalized)
205+
}
206+
207+
return
208+
}

commands/config_install_test.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package commands
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"io"
7+
"net/http"
8+
"net/http/httptest"
9+
"os"
10+
"path/filepath"
11+
"testing"
12+
13+
"github.com/stretchr/testify/assert"
14+
"github.com/stretchr/testify/require"
15+
"gopkg.in/yaml.v3"
16+
17+
"github.com/platformsh/cli/internal/config"
18+
)
19+
20+
func TestConfigInstallCmd(t *testing.T) {
21+
tempDir := t.TempDir()
22+
tempBinDir := filepath.Join(tempDir, "bin")
23+
require.NoError(t, os.Mkdir(tempBinDir, 0o755))
24+
_ = os.Setenv("HOME", tempDir)
25+
_ = os.Setenv("XDG_CONFIG_HOME", "")
26+
27+
remoteConfig := testConfig()
28+
29+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
30+
if req.URL.Path == "/test-config.yaml" {
31+
_ = yaml.NewEncoder(w).Encode(remoteConfig)
32+
}
33+
}))
34+
defer server.Close()
35+
testConfigURL := server.URL + "/test-config.yaml"
36+
37+
cnf := testConfig()
38+
ctx, cancel := context.WithCancel(context.Background())
39+
defer cancel()
40+
ctx = config.ToContext(ctx, cnf)
41+
42+
cmd := newConfigInstallCommand()
43+
cmd.SetContext(ctx)
44+
cmd.SetOut(io.Discard)
45+
_ = cmd.Flags().Set("config-dir", tempDir)
46+
_ = cmd.Flags().Set("bin-dir", tempBinDir)
47+
48+
args := []string{testConfigURL}
49+
50+
stdErrBuf := &bytes.Buffer{}
51+
cmd.SetErr(stdErrBuf)
52+
err := cmd.RunE(cmd, args)
53+
assert.ErrorContains(t, err, "cannot install config for same executable name as this program: test")
54+
55+
cnf.Application.Executable = "test-cli-executable-host"
56+
err = cmd.RunE(cmd, args)
57+
assert.NoError(t, err)
58+
assert.FileExists(t, filepath.Join(tempDir, "test-cli-executable.yaml"))
59+
assert.FileExists(t, filepath.Join(tempBinDir, "test-cli-executable"))
60+
assert.Contains(t, stdErrBuf.String(), "~/test-cli-executable.yaml")
61+
assert.Contains(t, stdErrBuf.String(), "~/bin/test-cli-executable")
62+
assert.Contains(t, stdErrBuf.String(), "Add the following directory to your PATH")
63+
64+
b, err := os.ReadFile(filepath.Join(tempBinDir, "test-cli-executable"))
65+
require.NoError(t, err)
66+
assert.Contains(t, string(b), `"${HOME}/test-cli-executable.yaml"`)
67+
assert.Contains(t, string(b), `test-cli-executable-host "$@"`)
68+
69+
_ = os.Setenv("PATH", tempBinDir+":"+os.Getenv("PATH"))
70+
remoteConfig.Application.Executable = "test-cli-executable2"
71+
err = cmd.RunE(cmd, args)
72+
assert.NoError(t, err)
73+
assert.FileExists(t, filepath.Join(tempDir, "test-cli-executable2.yaml"))
74+
assert.FileExists(t, filepath.Join(tempBinDir, "test-cli-executable2"))
75+
assert.Contains(t, stdErrBuf.String(), "~/test-cli-executable2.yaml")
76+
assert.Contains(t, stdErrBuf.String(), "~/bin/test-cli-executable2")
77+
assert.Contains(t, stdErrBuf.String(), "Run the new CLI with: test-cli-executable2")
78+
}
79+
80+
func testConfig() *config.Config {
81+
cnf := &config.Config{}
82+
cnf.Application.Name = "Test CLI"
83+
cnf.Application.Executable = "test-cli-executable" // Not "test" as that is usually a real binary
84+
cnf.Application.EnvPrefix = "TEST_"
85+
cnf.Application.Slug = "test-cli"
86+
cnf.Application.UserConfigDir = ".test-cli"
87+
cnf.API.BaseURL = "https://localhost"
88+
cnf.API.AuthURL = "https://localhost"
89+
cnf.Detection.GitRemoteName = "platform"
90+
cnf.Service.Name = "Test"
91+
cnf.Service.EnvPrefix = "TEST_"
92+
cnf.Service.ProjectConfigDir = ".test"
93+
cnf.SSH.DomainWildcards = []string{"*"}
94+
return cnf
95+
}

0 commit comments

Comments
 (0)