├── .gitignore ├── docs ├── _config.yml ├── open-in-code.md ├── index.md ├── installation.md ├── _layouts │ └── default.html ├── exec.md ├── template.md └── snippet.md ├── internal └── pkg │ ├── errors │ └── errors.go │ ├── devcontainers │ ├── devcontainer.go │ ├── dockerutils_test.go │ ├── remoteuri_test.go │ ├── remoteuri.go │ ├── template.go │ ├── template_test.go │ ├── snippet.go │ ├── dockerutils.go │ └── snippet_test.go │ ├── terminal │ └── terminal.go │ ├── git │ └── git.go │ ├── wsl │ └── wsl.go │ ├── update │ └── update.go │ ├── status │ └── status.go │ ├── ioutil │ └── files.go │ └── config │ └── config.go ├── .vscode ├── settings.json └── launch.json ├── go.mod ├── cmd └── devcontainerx │ ├── version.go │ ├── main.go │ ├── config.go │ ├── completion.go │ ├── openincode.go │ ├── update.go │ ├── snippet.go │ ├── template.go │ └── devcontainer.go ├── scripts ├── install.sh └── ci_release.sh ├── .devcontainer ├── scripts │ ├── gh.sh │ └── docker-client.sh ├── devcontainer.json └── Dockerfile ├── .goreleaser.yml ├── LICENSE ├── Makefile ├── .github └── workflows │ └── build-and-release.yaml ├── README.md └── go.sum /.gitignore: -------------------------------------------------------------------------------- 1 | /devcontainer 2 | /devcontainerx 3 | /dist -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-slate 2 | 3 | title: devcontainer-cli 4 | description: Making the most of VS Code dev containers at the terminal 5 | rootLink: https://stuartleeks.github.io/devcontainer-cli 6 | -------------------------------------------------------------------------------- /internal/pkg/errors/errors.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | var _ error = &StatusError{} 4 | 5 | // StatusError is used to return informational errors 6 | type StatusError struct { 7 | Message string 8 | } 9 | 10 | func (e *StatusError) Error() string { return e.Message } 11 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "go.delveConfig": { 3 | "dlvLoadConfig": { 4 | "followPointers": true, 5 | "maxVariableRecurse": 1, 6 | "maxStringLen": 512, 7 | "maxArrayValues": 64, 8 | "maxStructFields": -1 9 | }, 10 | "apiVersion": 2, 11 | "showGlobalVariables": false, 12 | "debugAdapter": "legacy" 13 | } 14 | } -------------------------------------------------------------------------------- /docs/open-in-code.md: -------------------------------------------------------------------------------- 1 | # devcontainer open-in-code 2 | 3 | The `devcontainer open-in-code` command opens the current folder in VS Code as a dev container, i.e. it skips the normal step of opening in VS Code and then clicking # on the "Re-open in container" prompt to reload the window as a dev container. With `devcontainer open-in-code` you get straight to the dev container! 4 | 5 | You can also use `devcontainer open-in-code ` to open a different folder as a devcontainer. 6 | 7 | If you want to use the VS Code Insiders release, you can use `devcontainer open-in-code-insiders`. -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/stuartleeks/devcontainer-cli 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/blang/semver v3.5.1+incompatible 7 | github.com/bradford-hamilton/dora v0.1.1 8 | github.com/kyoh86/richgo v0.3.12 // indirect 9 | github.com/mattn/go-isatty v0.0.19 // indirect 10 | github.com/rhysd/go-github-selfupdate v1.2.2 11 | github.com/spf13/cobra v1.0.0 12 | github.com/spf13/viper v1.4.0 13 | github.com/stretchr/testify v1.8.2 14 | golang.org/x/sys v0.10.0 // indirect 15 | gopkg.in/yaml.v2 v2.4.0 // indirect 16 | ) 17 | 18 | replace github.com/bradford-hamilton/dora v0.1.1 => github.com/stuartleeks/dora v0.1.5 19 | -------------------------------------------------------------------------------- /cmd/devcontainerx/version.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | func createVersionCommand() *cobra.Command { 10 | 11 | var verbose bool 12 | 13 | cmd := &cobra.Command{ 14 | Use: "version", 15 | Short: "show version", 16 | Long: "Show version", 17 | RunE: func(cmd *cobra.Command, args []string) error { 18 | if verbose { 19 | fmt.Printf("devcontainer version %s\nBuilt %s (commit %s)\n%s\n\n", version, date, commit, goversion) 20 | return nil 21 | } 22 | fmt.Println(version) 23 | return nil 24 | }, 25 | } 26 | cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "Verbose output") 27 | 28 | return cmd 29 | } 30 | -------------------------------------------------------------------------------- /internal/pkg/devcontainers/devcontainer.go: -------------------------------------------------------------------------------- 1 | package devcontainers 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | ) 9 | 10 | func getDevContainerJsonPath(folderPath string) (string, error) { 11 | pathsToTest := []string{".devcontainer/devcontainer.json", ".devcontainer.json"} 12 | 13 | for _, path := range pathsToTest { 14 | devcontainerJsonPath := filepath.Join(folderPath, path) 15 | devContainerJsonInfo, err := os.Stat(devcontainerJsonPath) 16 | if err == nil && !devContainerJsonInfo.IsDir() { 17 | return devcontainerJsonPath, nil 18 | } 19 | } 20 | 21 | return "", fmt.Errorf("devcontainer.json not found. Looked for %s", strings.Join(pathsToTest, ",")) 22 | } 23 | -------------------------------------------------------------------------------- /internal/pkg/terminal/terminal.go: -------------------------------------------------------------------------------- 1 | package terminal 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | // UpdatingStatusWriter overwrites messages on successive writes 9 | type UpdatingStatusWriter struct { 10 | lastMessageLength int 11 | } 12 | 13 | func (w *UpdatingStatusWriter) Printf(format string, a ...interface{}) { 14 | // format current message 15 | currentMessage := fmt.Sprintf(format, a...) 16 | // right-pad current message and prefix with carriage return to put at start of line 17 | fmt.Printf("\r%-*s", w.lastMessageLength, currentMessage) 18 | 19 | w.lastMessageLength = len(currentMessage) 20 | } 21 | 22 | func IsTTY() bool { 23 | fi, err := os.Stdout.Stat() 24 | if err != nil { 25 | return false 26 | } 27 | return fi.Mode()&os.ModeCharDevice != 0 28 | } 29 | -------------------------------------------------------------------------------- /scripts/install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # Expects 5 | # OS=linux or darwin 6 | # ARCH=amd64 or 386 7 | 8 | if [[ -z "$OS" ]]; then 9 | echo "$OS" 10 | echo "OS not set: expected linux or darwin" 11 | exit 1 12 | fi 13 | if [[ -z "$ARCH" ]]; then 14 | echo "ARCH not set: expected amd64 or 386" 15 | exit 1 16 | fi 17 | 18 | 19 | LATEST_VERSION=$(curl --silent "https://api.github.com/repos/stuartleeks/devcontainer-cli/releases/latest" | grep -Po '"tag_name": "\K.*?(?=")') 20 | echo $LATEST_VERSION 21 | mkdir -p ~/bin 22 | wget https://github.com/stuartleeks/devcontainer-cli/releases/download/${LATEST_VERSION}/devcontainer-cli_${OS}_${ARCH}.tar.gz 23 | tar -C ~/bin -zxvf devcontainer-cli_${OS}_${ARCH}.tar.gz devcontainerx 24 | chmod +x ~/bin/devcontainerx 25 | -------------------------------------------------------------------------------- /.devcontainer/scripts/gh.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | get_latest_release() { 5 | curl --silent "https://api.github.com/repos/$1/releases/latest" | 6 | grep '"tag_name":' | sed -E 's/.*"v([^"]+)".*/\1/' 7 | } 8 | 9 | VERSION=${1:-"$(get_latest_release cli/cli)"} 10 | INSTALL_DIR=${2:-"$HOME/.local/bin"} 11 | CMD=gh 12 | NAME="GitHub CLI" 13 | 14 | echo -e "\e[34m»»» 📦 \e[32mInstalling \e[33m$NAME \e[35mv$VERSION\e[0m ..." 15 | 16 | mkdir -p $INSTALL_DIR 17 | curl -sSL https://github.com/cli/cli/releases/download/v${VERSION}/gh_${VERSION}_linux_amd64.tar.gz -o /tmp/gh.tar.gz 18 | tar -zxvf /tmp/gh.tar.gz --strip-components 2 -C $INSTALL_DIR gh_${VERSION}_linux_amd64/bin/gh > /dev/null 19 | chmod +x $INSTALL_DIR/gh 20 | rm -rf /tmp/gh.tar.gz 21 | 22 | echo -e "\n\e[34m»»» 💾 \e[32mInstalled to: \e[33m$(which $CMD)" 23 | echo -e "\e[34m»»» 💡 \e[32mVersion details: \e[39m$($CMD --version)" 24 | -------------------------------------------------------------------------------- /.devcontainer/scripts/docker-client.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | VERSION=${1:-"20.10.5"} 5 | INSTALL_DIR=${2:-"$HOME/.local/bin"} 6 | CMD=docker 7 | NAME="Docker Client" 8 | 9 | echo -e "\e[34m»»» 📦 \e[32mInstalling \e[33m$NAME \e[35mv$VERSION\e[0m ..." 10 | 11 | mkdir -p $INSTALL_DIR 12 | curl -sSL https://download.docker.com/linux/static/stable/x86_64/docker-$VERSION.tgz -o /tmp/docker.tgz 13 | tar -zxvf /tmp/docker.tgz -C /tmp docker/docker 14 | chmod +x /tmp/docker/docker 15 | mv /tmp/docker/docker $INSTALL_DIR/docker 16 | rmdir /tmp/docker/ 17 | rm -rf /tmp/docker.tgz 18 | 19 | echo -e "\n\e[34m»»» 💾 \e[32mInstalled to: \e[33m$(which $CMD)" 20 | echo -e "\e[34m»»» 💡 \e[32mVersion details: \e[39m$($CMD --version)" 21 | 22 | 23 | if [ -z "$DOCKER_GROUP_ID" ]; then 24 | sudo groupadd docker 25 | else 26 | sudo groupadd -g $DOCKER_GROUP_ID docker 27 | fi 28 | 29 | sudo usermod -aG docker $(whoami) && newgrp docker 30 | getent group docker 31 | -------------------------------------------------------------------------------- /internal/pkg/git/git.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "fmt" 5 | "os/exec" 6 | "strings" 7 | "syscall" 8 | ) 9 | 10 | // GetTopLevelPath returns the top-level folder for the git-repo that contains path, or empty string if not a repo 11 | func GetTopLevelPath(path string) (string, error) { 12 | cmd := exec.Command("git", "rev-parse", "--show-toplevel") 13 | cmd.Dir = path 14 | 15 | buf, err := cmd.Output() 16 | if err != nil { 17 | if exiterr, ok := err.(*exec.ExitError); ok { 18 | // The program has exited with an exit code != 0 19 | if status, ok := exiterr.Sys().(syscall.WaitStatus); ok { 20 | if status.ExitStatus() == 128 { 21 | // exit code 128 indictates not a git repo 22 | return "", nil 23 | } 24 | } 25 | return "", fmt.Errorf("Error git rev-parse --show-toplevel: %s", err) 26 | } 27 | return "", fmt.Errorf("Error git rev-parse --show-toplevel: %s", err) 28 | } 29 | return strings.TrimSpace(string(buf)), nil 30 | } 31 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | builds: 2 | - env: 3 | - CGO_ENABLED=0 4 | binary: devcontainerx 5 | goos: 6 | - linux 7 | - darwin 8 | - windows 9 | goarch: 10 | - 386 11 | - amd64 12 | main: ./cmd/devcontainerx/ 13 | ldflags: 14 | - -s -w -X main.version={{.Version}} -X main.commit={{.ShortCommit}} -X main.date={{.Date}} -X "main.goversion={{.Env.GOVERSION}}" 15 | 16 | archives: 17 | - id: zip 18 | name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}" 19 | format_overrides: 20 | - goos: windows 21 | format: zip 22 | 23 | brews: 24 | - tap: 25 | owner: stuartleeks 26 | name: homebrew-tap 27 | folder: Formula 28 | name: devcontainer 29 | homepage: https://github.com/stuartleeks/devcontainer-cli 30 | description: CLI for working with Visual Studio Code devcontainers 31 | install: | 32 | bin.install "devcontainerx" 33 | test: | 34 | system "#{bin}/devcontainerx --version" 35 | 36 | changelog: 37 | sort: asc 38 | filters: 39 | exclude: 40 | - '^docs:' 41 | - '^test:' 42 | -------------------------------------------------------------------------------- /scripts/ci_release.sh: -------------------------------------------------------------------------------- 1 | set -e 2 | 3 | # Fail if build number not set 4 | if [ -z "$BUILD_NUMBER" ]; then 5 | echo "Env var 'BUILD_NUMBER' must be set for this script to work correctly" 6 | exit 1 7 | fi 8 | 9 | # If running inside CI login to docker 10 | if [ -z ${IS_CI} ]; then 11 | echo "Not running in CI, skipping CI setup" 12 | else 13 | if [ -z $IS_PR ] && [[ $BRANCH == "refs/heads/main" ]]; then 14 | echo "On main setting PUBLISH=true" 15 | export PUBLISH=true 16 | else 17 | echo "Skipping publish as is from PR: $PR_NUMBER or not 'refs/heads/main' BRANCH: $BRANCH" 18 | fi 19 | fi 20 | 21 | echo "Prechown" 22 | git status 23 | 24 | sudo chown -R $(whoami) . 25 | 26 | # Set version for release (picked up later by goreleaser) 27 | git tag -f v0.1.$BUILD_NUMBER 28 | 29 | export GOVERSION=$(go version) 30 | 31 | echo "Prelint" 32 | git status 33 | 34 | make lint 35 | 36 | echo "Postlint" 37 | git status 38 | 39 | if [ -z ${PUBLISH} ]; then 40 | echo "Running with --skip-publish as PUBLISH not set" 41 | goreleaser --skip-publish --rm-dist 42 | else 43 | echo "Publishing release" 44 | goreleaser 45 | fi -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Stuart Leeks 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /cmd/devcontainerx/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | "github.com/stuartleeks/devcontainer-cli/internal/pkg/config" 6 | "github.com/stuartleeks/devcontainer-cli/internal/pkg/update" 7 | ) 8 | 9 | // Overridden via ldflags 10 | var ( 11 | version = "99.0.1-devbuild" 12 | commit = "unknown" 13 | date = "unknown" 14 | goversion = "unknown" 15 | ) 16 | 17 | func main() { 18 | 19 | rootCmd := &cobra.Command{ 20 | Use: "devcontainerx", 21 | PersistentPreRun: func(cmd *cobra.Command, args []string) { 22 | update.PeriodicCheckForUpdate(version) 23 | }, 24 | } 25 | 26 | rootCmd.AddCommand(createCompleteCommand(rootCmd)) 27 | rootCmd.AddCommand(createConfigCommand()) 28 | rootCmd.AddCommand(createExecCommand()) 29 | rootCmd.AddCommand(createListCommand()) 30 | rootCmd.AddCommand(createShowCommand()) 31 | rootCmd.AddCommand(createTemplateCommand()) 32 | if config.GetExperimentalFeaturesEnabled() { 33 | rootCmd.AddCommand(createSnippetCommand()) 34 | } 35 | rootCmd.AddCommand(createUpdateCommand()) 36 | rootCmd.AddCommand(createOpenInCodeCommand()) 37 | rootCmd.AddCommand(createOpenInCodeInsidersCommand()) 38 | rootCmd.AddCommand(createVersionCommand()) 39 | 40 | _ = rootCmd.Execute() 41 | } 42 | -------------------------------------------------------------------------------- /internal/pkg/wsl/wsl.go: -------------------------------------------------------------------------------- 1 | package wsl 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "strings" 8 | ) 9 | 10 | // IsWsl returns true if running under WSL 11 | func IsWsl() bool { 12 | _, exists := os.LookupEnv("WSL_DISTRO_NAME") 13 | return exists 14 | } 15 | 16 | // ConvertWslPathToWindowsPath converts a WSL path to the corresponding \\wsl$\... path for access from Windows 17 | func ConvertWslPathToWindowsPath(path string) (string, error) { 18 | cmd := exec.Command("wslpath", "-w", path) 19 | 20 | buf, err := cmd.Output() 21 | if err != nil { 22 | return "", fmt.Errorf("Error running wslpath (for %q): %s", path, err) 23 | } 24 | return strings.TrimSpace(string(buf)), nil 25 | } 26 | 27 | // ConvertWslPathToWindowsPath converts a WSL path to the corresponding \\wsl$\... path for access from Windows 28 | func ConvertWindowsPathToWslPath(path string) (string, error) { 29 | cmd := exec.Command("wslpath", "-u", path) 30 | 31 | buf, err := cmd.Output() 32 | if err != nil { 33 | return "", fmt.Errorf("Error running wslpath (for %q): %s", path, err) 34 | } 35 | return strings.TrimSpace(string(buf)), nil 36 | } 37 | 38 | func HasWslPathPrefix(path string) bool { 39 | return strings.HasPrefix(path, "\\\\wsl$\\") || strings.HasPrefix(path, "\\\\wsl.localhost\\") 40 | } 41 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: help 2 | help: ## show this help 3 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \ 4 | | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%s\033[0m|%s\n", $$1, $$2}' \ 5 | | column -t -s '|' 6 | 7 | 8 | build: fmt ## Build devcontainer cli 9 | go build ./cmd/devcontainerx 10 | 11 | lint: build ## Build and lint 12 | golangci-lint run 13 | 14 | devcontainer: ## (Advanced) Build the devcontainer 15 | docker build -f ./.devcontainer/Dockerfile ./.devcontainer -t devcontainer-cli 16 | 17 | devcontainer-release: ## (Advanced) Run the devcontainer for release 18 | ifdef DEVCONTAINER 19 | $(error This target can only be run outside of the devcontainer as it mounts files and this fails within a devcontainer. Don't worry all it needs is docker) 20 | endif 21 | @docker run -v ${PWD}:${PWD} \ 22 | -e BUILD_NUMBER="${BUILD_NUMBER}" \ 23 | -e IS_CI="${IS_CI}" \ 24 | -e IS_PR="${IS_PR}" \ 25 | -e BRANCH="${BRANCH}" \ 26 | -e GITHUB_TOKEN="${GITHUB_TOKEN}" \ 27 | --entrypoint /bin/bash \ 28 | --workdir "${PWD}" \ 29 | devcontainer-cli \ 30 | -c "${PWD}/scripts/ci_release.sh" 31 | 32 | 33 | test: 34 | richgo test -v ./... 35 | 36 | 37 | fmt: 38 | find . -name '*.go' | grep -v vendor | xargs gofmt -s -w 39 | 40 | 41 | post-create: 42 | sudo chown vscode /go/pkg 43 | go get -u github.com/kyoh86/richgo -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | If you find frequently find yourself working at the terminal and are working with [Visual Studio Code dev containers](https://code.visualstudio.com/docs/remote/containers) then the `devcontainer` CLI might be of interest for you! 2 | 3 | Examples: 4 | 5 | ```bash 6 | 7 | # The following command opens the current folder in 8 | # VS Code as a dev container # i.e. it skips the 9 | # normal step of opening in VS Code and then 10 | # clicking # on the "Re-open in container" prompt. 11 | $ devcontainer open-in-code 12 | 13 | # If you don't have a dev container definition for 14 | # your folder then you can use 15 | #`devcontainer template add ` to add a 16 | # dev container definition. 17 | $ devcontainer template add python-3 18 | 19 | # You can use `devcontainer exec` to create a 20 | # shell (or run a process) # inside a dev container. 21 | $ devcontainer exec 22 | ``` 23 | 24 | See the following topics for more information: 25 | 26 | * [Installation](installation) 27 | * Commands 28 | * [open-in-code](open-in-code) - open dev containers in VS Code from the terminal 29 | * [template](template) - add dev container definitions to a folder 30 | * [exec](exec) - launch a terminal or other command in a dev container 31 | * [snippet](snippet) - add snippets to an existing dev container definition **experimental** 32 | -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | 4 | ## Download latest release 5 | 6 | Head to the [latest release page](https://github.com/stuartleeks/devcontainer-cli/releases/latest) and download the archive for your platform. 7 | 8 | Extract `devcontainer` from the archive and place in a folder in your `PATH`. 9 | 10 | ## Homebrew 11 | 12 | You can also install using `homebrew` with `brew install stuartleeks/tap/devcontainer` 13 | 14 | ## Just give me a script 15 | 16 | Or if you just don't care and are happy to run random scripts from the internet: 17 | 18 | ```bash 19 | export OS=linux # also darwin 20 | export ARCH=amd64 # also 386 21 | wget https://raw.githubusercontent.com/stuartleeks/devcontainer-cli/main/scripts/install.sh 22 | chmod +x install.sh 23 | sudo -E ./install.sh 24 | ``` 25 | 26 | ## Enabling bash completion 27 | 28 | The `devcontainer completion ` command generates a completion script for the specified shell. 29 | 30 | To enable bash completion, add the following to you `~/.bashrc` file: 31 | 32 | ```bash 33 | source <(devcontainer completion bash) 34 | ``` 35 | 36 | Or to alias `devcontainer` (to `dc` in this example): 37 | 38 | ```bash 39 | alias dc=devcontainer 40 | complete -F __start_devcontainer dc 41 | ``` 42 | 43 | The `devcontainer completion ` command accepts `bash`, `zsh`, and `powershell` for the `` parameter. 44 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Launch", 9 | "type": "go", 10 | "request": "launch", 11 | "mode": "auto", 12 | "program": "${workspaceFolder}/cmd/devcontainer", 13 | "env": {}, 14 | "args": ["list"], 15 | "dlvLoadConfig": { 16 | "followPointers": true, 17 | "maxVariableRecurse": 1, 18 | "maxStringLen": 512, 19 | "maxArrayValues": 64, 20 | "maxStructFields": -1 21 | } 22 | }, 23 | { 24 | "name": "Connect to server", 25 | "type": "go", 26 | "request": "attach", 27 | "mode": "remote", 28 | "remotePath": "${workspaceFolder}", 29 | "port": 2345, 30 | "host": "127.0.0.1", 31 | "dlvLoadConfig": { 32 | "followPointers": true, 33 | "maxVariableRecurse": 1, 34 | "maxStringLen": 512, 35 | "maxArrayValues": 64, 36 | "maxStructFields": -1 37 | } 38 | }, 39 | ] 40 | } -------------------------------------------------------------------------------- /cmd/devcontainerx/config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/spf13/cobra" 8 | "github.com/stuartleeks/devcontainer-cli/internal/pkg/config" 9 | ) 10 | 11 | func createConfigCommand() *cobra.Command { 12 | cmd := &cobra.Command{ 13 | Use: "config", 14 | } 15 | cmd.AddCommand(createConfigShowCommand()) 16 | cmd.AddCommand(createConfigWriteCommand()) 17 | return cmd 18 | } 19 | func createConfigShowCommand() *cobra.Command { 20 | cmd := &cobra.Command{ 21 | Use: "show", 22 | Short: "show the current config", 23 | Long: "load the current config and print it out", 24 | RunE: func(cmd *cobra.Command, args []string) error { 25 | c := config.GetAll() 26 | jsonConfig, err := json.MarshalIndent(c, "", " ") 27 | if err != nil { 28 | return fmt.Errorf("Error converting to JSON: %s\n", err) 29 | } 30 | fmt.Println(string(jsonConfig)) 31 | return nil 32 | }, 33 | } 34 | return cmd 35 | } 36 | func createConfigWriteCommand() *cobra.Command { 37 | cmd := &cobra.Command{ 38 | Use: "write", 39 | Short: "write config", 40 | Long: "Write out the config file to ~/.devcontainer-cli/devcontainer-cli.json", 41 | RunE: func(cmd *cobra.Command, args []string) error { 42 | if err := config.SaveConfig(); err != nil { 43 | return fmt.Errorf("Error saving config: %s\n", err) 44 | } 45 | fmt.Println("Config saved") 46 | return nil 47 | }, 48 | } 49 | return cmd 50 | 51 | } 52 | -------------------------------------------------------------------------------- /.github/workflows/build-and-release.yaml: -------------------------------------------------------------------------------- 1 | name: build-and-release 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | paths-ignore: 7 | - README.md 8 | - 'docs/**' 9 | pull_request: 10 | branches: [ main ] 11 | paths-ignore: 12 | - README.md 13 | - 'docs/**' 14 | 15 | jobs: 16 | build: 17 | runs-on: ubuntu-22.04 18 | 19 | steps: 20 | - uses: actions/checkout@v3 21 | 22 | - name: Set up Docker BuildKit 23 | uses: docker/setup-buildx-action@v2 24 | 25 | - name: Login to GitHub Container Registry 26 | uses: docker/login-action@v2 27 | with: 28 | registry: ghcr.io 29 | username: ${{ github.repository_owner }} 30 | password: ${{ secrets.GITHUB_TOKEN }} 31 | 32 | - name: Create dummy mount folders 33 | run: | 34 | mkdir -p ~/.config/gh 35 | mkdir -p ~/.azure 36 | 37 | - name: Build and run CLI 38 | uses: devcontainers/ci@v0.3 39 | env: 40 | GITHUB_TOKEN: ${{ secrets.GH_CROSS_REPO_TOKEN }} 41 | BUILD_NUMBER: ${{ github.run_id }} 42 | IS_PR: ${{ github.head_ref }} 43 | BRANCH: ${{ github.ref }} 44 | with: 45 | imageName: ghcr.io/stuartleeks/devcontainer-cli-devcontainer 46 | runCmd: | 47 | sudo chown -R $(whoami) /go/pkg 48 | ./scripts/ci_release.sh 49 | env: | 50 | GITHUB_TOKEN 51 | BUILD_NUMBER 52 | IS_CI=1 53 | IS_PR 54 | BRANCH 55 | -------------------------------------------------------------------------------- /internal/pkg/devcontainers/dockerutils_test.go: -------------------------------------------------------------------------------- 1 | package devcontainers 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestGetClosestPathMatchForPath_ReturnsLongestMatch(t *testing.T) { 10 | 11 | inputs := []DevcontainerInfo{ 12 | {LocalFolderPath: "/path/to/project"}, 13 | {LocalFolderPath: "/path/to/somewhere/else"}, 14 | {LocalFolderPath: "/path"}, 15 | } 16 | 17 | actual, err := GetClosestPathMatchForPath(inputs, "/path/to/project") 18 | if assert.NoError(t, err) { 19 | assert.Equal(t, "/path/to/project", actual.LocalFolderPath) 20 | } 21 | } 22 | 23 | func TestGetClosestPathMatchForPath_ReturnsLongestMatchWithTrailingSlash(t *testing.T) { 24 | 25 | inputs := []DevcontainerInfo{ 26 | {LocalFolderPath: "/path/to/project"}, 27 | {LocalFolderPath: "/path/to/somewhere/else"}, 28 | {LocalFolderPath: "/path"}, 29 | } 30 | 31 | actual, err := GetClosestPathMatchForPath(inputs, "/path/to/project/") 32 | if assert.NoError(t, err) { 33 | assert.Equal(t, "/path/to/project", actual.LocalFolderPath) 34 | } 35 | } 36 | 37 | func TestGetClosestPathMatchForPath_ReturnsLongestMatchForChildFolder(t *testing.T) { 38 | 39 | inputs := []DevcontainerInfo{ 40 | {LocalFolderPath: "/path/to/project"}, 41 | {LocalFolderPath: "/path/to/somewhere/else"}, 42 | {LocalFolderPath: "/path"}, 43 | } 44 | 45 | actual, err := GetClosestPathMatchForPath(inputs, "/path/to/project/with/child") 46 | if assert.NoError(t, err) { 47 | assert.Equal(t, "/path/to/project", actual.LocalFolderPath) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /internal/pkg/devcontainers/remoteuri_test.go: -------------------------------------------------------------------------------- 1 | package devcontainers 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestHexToString(t *testing.T) { 10 | input := "\\\\wsl$\\Ubuntusl\\home\\stuart\\source\\kips-operator" 11 | expected := "5c5c77736c245c5562756e7475736c5c686f6d655c7374756172745c736f757263655c6b6970732d6f70657261746f72" 12 | actual := convertToHexString(input) 13 | assert.Equal(t, expected, actual) 14 | } 15 | 16 | func TestGetWorkspaceFolder_withWorkspaceFolder(t *testing.T) { 17 | 18 | content := `{ 19 | "someProp": 2, 20 | // add a content here for good measure 21 | "workspaceFolder": "/workspace/wibble", 22 | }` 23 | result, err := getWorkspaceMountPathFromDevcontainerDefinition([]byte(content)) 24 | 25 | assert.NoError(t, err) 26 | assert.Equal(t, "/workspace/wibble", result) 27 | } 28 | func TestGetWorkspaceFolder_withCommentedWorkspaceFolder(t *testing.T) { 29 | 30 | content := `{ 31 | "someProp": 2, 32 | // add a content here for good measure 33 | //"workspaceFolder": "/workspace/wibble", 34 | }` 35 | result, err := getWorkspaceMountPathFromDevcontainerDefinition([]byte(content)) 36 | 37 | assert.NoError(t, err) 38 | assert.Equal(t, "", result) 39 | } 40 | func TestGetWorkspaceFolder_withNoWorkspaceFolder(t *testing.T) { 41 | 42 | content := `{ 43 | "someProp": 2, 44 | // add a content here for good measure 45 | }` 46 | result, err := getWorkspaceMountPathFromDevcontainerDefinition([]byte(content)) 47 | 48 | assert.NoError(t, err) 49 | assert.Equal(t, "", result) 50 | } 51 | -------------------------------------------------------------------------------- /cmd/devcontainerx/completion.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | func createCompleteCommand(rootCmd *cobra.Command) *cobra.Command { 12 | cmd := &cobra.Command{ 13 | Use: "completion SHELL", 14 | Short: "Generates bash completion scripts", 15 | Long: `To load completion run 16 | 17 | . <(devcontainerx completion SHELL) 18 | 19 | Valid values for SHELL are : bash, fish, powershell, zsh 20 | 21 | For example, to configure your bash shell to load completions for each session add to your bashrc 22 | 23 | # ~/.bashrc or ~/.profile 24 | source <(devcontainerx completion) 25 | 26 | # if you want to alias the CLI: 27 | alias dcx=devcontainerx 28 | source <(devcontainerx completion bash | sed s/devcontainerx/dcx/g) 29 | 30 | `, 31 | PersistentPreRun: func(cmd *cobra.Command, args []string) { 32 | // suppress the PersistentPreRun in main 33 | }, 34 | RunE: func(cmd *cobra.Command, args []string) error { 35 | if len(args) != 1 { 36 | _ = cmd.Usage() 37 | os.Exit(1) 38 | } 39 | shell := args[0] 40 | var err error 41 | switch strings.ToLower(shell) { 42 | case "bash": 43 | err = rootCmd.GenBashCompletion(os.Stdout) 44 | case "fish": 45 | err = rootCmd.GenFishCompletion(os.Stdout, true) 46 | case "powershell": 47 | err = rootCmd.GenPowerShellCompletion(os.Stdout) 48 | case "zsh": 49 | err = rootCmd.GenZshCompletion(os.Stdout) 50 | default: 51 | fmt.Printf("Unsupported SHELL value: '%s'\n", shell) 52 | return cmd.Usage() 53 | } 54 | 55 | return err 56 | }, 57 | } 58 | return cmd 59 | } 60 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # devcontainer-cli 2 | 3 | devcontainer-cli is the start of a CLI to improve the experience of working with [Visual Studio Code devcontainers](https://code.visualstudio.com/docs/remote/containers) 4 | 5 | **Status: this is a pet project that I've been experimenting with. It is not supported and you should expect bugs :-)** 6 | 7 | **NOTE: To avoid conflicts with the [official CLI](https://github.com/devcontainers/cli) the binary for this project has been renamed to `devcontainerx`** 8 | 9 | ## Installation 10 | 11 | Head to the [latest release page](https://github.com/stuartleeks/devcontainer-cli/releases/latest) and download the archive for your platform. 12 | 13 | Extract `devcontainerx` from the archive and place in a folder in your `PATH`. 14 | 15 | You can also install using `homebrew` with `brew install stuartleeks/tap/devcontainer` 16 | 17 | Or if you just don't care and are happy to run random scripts from the internet: 18 | 19 | ```bash 20 | export OS=linux # also darwin 21 | export ARCH=amd64 # also 386 22 | wget https://raw.githubusercontent.com/stuartleeks/devcontainer-cli/main/scripts/install.sh 23 | chmod +x install.sh 24 | sudo -E ./install.sh 25 | ``` 26 | 27 | ## Enabling bash completion 28 | 29 | To enable bash completion, add the following to you `~/.bashrc` file: 30 | 31 | ```bash 32 | source <(devcontainerx completion bash) 33 | ``` 34 | 35 | Or to alias `devcontainerx` (to `dcx` in this example): 36 | 37 | ```bash 38 | alias dcx=devcontainerx 39 | complete -F __start_devcontainerx dcx 40 | ``` 41 | 42 | ## Docs 43 | 44 | See [the documentation](https://stuartleeks.github.io/devcontainer-cli) on how to work with `devcontainerx`. 45 | -------------------------------------------------------------------------------- /internal/pkg/update/update.go: -------------------------------------------------------------------------------- 1 | package update 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "time" 7 | 8 | "github.com/blang/semver" 9 | "github.com/rhysd/go-github-selfupdate/selfupdate" 10 | "github.com/stuartleeks/devcontainer-cli/internal/pkg/status" 11 | ) 12 | 13 | func CheckForUpdate(currentVersion string) (*selfupdate.Release, error) { 14 | 15 | latest, found, err := selfupdate.DetectLatest("stuartleeks/devcontainer-cli") 16 | if err != nil { 17 | return nil, fmt.Errorf("Error occurred while detecting version: %v", err) 18 | } 19 | 20 | v, err := semver.Parse(currentVersion) 21 | if err != nil { 22 | return nil, fmt.Errorf("Error occurred while parsing version: %v", err) 23 | } 24 | 25 | if !found || latest.Version.LTE(v) { 26 | return nil, nil 27 | } 28 | return latest, nil 29 | } 30 | func PeriodicCheckForUpdate(currentVersion string) { 31 | const checkInterval time.Duration = 24 * time.Hour 32 | 33 | if os.Getenv("DEVCONTAINERX_SKIP_UPDATE") != "" { 34 | // Skip update check 35 | return 36 | } 37 | 38 | lastCheck := status.GetLastUpdateCheck() 39 | 40 | if time.Now().Before(lastCheck.Add(checkInterval)) { 41 | return 42 | } 43 | fmt.Println("Checking for updates...") 44 | latest, err := CheckForUpdate(currentVersion) 45 | if err != nil { 46 | fmt.Printf("Error checking for updates: %s", err) 47 | } 48 | 49 | status.SetLastUpdateCheck(time.Now()) 50 | if err = status.SaveStatus(); err != nil { 51 | fmt.Printf("Error saving last update check time: :%s\n", err) 52 | } 53 | 54 | if latest == nil { 55 | return 56 | } 57 | 58 | fmt.Printf("\n\n UPDATE AVAILABLE: %s \n \n Release notes: %s\n", latest.Version, latest.ReleaseNotes) 59 | fmt.Printf("Run `devcontainer update` to apply the update\n\n") 60 | } 61 | -------------------------------------------------------------------------------- /cmd/devcontainerx/openincode.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os/exec" 6 | 7 | "github.com/spf13/cobra" 8 | "github.com/stuartleeks/devcontainer-cli/internal/pkg/devcontainers" 9 | "github.com/stuartleeks/devcontainer-cli/internal/pkg/wsl" 10 | ) 11 | 12 | func createOpenInCodeCommand() *cobra.Command { 13 | cmd := &cobra.Command{ 14 | Use: "open-in-code ", 15 | Short: "open the specified path devcontainer project in VS Code", 16 | Long: "Open the specified path (containing a .devcontainer folder in VS Code", 17 | RunE: func(cmd *cobra.Command, args []string) error { 18 | return launchDevContainer(cmd, "code", args) 19 | }, 20 | } 21 | return cmd 22 | } 23 | func createOpenInCodeInsidersCommand() *cobra.Command { 24 | cmd := &cobra.Command{ 25 | Use: "open-in-code-insiders ", 26 | Short: "open the specified path devcontainer project in VS Code Insiders", 27 | Long: "Open the specified path (containing a .devcontainer folder in VS Code Insiders", 28 | RunE: func(cmd *cobra.Command, args []string) error { 29 | return launchDevContainer(cmd, "code-insiders", args) 30 | }, 31 | } 32 | return cmd 33 | } 34 | 35 | func launchDevContainer(cmd *cobra.Command, appBase string, args []string) error { 36 | if len(args) > 1 { 37 | return cmd.Usage() 38 | } 39 | path := "." // default to current directory 40 | if len(args) == 1 { 41 | path = args[0] 42 | } 43 | 44 | launchURI, err := devcontainers.GetDevContainerURI(path) 45 | if err != nil { 46 | return err 47 | } 48 | var execCmd *exec.Cmd 49 | if wsl.IsWsl() { 50 | execCmd = exec.Command("cmd.exe", "/C", appBase+".cmd", "--folder-uri="+launchURI) 51 | } else { 52 | execCmd = exec.Command(appBase, "--folder-uri="+launchURI) 53 | } 54 | output, err := execCmd.Output() 55 | fmt.Println(string(output)) 56 | if err != nil { 57 | return err 58 | } 59 | return nil 60 | } 61 | -------------------------------------------------------------------------------- /internal/pkg/status/status.go: -------------------------------------------------------------------------------- 1 | package status 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "time" 8 | 9 | viperlib "github.com/spf13/viper" 10 | ) 11 | 12 | var initialised bool = false 13 | var viper *viperlib.Viper = viperlib.New() 14 | 15 | // EnsureInitialised reads the config. Will quit if config is invalid 16 | func EnsureInitialised() { 17 | if !initialised { 18 | viper.SetConfigName("devcontainer-cli-status") 19 | viper.SetConfigType("json") 20 | 21 | viper.AddConfigPath(getConfigPath()) 22 | 23 | // TODO - allow env var for config 24 | if err := viper.ReadInConfig(); err != nil { 25 | if _, ok := err.(viperlib.ConfigFileNotFoundError); ok { 26 | // Config file not found; ignore error if desired 27 | } else { 28 | fmt.Printf("Error loading status file: %s\n", err) 29 | os.Exit(1) 30 | } 31 | } 32 | initialised = true 33 | } 34 | } 35 | func getConfigPath() string { 36 | path := os.Getenv("DEVCONTAINERX_STATUS_PATH") 37 | if path != "" { 38 | return path 39 | } 40 | if os.Getenv("HOME") != "" { 41 | path = filepath.Join("$HOME", ".devcontainer-cli/") 42 | } else { 43 | // if HOME not set, assume Windows and use USERPROFILE env var 44 | path = filepath.Join("$USERPROFILE", ".devcontainer-cli/") 45 | } 46 | return os.ExpandEnv(path) 47 | } 48 | 49 | func GetLastUpdateCheck() time.Time { 50 | EnsureInitialised() 51 | return viper.GetTime("lastUpdateCheck") 52 | } 53 | func SetLastUpdateCheck(t time.Time) { 54 | EnsureInitialised() 55 | viper.Set("lastUpdateCheck", t) 56 | } 57 | func GetAll() map[string]interface{} { 58 | EnsureInitialised() 59 | return viper.AllSettings() 60 | } 61 | 62 | func SaveStatus() error { 63 | EnsureInitialised() 64 | configPath := getConfigPath() 65 | configPath = os.ExpandEnv(configPath) 66 | if err := os.MkdirAll(configPath, 0755); err != nil { 67 | return err 68 | } 69 | configFilePath := filepath.Join(configPath, "devcontainer-cli-status.json") 70 | return viper.WriteConfigAs(configFilePath) 71 | } 72 | -------------------------------------------------------------------------------- /cmd/devcontainerx/update.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/rhysd/go-github-selfupdate/selfupdate" 9 | "github.com/spf13/cobra" 10 | "github.com/stuartleeks/devcontainer-cli/internal/pkg/update" 11 | ) 12 | 13 | func createUpdateCommand() *cobra.Command { 14 | 15 | var checkOnly bool 16 | var yes bool 17 | 18 | cmd := &cobra.Command{ 19 | Use: "update", 20 | Short: "update cli", 21 | Long: "Apply the latest update", 22 | PersistentPreRun: func(cmd *cobra.Command, args []string) { 23 | // do nothing - suppress root PersistentPreRun which does periodic update check 24 | }, 25 | RunE: func(cmd *cobra.Command, args []string) error { 26 | latest, err := update.CheckForUpdate(version) 27 | if err != nil { 28 | return fmt.Errorf("Error occurred while checking for updates: %v", err) 29 | } 30 | 31 | if latest == nil { 32 | fmt.Println("No updates available") 33 | return nil 34 | } 35 | 36 | fmt.Printf("\n\n UPDATE AVAILABLE: %s \n \n Release notes: %s\n", latest.Version, latest.ReleaseNotes) 37 | 38 | if checkOnly { 39 | return nil 40 | } 41 | 42 | fmt.Print("Do you want to update? (y/n): ") 43 | if !yes { 44 | input, err := bufio.NewReader(os.Stdin).ReadString('\n') 45 | if err != nil || (input != "y\n" && input != "y\r\n") { 46 | // error or something other than `y` 47 | return err 48 | } 49 | } 50 | fmt.Println("Applying...") 51 | 52 | exe, err := os.Executable() 53 | if err != nil { 54 | return fmt.Errorf("Could not locate executable path: %v", err) 55 | } 56 | if err := selfupdate.UpdateTo(latest.AssetURL, exe); err != nil { 57 | return fmt.Errorf("Error occurred while updating binary: %v", err) 58 | } 59 | fmt.Printf("Successfully updated to version %s\n", latest.Version) 60 | return nil 61 | }, 62 | } 63 | cmd.Flags().BoolVar(&checkOnly, "check-only", false, "Check for an update without applying") 64 | cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Automatically apply any updates (i.e. answer yes) ") 65 | 66 | return cmd 67 | } 68 | -------------------------------------------------------------------------------- /internal/pkg/ioutil/files.go: -------------------------------------------------------------------------------- 1 | package ioutil 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "io/ioutil" 7 | "os" 8 | "path/filepath" 9 | ) 10 | 11 | func CopyFolder(source string, target string) error { 12 | copy := func(sourceFolder string, targetFolder string, item os.FileInfo) error { 13 | return CopyFile(filepath.Join(sourceFolder, item.Name()), filepath.Join(targetFolder, item.Name()), item.Mode()) 14 | } 15 | return processFolder(source, target, copy) 16 | } 17 | func LinkFolder(source string, target string) error { 18 | symlink := func(sourceFolder string, targetFolder string, item os.FileInfo) error { 19 | return os.Symlink(filepath.Join(sourceFolder, item.Name()), filepath.Join(targetFolder, item.Name())) 20 | } 21 | return processFolder(source, target, symlink) 22 | } 23 | 24 | func processFolder(source string, target string, fileHandler func(sourceFolder string, targetFolder string, item os.FileInfo) error) error { 25 | sourceItem, err := os.Stat(source) 26 | if err != nil { 27 | return fmt.Errorf("Error reading source folder: %s\n", err) 28 | } 29 | if err = os.Mkdir(target, sourceItem.Mode()); err != nil { 30 | return fmt.Errorf("Error creating directory '%s': %s", target, err) 31 | } 32 | 33 | sourceSubItems, err := ioutil.ReadDir(source) 34 | if err != nil { 35 | return fmt.Errorf("Error reading source folder contents: %s\n", err) 36 | } 37 | 38 | for _, sourceSubItem := range sourceSubItems { 39 | if sourceSubItem.IsDir() { 40 | if err = processFolder(filepath.Join(source, sourceSubItem.Name()), filepath.Join(target, sourceSubItem.Name()), fileHandler); err != nil { 41 | return err 42 | } 43 | } else { 44 | if err = fileHandler(source, target, sourceSubItem); err != nil { 45 | return err 46 | } 47 | } 48 | } 49 | return nil 50 | } 51 | 52 | func CopyFile(source string, target string, perm os.FileMode) error { 53 | sourceFile, err := os.Open(source) 54 | if err != nil { 55 | return err 56 | } 57 | defer sourceFile.Close() 58 | 59 | targetFile, err := os.OpenFile(target, os.O_RDWR|os.O_CREATE|os.O_EXCL|os.O_TRUNC, perm) 60 | if err != nil { 61 | return err 62 | } 63 | defer targetFile.Close() 64 | _, err = io.Copy(targetFile, sourceFile) 65 | return err 66 | } 67 | -------------------------------------------------------------------------------- /internal/pkg/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | 8 | viperlib "github.com/spf13/viper" 9 | ) 10 | 11 | var initialised bool = false 12 | var viper *viperlib.Viper = viperlib.New() 13 | 14 | // EnsureInitialised reads the config. Will quit if config is invalid 15 | func EnsureInitialised() { 16 | if !initialised { 17 | viper.SetConfigName("devcontainer-cli") 18 | viper.SetConfigType("json") 19 | 20 | viper.AddConfigPath(getConfigPath()) 21 | 22 | viper.SetDefault("templatePaths", []string{}) 23 | viper.SetDefault("settingPaths", []string{}) 24 | viper.SetDefault("experimental", false) 25 | 26 | // TODO - allow env var for config 27 | if err := viper.ReadInConfig(); err != nil { 28 | if _, ok := err.(viperlib.ConfigFileNotFoundError); ok { 29 | // Config file not found; ignore error if desired 30 | } else { 31 | fmt.Printf("Error loading config file: %s\n", err) 32 | os.Exit(1) 33 | } 34 | } 35 | initialised = true 36 | } 37 | } 38 | func getConfigPath() string { 39 | path := os.Getenv("DEVCONTAINERX_CONFIG_PATH") 40 | if path != "" { 41 | return path 42 | } 43 | if os.Getenv("HOME") != "" { 44 | path = filepath.Join("$HOME", ".devcontainer-cli/") 45 | } else { 46 | // if HOME not set, assume Windows and use USERPROFILE env var 47 | path = filepath.Join("$USERPROFILE", ".devcontainer-cli/") 48 | } 49 | return os.ExpandEnv(path) 50 | } 51 | 52 | func GetTemplateFolders() []string { 53 | EnsureInitialised() 54 | return viper.GetStringSlice("templatePaths") 55 | } 56 | func GetSnippetFolders() []string { 57 | EnsureInitialised() 58 | return viper.GetStringSlice("snippetPaths") 59 | } 60 | func GetExperimentalFeaturesEnabled() bool { 61 | EnsureInitialised() 62 | return viper.GetBool("experimental") 63 | } 64 | func GetAll() map[string]interface{} { 65 | EnsureInitialised() 66 | return viper.AllSettings() 67 | } 68 | 69 | func SaveConfig() error { 70 | EnsureInitialised() 71 | configPath := getConfigPath() 72 | configPath = os.ExpandEnv(configPath) 73 | if err := os.MkdirAll(configPath, 0755); err != nil { 74 | return err 75 | } 76 | configFilePath := filepath.Join(configPath, "devcontainer-cli.json") 77 | return viper.WriteConfigAs(configFilePath) 78 | } 79 | -------------------------------------------------------------------------------- /docs/_layouts/default.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | {% seo %} 11 | 12 | 13 | 14 | 15 | 16 |
17 |
18 | {% if site.github.is_project_page %} 19 | View on GitHub 20 | {% endif %} 21 | 22 |

{{ site.title | default: site.github.repository_name }}

23 |

{{ site.description | default: site.github.project_tagline }}

24 | 25 | {% if site.show_downloads %} 26 |
27 | Download this project as a .zip file 28 | Download this project as a tar.gz file 29 |
30 | {% endif %} 31 |
32 |
33 | 34 | 35 |
36 |
37 | {{ content }} 38 |
39 |
40 | 41 | 42 | 50 | 51 | {% if site.google_analytics %} 52 | 60 | {% endif %} 61 | 62 | -------------------------------------------------------------------------------- /docs/exec.md: -------------------------------------------------------------------------------- 1 | # devcontainer exec 2 | 3 | The `devcontainer exec` command can be used to run commands inside a running dev container. When no options are passed, it will run `bash` in the dev container for the dev container for the current working directory. 4 | 5 | Some examples of using `devcontainer exec` are shown below: 6 | 7 | ```bash 8 | # Run an interactive bash shell in the 9 | # vscode-remote-test-dockerfile devcontainer 10 | devcontainer exec --name vscode-remote-test-dockerfile bash 11 | 12 | # Run a command with args in the 13 | # vscode-remote-test-dockercompose_devcontainer/mongo 14 | # devcontainer 15 | devcontainer exec --name vscode-remote-test-dockercompose_devcontainer/mongo ls -a /workspaces/vscode-remote-test-dockerfile 16 | 17 | # Run `bash` in the dev container for 18 | # the project at `~/ source/my-proj` 19 | devcontainer exec --path ~/source/my-proj bash 20 | 21 | # If none of --name/--path/--prompt 22 | # are specified then `--path .` is assumed 23 | # (i.e. use the dev container for the current directory) 24 | devcontainer exec bash 25 | 26 | # If command/args not set, `bash` is assumed 27 | devcontainer exec --name vscode-remote-test-dockerfile 28 | 29 | # Combining these to launch bash in the 30 | # dev container for the project in the current directory: 31 | devcontainer exec 32 | ``` 33 | 34 | ## Features of devcontainer exec 35 | 36 | Under the covers, `devcontainer exec` launches `docker exec`, but it has a few features on top of this to try to increase productivity. 37 | 38 | First, it sets the working directory to be the mount path for the dev container rather than just dropping you in at the root of the container flie system. This can be overridden using `--work-dir`. 39 | 40 | Second, it checks whether you have [configured a user in the dev container](https://code.visualstudio.com/docs/remote/containers-advanced#_adding-a-nonroot-user-to-your-dev-container) and uses this user for the `docker exec`. 41 | 42 | Lastly, it checks whether you have set up an SSH agent on your host. If you have and VS Code detects it then VS Code will [forward key requests from the container](https://code.visualstudio.com/docs/remote/containers#_using-ssh-keys). In this scenario, `devcontainer exec` configures the exec session to also forward key requests. This enables operations against git remotes secured with SSH keys to succeed. 43 | 44 | 45 | ## Prompting for the dev container 46 | 47 | 48 | You can use `--prompt` with `devcontainer exec` instead of `--name` or `--path` and the CLI will prompt you to pick a devcontainer to run the `exec` command against, e.g.: 49 | 50 | ```bash 51 | $ ./devcontainer exec ? bash 52 | Specify the devcontainer to use: 53 | 0: devcontainer-cli (festive_saha) 54 | 1: vscode-remote-test-dockerfile (fervent_gopher) 55 | 0 56 | ``` 57 | 58 | This works well as a terminal profile. For example, you can use this with Windows Terminal profiles: 59 | 60 | ```json 61 | { 62 | "guid": "{4b304185-99d2-493c-940c-ae74e0f14bba}", 63 | "hidden": false, 64 | "name": "devcontainer exec", 65 | "commandline": "wsl bash -c \"path/to/devcontainer exec --prompt bash\"", 66 | }, 67 | ``` 68 | -------------------------------------------------------------------------------- /cmd/devcontainerx/snippet.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "sort" 7 | "text/tabwriter" 8 | 9 | "github.com/spf13/cobra" 10 | "github.com/stuartleeks/devcontainer-cli/internal/pkg/devcontainers" 11 | ) 12 | 13 | func createSnippetCommand() *cobra.Command { 14 | cmd := &cobra.Command{ 15 | Use: "snippet", 16 | Short: "work with snippets (experimental)", 17 | Long: "Use subcommands to work with devcontainer snippets (experimental)", 18 | } 19 | cmd.AddCommand(createSnippetListCommand()) 20 | cmd.AddCommand(createSnippetAddCommand()) 21 | return cmd 22 | } 23 | 24 | func createSnippetListCommand() *cobra.Command { 25 | var listVerbose bool 26 | cmd := &cobra.Command{ 27 | Use: "list", 28 | Short: "list snippets", 29 | Long: "List devcontainer snippets", 30 | RunE: func(cmd *cobra.Command, args []string) error { 31 | 32 | snippets, err := devcontainers.GetSnippets() 33 | if err != nil { 34 | return err 35 | } 36 | 37 | if listVerbose { 38 | w := new(tabwriter.Writer) 39 | // minwidth, tabwidth, padding, padchar, flags 40 | w.Init(os.Stdout, 8, 8, 0, '\t', 0) 41 | defer w.Flush() 42 | 43 | fmt.Fprintf(w, "%s\t%s\n", "SNIPPET NAME", "PATH") 44 | fmt.Fprintf(w, "%s\t%s\n", "-------------", "----") 45 | 46 | for _, snippet := range snippets { 47 | fmt.Fprintf(w, "%s\t%s\n", snippet.Name, snippet.Path) 48 | } 49 | return nil 50 | } 51 | 52 | for _, snippet := range snippets { 53 | fmt.Println(snippet.Name) 54 | } 55 | return nil 56 | }, 57 | } 58 | cmd.Flags().BoolVarP(&listVerbose, "verbose", "v", false, "Verbose output") 59 | return cmd 60 | } 61 | 62 | func createSnippetAddCommand() *cobra.Command { 63 | var devcontainerName string 64 | cmd := &cobra.Command{ 65 | Use: "add SNIPPET_NAME", 66 | Short: "add snippet to devcontainer", 67 | Long: "Add a snippet to the devcontainer definition for the current folder", 68 | RunE: func(cmd *cobra.Command, args []string) error { 69 | 70 | if len(args) != 1 { 71 | return cmd.Usage() 72 | } 73 | name := args[0] 74 | 75 | currentDirectory, err := os.Getwd() 76 | if err != nil { 77 | return fmt.Errorf("Error reading current directory: %s\n", err) 78 | } 79 | 80 | err = devcontainers.AddSnippetToDevcontainer(currentDirectory, name) 81 | if err != nil { 82 | return fmt.Errorf("Error setting devcontainer name: %s", err) 83 | } 84 | 85 | return nil 86 | }, 87 | ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { 88 | // only completing the first arg (template name) 89 | if len(args) != 0 { 90 | return nil, cobra.ShellCompDirectiveNoFileComp 91 | } 92 | templates, err := devcontainers.GetSnippets() 93 | if err != nil { 94 | os.Exit(1) 95 | } 96 | names := []string{} 97 | for _, template := range templates { 98 | names = append(names, template.Name) 99 | } 100 | sort.Strings(names) 101 | return names, cobra.ShellCompDirectiveNoFileComp 102 | }, 103 | } 104 | cmd.Flags().StringVar(&devcontainerName, "devcontainer-name", "", "Value to set the devcontainer.json name property to (default is folder name)") 105 | return cmd 106 | } 107 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | -y// For format details, see https://aka.ms/vscode-remote/devcontainer.json or this file's README at: 2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.117.1/containers/go 3 | { 4 | "name": "devcontainer-cli", 5 | "dockerFile": "Dockerfile", 6 | "build": { 7 | "cacheFrom": "ghcr.io/stuartleeks/devcontainer-cli-devcontainer", 8 | "args": { 9 | // To ensure that the group ID for the docker group in the container 10 | // matches the group ID on the host, add this to your .bash_profile on the host 11 | // export DOCKER_GROUP_ID=$(getent group docker | awk -F ":" '{ print $3 }') 12 | "DOCKER_GROUP_ID": "${localEnv:DOCKER_GROUP_ID}" 13 | } 14 | }, 15 | "runArgs": [ 16 | // Uncomment the next line to use a non-root user. On Linux, this will prevent 17 | // new files getting created as root, but you may need to update the USER_UID 18 | // and USER_GID in .devcontainer/Dockerfile to match your user if not 1000. 19 | // "-u", "vscode", 20 | "--cap-add=SYS_PTRACE", 21 | "--security-opt", 22 | "seccomp=unconfined", 23 | 24 | // Mount go mod cache 25 | "-v", "devcontainer-cli-gomodcache:/go/pkg", 26 | // Use host network 27 | "--network=host", 28 | ], 29 | 30 | // Set *default* container specific settings.json values on container create. 31 | "settings": { 32 | "terminal.integrated.shell.linux": "/bin/bash", 33 | "go.gopath": "/go", 34 | "go.useLanguageServer": true, 35 | "[go]": { 36 | "editor.snippetSuggestions": "none", 37 | "editor.formatOnSave": true, 38 | "editor.codeActionsOnSave": { 39 | "source.organizeImports": true, 40 | } 41 | }, 42 | "gopls": { 43 | "usePlaceholders": true, // add parameter placeholders when completing a function 44 | // Experimental settings 45 | "completeUnimported": true, // autocomplete unimported packages 46 | "deepCompletion": true, // enable deep completion 47 | }, 48 | "files.eol": "\n", // formatting only supports LF line endings 49 | }, 50 | 51 | // Add the IDs of extensions you want installed when the container is created. 52 | "extensions": [ 53 | "golang.go", 54 | "stuartleeks.vscode-go-by-example", 55 | "darkriszty.markdown-table-prettify", 56 | "davidanson.vscode-markdownlint", 57 | "mushan.vscode-paste-image" 58 | , 59 | "ms-azuretools.vscode-docker",], 60 | 61 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 62 | // "forwardPorts": [], 63 | 64 | // Use 'postCreateCommand' to run commands after the container is created. 65 | "postCreateCommand": "make post-create", 66 | 67 | // Uncomment to connect as a non-root user. See https://aka.ms/vscode-remote/containers/non-root. 68 | "remoteUser": "vscode", 69 | "mounts": [ 70 | // Keep command history 71 | "source=devcontainer-cli-bashhistory,target=/home/vscode/commandhistory", 72 | // Mounts the .config/gh host folder into the dev container to pick up host gh CLI login details 73 | // NOTE that mounting directly to ~/.config/gh makes ~/.config only root-writable 74 | // Instead monut to another location and symlink in Dockerfile 75 | "type=bind,source=${env:HOME}${env:USERPROFILE}/.config/gh,target=/config/gh", 76 | // Mounts the .azure host folder into the dev container to pick up host az CLI login details 77 | "type=bind,source=${env:HOME}${env:USERPROFILE}/.azure,target=/home/vscode/.azure", 78 | // Mount host docker socket 79 | "type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock", 80 | ], 81 | } -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | #------------------------------------------------------------------------------------------------------------- 2 | # Copyright (c) Microsoft Corporation. All rights reserved. 3 | # Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information. 4 | #------------------------------------------------------------------------------------------------------------- 5 | 6 | FROM golang:1.17-bullseye 7 | 8 | # Avoid warnings by switching to noninteractive 9 | ENV DEBIAN_FRONTEND=noninteractive 10 | 11 | # Configure apt, install packages and tools 12 | RUN apt-get update \ 13 | && apt-get -y install --no-install-recommends apt-utils dialog nano sudo bsdmainutils \ 14 | # 15 | # Verify git, process tools, lsb-release (common in install instructions for CLIs) installed 16 | && apt-get -y install git iproute2 procps lsb-release build-essential \ 17 | # Install Release Tools 18 | # 19 | # --> RPM used by goreleaser 20 | && apt install -y rpm 21 | 22 | # This Dockerfile adds a non-root user with sudo access. Use the "remoteUser" 23 | # property in devcontainer.json to use it. On Linux, the container user's GID/UIDs 24 | # will be updated to match your local UID/GID (when using the dockerFile property). 25 | # See https://aka.ms/vscode-remote/containers/non-root-user for details. 26 | ARG USERNAME=vscode 27 | ARG USER_UID=1000 28 | ARG USER_GID=$USER_UID 29 | 30 | # Create the user 31 | RUN groupadd --gid $USER_GID $USERNAME \ 32 | && useradd --uid $USER_UID --gid $USER_GID -m $USERNAME \ 33 | && apt-get update \ 34 | && apt-get install -y sudo \ 35 | && echo $USERNAME ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USERNAME \ 36 | && chmod 0440 /etc/sudoers.d/$USERNAME 37 | 38 | # Set default user 39 | USER $USERNAME 40 | RUN mkdir -p ~/.local/bin 41 | ENV PATH /home/${USERNAME}/.local/bin:$PATH 42 | 43 | # Set env for tracking that we're running in a devcontainer 44 | ENV DEVCONTAINER=true 45 | 46 | # Enable go modules 47 | ENV GO111MODULE=on 48 | 49 | # Install Go tools 50 | ARG GO_PLS_VERSION=0.7.2 51 | ARG DLV_VERSION=1.7.2 52 | ARG GOLANGCI_LINT_VERSION=1.42.1 53 | RUN \ 54 | # --> Delve for debugging 55 | go get github.com/go-delve/delve/cmd/dlv@v${DLV_VERSION}\ 56 | # --> Go language server 57 | && go get golang.org/x/tools/gopls@v${GO_PLS_VERSION} \ 58 | # --> Go symbols and outline for go to symbol support and test support 59 | && go get github.com/acroca/go-symbols@v0.1.1 && go get github.com/ramya-rao-a/go-outline@7182a932836a71948db4a81991a494751eccfe77 \ 60 | # --> GolangCI-lint 61 | && curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v${GOLANGCI_LINT_VERSION} \ 62 | # --> Go releaser 63 | && echo 'deb [trusted=yes] https://repo.goreleaser.com/apt/ /' | sudo tee /etc/apt/sources.list.d/goreleaser.list \ 64 | && sudo apt update \ 65 | && sudo apt install goreleaser -y \ 66 | # --> Install junit converter 67 | && go get github.com/jstemmer/go-junit-report@v0.9.1 \ 68 | && sudo rm -rf /go/src/ \ 69 | && sudo rm -rf /go/pkg 70 | 71 | # Switch back to dialog for any ad-hoc use of apt-get 72 | ENV DEBIAN_FRONTEND=dialog 73 | 74 | # gh 75 | COPY scripts/gh.sh /tmp/ 76 | RUN /tmp/gh.sh 77 | 78 | # symlink gh config folder 79 | RUN echo 'if [[ ! -d /home/vscode/.config/gh ]]; then mkdir -p /home/vscode/.config; ln -s /config/gh /home/vscode/.config/gh; fi ' >> ~/.bashrc 80 | 81 | ARG DOCKER_GROUP_ID 82 | 83 | # docker-from-docker 84 | COPY scripts/docker-client.sh /tmp/ 85 | RUN /tmp/docker-client.sh 86 | -------------------------------------------------------------------------------- /internal/pkg/devcontainers/remoteuri.go: -------------------------------------------------------------------------------- 1 | package devcontainers 2 | 3 | import ( 4 | "encoding/hex" 5 | "fmt" 6 | "io/ioutil" 7 | "path/filepath" 8 | "regexp" 9 | 10 | "github.com/stuartleeks/devcontainer-cli/internal/pkg/git" 11 | "github.com/stuartleeks/devcontainer-cli/internal/pkg/wsl" 12 | ) 13 | 14 | // GetDevContainerURI gets the devcontainer URI for a folder to launch using the VS Code --folder-uri switch 15 | func GetDevContainerURI(folderPath string) (string, error) { 16 | 17 | absPath, err := filepath.Abs(folderPath) 18 | if err != nil { 19 | return "", fmt.Errorf("Error handling path %q: %s", folderPath, err) 20 | } 21 | 22 | launchPath := absPath 23 | if wsl.IsWsl() { 24 | var err error 25 | launchPath, err = wsl.ConvertWslPathToWindowsPath(launchPath) 26 | if err != nil { 27 | return "", err 28 | } 29 | } 30 | 31 | launchPathHex := convertToHexString(launchPath) 32 | workspaceMountPath, err := GetWorkspaceMountPath(absPath) 33 | if err != nil { 34 | return "", err 35 | } 36 | uri := fmt.Sprintf("vscode-remote://dev-container+%s%s", launchPathHex, workspaceMountPath) 37 | 38 | return uri, nil 39 | } 40 | 41 | func convertToHexString(input string) string { 42 | return hex.EncodeToString([]byte(input)) 43 | } 44 | 45 | // GetWorkspaceMountPath returns the devcontainer mount path for the devcontainer in the specified folder 46 | func GetWorkspaceMountPath(folderPath string) (string, error) { 47 | // TODO - consider how to support repository-containers (https://github.com/microsoft/vscode-remote-release/issues/3218) 48 | 49 | // If we're called from WSL we want a WSL Path but will also handle a Windows Path 50 | if wsl.IsWsl() { 51 | if wsl.HasWslPathPrefix(folderPath) { 52 | convertedPath, err := wsl.ConvertWindowsPathToWslPath(folderPath) 53 | if err != nil { 54 | return "", err 55 | } 56 | folderPath = convertedPath 57 | } 58 | } 59 | 60 | devcontainerDefinitionPath, err := getDevContainerJsonPath(folderPath) 61 | if err != nil { 62 | return "", fmt.Errorf("Error getting devcontainer definition path: %s", err) 63 | } 64 | buf, err := ioutil.ReadFile(devcontainerDefinitionPath) 65 | if err != nil { 66 | return "", fmt.Errorf("Error loading devcontainer definition: %s", err) 67 | } 68 | 69 | workspaceMountPath, err := getWorkspaceMountPathFromDevcontainerDefinition(buf) 70 | if err != nil { 71 | return "", fmt.Errorf("Error parsing devcontainer definition: %s", err) 72 | } 73 | if workspaceMountPath != "" { 74 | return workspaceMountPath, nil 75 | } 76 | 77 | // No `workspaceFolder` found in devcontainer.json - use default 78 | devcontainerPath, err := getDefaultWorkspaceFolderForPath(folderPath) 79 | if err != nil { 80 | return "", fmt.Errorf("Error getting default workspace path: %s", err) 81 | } 82 | return fmt.Sprintf("/workspaces/%s", devcontainerPath), nil 83 | } 84 | 85 | // TODO: add tests (and implementation) to handle JSON parsing with comments 86 | // Current implementation doesn't handle 87 | // - block comments 88 | // - the value split on a new line from the property name 89 | 90 | func getWorkspaceMountPathFromDevcontainerDefinition(definition []byte) (string, error) { 91 | r, err := regexp.Compile("(?m)^\\s*\"workspaceFolder\"\\s*:\\s*\"(.*)\"") 92 | if err != nil { 93 | return "", fmt.Errorf("Error compiling regex: %s", err) 94 | } 95 | matches := r.FindSubmatch(definition) 96 | if len(matches) == 2 { 97 | return string(matches[1]), nil 98 | } 99 | return "", nil 100 | } 101 | 102 | func getDefaultWorkspaceFolderForPath(path string) (string, error) { 103 | 104 | // get the git repo-root 105 | rootPath, err := git.GetTopLevelPath(path) 106 | if err != nil { 107 | return "", err 108 | } 109 | if rootPath == "" { 110 | // not a git repo, default to path 111 | rootPath = path 112 | } 113 | 114 | // get parent to root 115 | rootParent, _ := filepath.Split(rootPath) 116 | 117 | // return path relative to rootParent 118 | relativePath, err := filepath.Rel(rootParent, path) 119 | if err != nil { 120 | return "", err 121 | } 122 | return relativePath, nil 123 | } 124 | -------------------------------------------------------------------------------- /docs/template.md: -------------------------------------------------------------------------------- 1 | # devcontainer template ... 2 | 3 | {:toc} 4 | 5 | ## Setting up templates 6 | 7 | To use the `devcontainer template` commands you need to configure some templates. 8 | 9 | A good starting point is the the VS Code devcontainers repo. Choose a directory, and clone the repo using `git clone https://github.com/microsoft/vscode-dev-containers` 10 | 11 | Next, we need to tell the `devcontainer` CLI to use this folder. If you haven't previously created a config file, run `devcontainer config write` to save a config file and then open `~/.devcontainer-cli/devcontainer-cli.json` in your favourite editor. 12 | 13 | The starting configuration will look something like: 14 | 15 | ```json 16 | { 17 | "templatepaths": [] 18 | } 19 | ``` 20 | 21 | Update to include the path to the `containers` folder in the `vscode-dev-containers` repo you just cloned: 22 | 23 | ```json 24 | { 25 | "templatepaths": ["$HOME/source/vscode-dev-containers/containers"] 26 | } 27 | ``` 28 | 29 | ## Listing templates 30 | 31 | Running `devcontainer template list` will show the templates that `devcontainer` discovered 32 | 33 | ## Adding a devcontainer definition 34 | 35 | To add the files for a devcontainer definition to your project, change directory to the folder you want to add the devcontainer to and then run: 36 | 37 | ```bash 38 | # Add the go template 39 | devcontainer template add go 40 | ``` 41 | 42 | This will copy in the template files for you to modify as you wish. 43 | 44 | ## Adding a link to a devcontainer 45 | 46 | If you are working with a codebase that you don't want to commit the devcontainer definition to (e.g. an OSS project that doesn't want a devcontainer definition), you can use the `template add-link` command. Instead of copying template files it creates symlinks to the template files and adds a `.gitignore` file to avoid accidental git commits. 47 | 48 | As with `template add`, run this from the folder you want to add the devcontainer to: 49 | 50 | ```bash 51 | # Symlink to the go template 52 | devcontainer template add-link go 53 | ``` 54 | 55 | See the [repository containers](#repository-containers) section for an alternative to template links. 56 | 57 | ## Creating your own templates 58 | 59 | `devcontainer` can be configured to scan multiple folders to find templates. It is designed to work with folders structured in the same was as the [containers from in github.com/microsoft/vscode-dev-containers](https://github.com/microsoft/vscode-dev-containers/tree/master/containers), e.g.: 60 | 61 | 62 | ```misc 63 | template-collection-folder 64 | |-template1 65 | | |-.devcontainer 66 | | | |-devcontainer.json 67 | | | |-Dockerfile 68 | | | |- 69 | |-misc-folder 70 | |- 71 | |- 72 | ``` 73 | 74 | Assuming you cloned [github.com/microsoft/vscode-dev-containers/](https://github.com/microsoft/vscode-dev-containers/) into your `~/source/` folder and set up a custom devcontainer folder in `~/source/devcontainers` then you can configure your template paths as shown below. The sub-folder names are used as the template name and when duplicates are found the first matching folder is taken, so in the example below the `~/source/devcontainers` templates take precedence. 75 | 76 | ```json 77 | { 78 | "templatepaths": [ 79 | "$HOME/source/devcontainers", 80 | "$HOME/source/vscode-dev-containers/containers" 81 | ] 82 | } 83 | ``` 84 | 85 | ## Placeholder Values 86 | 87 | After content has been copied to the project folder from a template, the following placeholder values are substituted: 88 | 89 | | Placeholder | Value | 90 | |------------------------------|----------------------------------------------------------------------------------------------------------------------| 91 | | `__DEVCONTAINER_NAME__` | The name of the dev container (from the `name` property in `devcontainer.json`) | 92 | | `__DEVCONTAINER_USER_NAME__` | The name of the user for dev container (from the `remoteuser` property in `devcontainer.json`, or `root` if not set) | 93 | | `__DEVCONTAINER_HOME__` | The home folder for the dev container (e.g. `/home/vscode` or `/root`) | 94 | 95 | ## Repository containers 96 | 97 | VS Code dev containers have another feature called "Repository containers". These are a set of dev container definitions that VS Code will automatically apply to a project based on its git repo. 98 | 99 | The default definitions are in the [microsoft/vscode-dev-containers](https://github.com/microsoft/vscode-dev-containers/tree/master/repository-containers) repo. If you look at the repo, you will see a `github.com` folder followed by paths for `/`, e.g. `django/django`. The `https://github.com/django/django` repo doesn't contain a dev container definition, but VS Code will use the repository container definition from the `microsoft/vscode-dev-containers` repo. 100 | 101 | You can also configure VS Code to look for additional local paths for repository containers by providing a value for the VS Code `remote.containers.repository-container-paths` setting (see [this issue](https://github.com/microsoft/vscode-remote-release/issues/3218) for more details). 102 | -------------------------------------------------------------------------------- /cmd/devcontainerx/template.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "os" 7 | "sort" 8 | "text/tabwriter" 9 | 10 | "github.com/spf13/cobra" 11 | "github.com/stuartleeks/devcontainer-cli/internal/pkg/devcontainers" 12 | ioutil2 "github.com/stuartleeks/devcontainer-cli/internal/pkg/ioutil" 13 | ) 14 | 15 | func createTemplateCommand() *cobra.Command { 16 | cmd := &cobra.Command{ 17 | Use: "template", 18 | Short: "work with templates", 19 | Long: "Use subcommands to work with devcontainer templates", 20 | } 21 | cmd.AddCommand(createTemplateListCommand()) 22 | cmd.AddCommand(createTemplateAddCommand()) 23 | cmd.AddCommand(createTemplateAddLinkCommand()) 24 | return cmd 25 | } 26 | 27 | func createTemplateListCommand() *cobra.Command { 28 | var listVerbose bool 29 | cmd := &cobra.Command{ 30 | Use: "list", 31 | Short: "list templates", 32 | Long: "List devcontainer templates", 33 | RunE: func(cmd *cobra.Command, args []string) error { 34 | 35 | templates, err := devcontainers.GetTemplates() 36 | if err != nil { 37 | return err 38 | } 39 | 40 | if listVerbose { 41 | w := new(tabwriter.Writer) 42 | // minwidth, tabwidth, padding, padchar, flags 43 | w.Init(os.Stdout, 8, 8, 0, '\t', 0) 44 | defer w.Flush() 45 | 46 | fmt.Fprintf(w, "%s\t%s\n", "TEMPLATE NAME", "PATH") 47 | fmt.Fprintf(w, "%s\t%s\n", "-------------", "----") 48 | 49 | for _, template := range templates { 50 | fmt.Fprintf(w, "%s\t%s\n", template.Name, template.Path) 51 | } 52 | return nil 53 | } 54 | 55 | for _, template := range templates { 56 | fmt.Println(template.Name) 57 | } 58 | return nil 59 | }, 60 | } 61 | cmd.Flags().BoolVarP(&listVerbose, "verbose", "v", false, "Verbose output") 62 | return cmd 63 | } 64 | 65 | func createTemplateAddCommand() *cobra.Command { 66 | var devcontainerName string 67 | cmd := &cobra.Command{ 68 | Use: "add TEMPLATE_NAME", 69 | Short: "add devcontainer from template", 70 | Long: "Add a devcontainer definition to the current folder using the specified template", 71 | RunE: func(cmd *cobra.Command, args []string) error { 72 | 73 | if len(args) != 1 { 74 | return cmd.Usage() 75 | } 76 | name := args[0] 77 | 78 | template, err := devcontainers.GetTemplateByName(name) 79 | if err != nil { 80 | return err 81 | } 82 | if template == nil { 83 | fmt.Printf("Template '%s' not found\n", name) 84 | } 85 | 86 | info, err := os.Stat("./.devcontainer") 87 | if info != nil && err == nil { 88 | return fmt.Errorf("Current folder already contains a .devcontainer folder - exiting") 89 | } 90 | 91 | currentDirectory, err := os.Getwd() 92 | if err != nil { 93 | return fmt.Errorf("Error reading current directory: %s\n", err) 94 | } 95 | 96 | err = devcontainers.CopyTemplateToFolder(template.Path, currentDirectory, devcontainerName) 97 | if err != nil { 98 | return err 99 | } 100 | 101 | return nil 102 | }, 103 | ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { 104 | // only completing the first arg (template name) 105 | if len(args) != 0 { 106 | return nil, cobra.ShellCompDirectiveNoFileComp 107 | } 108 | templates, err := devcontainers.GetTemplates() 109 | if err != nil { 110 | os.Exit(1) 111 | } 112 | names := []string{} 113 | for _, template := range templates { 114 | names = append(names, template.Name) 115 | } 116 | sort.Strings(names) 117 | return names, cobra.ShellCompDirectiveNoFileComp 118 | }, 119 | } 120 | cmd.Flags().StringVar(&devcontainerName, "devcontainer-name", "", "Value to set the devcontainer.json name property to (default is folder name)") 121 | return cmd 122 | } 123 | 124 | func createTemplateAddLinkCommand() *cobra.Command { 125 | cmd := &cobra.Command{ 126 | Use: "add-link TEMPLATE_NAME", 127 | Short: "add-link devcontainer from template", 128 | Long: "Symlink a devcontainer definition to the current folder using the specified template", 129 | RunE: func(cmd *cobra.Command, args []string) error { 130 | 131 | if len(args) != 1 { 132 | return cmd.Usage() 133 | } 134 | name := args[0] 135 | 136 | template, err := devcontainers.GetTemplateByName(name) 137 | if err != nil { 138 | return err 139 | } 140 | if template == nil { 141 | return fmt.Errorf("Template '%s' not found\n", name) 142 | } 143 | 144 | info, err := os.Stat("./.devcontainer") 145 | if info != nil && err == nil { 146 | return fmt.Errorf("Current folder already contains a .devcontainer folder - exiting") 147 | } 148 | 149 | currentDirectory, err := os.Getwd() 150 | if err != nil { 151 | return fmt.Errorf("Error reading current directory: %s\n", err) 152 | } 153 | if err = ioutil2.LinkFolder(template.Path, currentDirectory+"/.devcontainer"); err != nil { 154 | return fmt.Errorf("Error linking folder: %s\n", err) 155 | } 156 | 157 | content := []byte("*\n") 158 | if err := ioutil.WriteFile(currentDirectory+"/.devcontainer/.gitignore", content, 0644); err != nil { // -rw-r--r-- 159 | return fmt.Errorf("Error writing .gitignore: %s\n", err) 160 | } 161 | return err 162 | }, 163 | ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { 164 | // only completing the first arg (template name) 165 | if len(args) != 0 { 166 | return nil, cobra.ShellCompDirectiveNoFileComp 167 | } 168 | templates, err := devcontainers.GetTemplates() 169 | if err != nil { 170 | os.Exit(1) 171 | } 172 | names := []string{} 173 | for _, template := range templates { 174 | names = append(names, template.Name) 175 | } 176 | sort.Strings(names) 177 | return names, cobra.ShellCompDirectiveNoFileComp 178 | }, 179 | } 180 | return cmd 181 | } 182 | -------------------------------------------------------------------------------- /docs/snippet.md: -------------------------------------------------------------------------------- 1 | # devcontainer snippet ... 2 | 3 | ***WARNING: This feature is experimental!*** 4 | 5 | {:toc} 6 | 7 | ## Setting up snippets 8 | 9 | To use the `devcontainer snippet` commands you need to enable experimental feaaures and configure some snippet folders. 10 | 11 | A snippet collection can be as simple as a set of `.sh` scripts. Good starting points for snippets are the `snippets` folder of [stuartleeks/devcontainers](https://github.com/stuartleeks/devcontainers) and [benc-uk/tools-install](https://github.com/benc-uk/tools-install/). 12 | 13 | Choose a directory to put the snippets in, and clone the repos: 14 | 15 | ```bash 16 | git clone https://github.com/stuartleeks/devcontainers 17 | git clone https://github.com/benc-uk/tools-install/ 18 | ``` 19 | 20 | Next, we need to tell the `devcontainer` CLI to use these folders. If you haven't previously created a config file, run `devcontainer config write` to save a config file and then open `~/.devcontainer-cli/devcontainer-cli.json` in your favourite editor. 21 | 22 | The starting configuration will look something like: 23 | 24 | ```json 25 | { 26 | "templatepaths": [] 27 | } 28 | ``` 29 | 30 | Add a `snippetpaths` setting with an array value containing the path to your snippet folders. For example, if you cloned the repos into your `~/source` folder add the following to your config file: 31 | 32 | ```json 33 | { 34 | "experimental" : true, 35 | "snippetpaths": ["$HOME/source/sl-devcontainers/snippets", "$HOME/source/tools-install"] 36 | } 37 | ``` 38 | 39 | NOTE: You also need to add the `experimental` setting with the value `true` as snippets are currently an experimental feature 40 | 41 | ## Listing snippets 42 | 43 | Running `devcontainer snippet list` will show the snippets that `devcontainer` discovered 44 | 45 | ## Adding a snippet 46 | 47 | To add a snippet to the dev container definition to your project, change directory to the project folder (i.e. the one containing the `.devcontainer` folder) and then run: 48 | 49 | ```bash 50 | # Add the azbrowse 51 | devcontainer snippet add azbrowse 52 | ``` 53 | 54 | This will copy in the snippet files for you to modify as you wish. 55 | 56 | ## Creating your own snippets 57 | 58 | `devcontainer` can be configured to scan multiple folders to find snippets. For each folder configured in the `snippetpaths` setting it searches for snippets. There are currently two types of snippet supported: single file snippets and folder-based snippets. 59 | 60 | ### Single file snippets 61 | 62 | Single file snippets are `.sh` files that are copied to the `.devcontainer/scripts` folder and added to the `Dockerfile`. This is the simplest place to start when creating a snippet. 63 | 64 | ### Folder-based snippets 65 | 66 | Folder-based snippets are folders containing a `snippet.json` file. The `snippet.json` describes the actions to take when applying the snippet, for example: 67 | 68 | ```json 69 | { 70 | "actions": [ 71 | { 72 | "type" : "copyAndRun", 73 | "source": "my-script.sh" 74 | } 75 | ] 76 | } 77 | ``` 78 | 79 | The `actions` property can contain multiple actions and they are applied in order. 80 | 81 | The following action types are supported: 82 | 83 | - `copyAndRun` 84 | - `mergeJSON` 85 | - `dockerfileSnippet` 86 | 87 | #### copyAndRun action 88 | 89 | The `copyAndRun` action provides the same capability as the single file snippet, i.e. the source file is copied and added to the `Dockerfile`. 90 | 91 | The following properties are supported for a `copyAndRun` action: 92 | 93 | | Property | Description | 94 | |----------|--------------------------------------------------------------------| 95 | | source | The path to the script file to copy (relative to the snippet.json) | 96 | 97 | For example: 98 | 99 | ```json 100 | { 101 | "actions": [ 102 | { 103 | "type": "copyAndRun", 104 | "source": "golang.sh" 105 | } 106 | ] 107 | } 108 | ``` 109 | 110 | #### mergeJSON action 111 | 112 | The `mergeJSON` action provides the ability to merge changes into a JSON file (e.g. `devcontainer.json`). 113 | 114 | The following properties are supported for a `mergeJSON` action: 115 | 116 | | Property | Description | 117 | |----------|-------------------------------------------------------------------------------| 118 | | source | The path to the JSON file containing the properties to merge in to the target | 119 | | target | The path to the JSON file to merge the changes into | 120 | 121 | For example: 122 | 123 | ```json 124 | { 125 | "actions": [ 126 | { 127 | "type": "mergeJSON", 128 | "source": "devcontainer.json", 129 | "target": ".devcontainer/devcontainer.json" 130 | } 131 | ] 132 | } 133 | ``` 134 | 135 | #### dockerfileSnippet action 136 | 137 | The `dockerfileSnippet` action provides a way to add custom steps to the `Dockerfile` for a dev container. 138 | 139 | The following properties are supported for a `dockerfileSnippet` action: 140 | 141 | | Property | Description | 142 | |----------|--------------------------------------| 143 | | content | The content to add to the Dockerfile | 144 | 145 | For example: 146 | 147 | ```json 148 | { 149 | "actions": [ 150 | { 151 | "type": "dockerfileSnippet", 152 | "content": "# Add go to PATH\nENV PATH /usr/local/go/bin:$PATH" 153 | } 154 | ] 155 | } 156 | ``` 157 | 158 | ### Placeholder Values 159 | 160 | After content has been merged/added to files when performing snippet actions, the following placeholder values are substituted: 161 | 162 | | Placeholder | Value | 163 | |------------------------------|----------------------------------------------------------------------------------------------------------------------| 164 | | `__DEVCONTAINER_NAME__` | The name of the dev container (from the `name` property in `devcontainer.json`) | 165 | | `__DEVCONTAINER_USER_NAME__` | The name of the user for dev container (from the `remoteuser` property in `devcontainer.json`, or `root` if not set) | 166 | | `__DEVCONTAINER_HOME__` | The home folder for the dev container (e.g. `/home/vscode` or `/root`) | 167 | -------------------------------------------------------------------------------- /internal/pkg/devcontainers/template.go: -------------------------------------------------------------------------------- 1 | package devcontainers 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "os" 7 | "path/filepath" 8 | "regexp" 9 | "sort" 10 | "strings" 11 | 12 | "github.com/stuartleeks/devcontainer-cli/internal/pkg/config" 13 | "github.com/stuartleeks/devcontainer-cli/internal/pkg/errors" 14 | ioutil2 "github.com/stuartleeks/devcontainer-cli/internal/pkg/ioutil" 15 | ) 16 | 17 | // DevcontainerTemplate holds info on templates for list/add etc 18 | type DevcontainerTemplate struct { 19 | Name string 20 | // Path is the path including the .devcontainer folder 21 | Path string 22 | } 23 | 24 | // GetTemplateByName returns the template with the specified name or nil if not found 25 | func GetTemplateByName(name string) (*DevcontainerTemplate, error) { 26 | // TODO - could possibly make this quicker by searching using the name rather than listing all and filtering 27 | templates, err := GetTemplates() 28 | if err != nil { 29 | return nil, err 30 | } 31 | for _, template := range templates { 32 | if template.Name == name { 33 | return &template, nil 34 | } 35 | } 36 | return nil, nil 37 | } 38 | 39 | // GetTemplates returns a list of discovered templates 40 | func GetTemplates() ([]DevcontainerTemplate, error) { 41 | 42 | folders := config.GetTemplateFolders() 43 | if len(folders) == 0 { 44 | return []DevcontainerTemplate{}, &errors.StatusError{Message: "No template folders configured - see https://github.com/stuartleeks/devcontainer-cli/#working-with-devcontainer-templates"} 45 | } 46 | templates, err := getTemplatesFromFolders(folders) 47 | if err != nil { 48 | return []DevcontainerTemplate{}, err 49 | } 50 | return templates, nil 51 | } 52 | 53 | func getTemplatesFromFolders(folders []string) ([]DevcontainerTemplate, error) { 54 | templates := []DevcontainerTemplate{} 55 | templateNames := map[string]bool{} 56 | 57 | for _, folder := range folders { 58 | folder := os.ExpandEnv(folder) 59 | newTemplates, err := getTemplatesFromFolder(folder) 60 | if err != nil { 61 | return []DevcontainerTemplate{}, err 62 | } 63 | for _, template := range newTemplates { 64 | if !templateNames[template.Name] { 65 | templateNames[template.Name] = true 66 | templates = append(templates, template) 67 | } 68 | } 69 | } 70 | sort.Slice(templates, func(i int, j int) bool { return templates[i].Name < templates[j].Name }) 71 | return templates, nil 72 | } 73 | 74 | func getTemplatesFromFolder(folder string) ([]DevcontainerTemplate, error) { 75 | isDevcontainerFolder := func(parentPath string, fi os.FileInfo) bool { 76 | if !fi.IsDir() { 77 | return false 78 | } 79 | // TOODO - add support for templates with .devcontainer.json rather than .devcontainer folder 80 | devcontainerJsonPath := filepath.Join(parentPath, fi.Name(), ".devcontainer/devcontainer.json") 81 | devContainerJsonInfo, err := os.Stat(devcontainerJsonPath) 82 | return err == nil && !devContainerJsonInfo.IsDir() 83 | } 84 | c, err := ioutil.ReadDir(folder) 85 | 86 | if err != nil { 87 | return []DevcontainerTemplate{}, fmt.Errorf("Error reading devcontainer definitions: %s\n", err) 88 | } 89 | 90 | templates := []DevcontainerTemplate{} 91 | for _, entry := range c { 92 | if isDevcontainerFolder(folder, entry) { 93 | template := DevcontainerTemplate{ 94 | Name: entry.Name(), 95 | Path: filepath.Join(folder, entry.Name(), ".devcontainer"), 96 | } 97 | templates = append(templates, template) 98 | } 99 | } 100 | return templates, nil 101 | } 102 | 103 | func GetDefaultDevcontainerNameForFolder(folderPath string) (string, error) { 104 | 105 | absPath, err := filepath.Abs(folderPath) 106 | if err != nil { 107 | return "", err 108 | } 109 | 110 | _, folderName := filepath.Split(absPath) 111 | return folderName, nil 112 | } 113 | 114 | func CopyTemplateToFolder(templatePath string, targetFolder string, devcontainerName string) error { 115 | var err error 116 | 117 | if err = ioutil2.CopyFolder(templatePath, filepath.Join(targetFolder, ".devcontainer")); err != nil { 118 | return fmt.Errorf("Error copying folder: %s\n", err) 119 | } 120 | 121 | // by default the "name" in devcontainer.json is set to the name of the template 122 | // override it here with the value passed in as --devcontainer-name (or the containing folder if not set) 123 | if devcontainerName == "" { 124 | devcontainerName, err = GetDefaultDevcontainerNameForFolder(targetFolder) 125 | if err != nil { 126 | return fmt.Errorf("Error getting default devcontainer name: %s", err) 127 | } 128 | } 129 | devcontainerJsonPath := filepath.Join(targetFolder, ".devcontainer", "devcontainer.json") 130 | err = SetDevcontainerName(devcontainerJsonPath, devcontainerName) 131 | if err != nil { 132 | return fmt.Errorf("Error setting devcontainer name: %s", err) 133 | } 134 | 135 | values, err := getSubstitutionValuesFromFile(devcontainerJsonPath) 136 | if err != nil { 137 | return fmt.Errorf("Error getting substituion values: %s", err) 138 | } 139 | err = recursiveSubstituteValues(values, filepath.Join(targetFolder, ".devcontainer")) 140 | if err != nil { 141 | return fmt.Errorf("Error performing substitution: %s", err) 142 | } 143 | 144 | return nil 145 | } 146 | 147 | func recursiveSubstituteValues(values *SubstitutionValues, path string) error { 148 | _, err := os.Stat(path) 149 | if err != nil { 150 | return fmt.Errorf("Error reading folder: %s\n", err) 151 | } 152 | 153 | subItems, err := ioutil.ReadDir(path) 154 | if err != nil { 155 | return fmt.Errorf("Error reading source folder contents: %s\n", err) 156 | } 157 | 158 | for _, subItem := range subItems { 159 | if subItem.IsDir() { 160 | if err = recursiveSubstituteValues(values, filepath.Join(path, subItem.Name())); err != nil { 161 | return err 162 | } 163 | } else { 164 | if err = performSubstitutionFile(values, filepath.Join(path, subItem.Name())); err != nil { 165 | return err 166 | } 167 | } 168 | } 169 | return nil 170 | } 171 | 172 | func SetDevcontainerName(devContainerJsonPath string, name string) error { 173 | // This doesn't use `json` as devcontainer.json permits comments (and the default templates include them!) 174 | 175 | // TODO - update this to use dora to query 176 | // TODO - update this to replace __DEVCONTAINER_USER_NAME__ and __DEVCONTAINER_HOME__ 177 | 178 | buf, err := ioutil.ReadFile(devContainerJsonPath) 179 | if err != nil { 180 | return fmt.Errorf("error reading file %q: %s", devContainerJsonPath, err) 181 | } 182 | content := string(buf) 183 | 184 | // replace `name` property in JSON 185 | r := regexp.MustCompile("(\"name\"\\s*:\\s*\")[^\"]*(\")") 186 | replacement := "${1}" + name + "${2}" 187 | content = r.ReplaceAllString(content, replacement) 188 | 189 | // replace __DEVCONTAINER_NAME__ with name 190 | content = strings.ReplaceAll(content, "__DEVCONTAINER_NAME__", name) 191 | 192 | buf = []byte(content) 193 | if err = ioutil.WriteFile(devContainerJsonPath, buf, 0777); err != nil { 194 | return fmt.Errorf("error writing file %q: %s", devContainerJsonPath, err) 195 | } 196 | 197 | return nil 198 | } 199 | 200 | // "remoteUser": "vscode" 201 | func GetDevContainerUserName(devContainerJsonPath string) (string, error) { 202 | buf, err := ioutil.ReadFile(devContainerJsonPath) 203 | if err != nil { 204 | return "", fmt.Errorf("error reading file %q: %s", devContainerJsonPath, err) 205 | } 206 | 207 | r := regexp.MustCompile("\n[^/]*\"remoteUser\"\\s*:\\s*\"([^\"]*)\"") 208 | match := r.FindStringSubmatch(string(buf)) 209 | 210 | if len(match) <= 0 { 211 | return "", nil 212 | } 213 | return match[1], nil 214 | } 215 | -------------------------------------------------------------------------------- /cmd/devcontainerx/devcontainer.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | "sort" 8 | "text/tabwriter" 9 | 10 | "github.com/spf13/cobra" 11 | "github.com/stuartleeks/devcontainer-cli/internal/pkg/devcontainers" 12 | ) 13 | 14 | func createListCommand() *cobra.Command { 15 | var listIncludeContainerNames bool 16 | var listVerbose bool 17 | cmdList := &cobra.Command{ 18 | Use: "list", 19 | Short: "List devcontainers", 20 | Long: "Lists running devcontainers", 21 | RunE: func(cmd *cobra.Command, args []string) error { 22 | if listIncludeContainerNames && listVerbose { 23 | fmt.Println("Can't use both verbose and include-container-names") 24 | os.Exit(1) 25 | } 26 | devcontainers, err := devcontainers.ListDevcontainers() 27 | if err != nil { 28 | return err 29 | } 30 | if listVerbose { 31 | sort.Slice(devcontainers, func(i, j int) bool { return devcontainers[i].DevcontainerName < devcontainers[j].DevcontainerName }) 32 | 33 | w := new(tabwriter.Writer) 34 | // minwidth, tabwidth, padding, padchar, flags 35 | w.Init(os.Stdout, 8, 8, 0, '\t', 0) 36 | defer w.Flush() 37 | 38 | fmt.Fprintf(w, "%s\t%s\n", "DEVCONTAINER NAME", "CONTAINER NAME") 39 | fmt.Fprintf(w, "%s\t%s\n", "-----------------", "--------------") 40 | 41 | for _, devcontainer := range devcontainers { 42 | fmt.Fprintf(w, "%s\t%s\n", devcontainer.DevcontainerName, devcontainer.ContainerName) 43 | } 44 | return nil 45 | } 46 | names := []string{} 47 | for _, devcontainer := range devcontainers { 48 | names = append(names, devcontainer.DevcontainerName) 49 | if listIncludeContainerNames { 50 | names = append(names, devcontainer.ContainerName) 51 | } 52 | } 53 | sort.Strings(names) 54 | for _, name := range names { 55 | fmt.Println(name) 56 | } 57 | return nil 58 | }, 59 | } 60 | cmdList.Flags().BoolVar(&listIncludeContainerNames, "include-container-names", false, "Also include container names in the list") 61 | cmdList.Flags().BoolVarP(&listVerbose, "verbose", "v", false, "Verbose output") 62 | return cmdList 63 | } 64 | 65 | func createShowCommand() *cobra.Command { 66 | var argDevcontainerName string 67 | cmd := &cobra.Command{ 68 | Use: "show --name ", 69 | Short: "Show devcontainer info", 70 | Long: "Show information about a running dev container", 71 | RunE: func(cmd *cobra.Command, args []string) error { 72 | devcontainers, err := devcontainers.ListDevcontainers() 73 | if err != nil { 74 | return err 75 | } 76 | containerIDOrName := argDevcontainerName 77 | 78 | // Get container ID 79 | for _, devcontainer := range devcontainers { 80 | if devcontainer.ContainerName == containerIDOrName || 81 | devcontainer.DevcontainerName == containerIDOrName || 82 | devcontainer.ContainerID == containerIDOrName { 83 | output, err := json.MarshalIndent(devcontainer, "", "\t") 84 | if err != nil { 85 | return fmt.Errorf("Failed to serialise devcontainer info: %s", err) 86 | } 87 | fmt.Printf("%s\n", output) 88 | return nil 89 | } 90 | } 91 | 92 | return fmt.Errorf("Failed to find a matching (running) dev container for %q", containerIDOrName) 93 | }, 94 | } 95 | cmd.Flags().StringVarP(&argDevcontainerName, "name", "n", "", "name of dev container to exec into") 96 | 97 | _ = cmd.RegisterFlagCompletionFunc("name", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { 98 | devcontainers, err := devcontainers.ListDevcontainers() 99 | if err != nil { 100 | os.Exit(1) 101 | } 102 | names := []string{} 103 | for _, devcontainer := range devcontainers { 104 | names = append(names, devcontainer.DevcontainerName) 105 | } 106 | sort.Strings(names) 107 | return names, cobra.ShellCompDirectiveNoFileComp 108 | 109 | }) 110 | return cmd 111 | } 112 | 113 | func countBooleans(values ...bool) int { 114 | count := 0 115 | for _, v := range values { 116 | if v { 117 | count++ 118 | } 119 | } 120 | return count 121 | } 122 | 123 | func createExecCommand() *cobra.Command { 124 | var argDevcontainerName string 125 | var argDevcontainerPath string 126 | var argPromptForDevcontainer bool 127 | var argWorkDir string 128 | 129 | cmd := &cobra.Command{ 130 | Use: "exec [--name | --path | --prompt ] [--work-dir ] [ []] (command will default to /bin/bash if none provided)", 131 | Short: "Execute a command in a devcontainer", 132 | Long: "Execute a command in a devcontainer, similar to `docker exec`", 133 | RunE: func(cmd *cobra.Command, args []string) error { 134 | // Default to executing /bin/bash 135 | if len(args) == 0 { 136 | args = []string{"/bin/bash"} 137 | } 138 | 139 | sourceCount := countBooleans( 140 | argDevcontainerName != "", 141 | argDevcontainerPath != "", 142 | argPromptForDevcontainer, 143 | ) 144 | if sourceCount > 1 { 145 | fmt.Println("Can specify at most one of --name/--path/--prompt") 146 | return cmd.Usage() 147 | } 148 | 149 | // workDir default: 150 | // - devcontainer mount path if name or prompt specified (ExecInDevContainer defaults to this if workDir is "") 151 | // - path if path set 152 | // - current directory if path == "" and neither name or prompt set 153 | workDir := argWorkDir 154 | 155 | containerID := "" 156 | devcontainerList, err := devcontainers.ListDevcontainers() 157 | if err != nil { 158 | return err 159 | } 160 | if argDevcontainerName != "" { 161 | containerIDOrName := argDevcontainerName 162 | 163 | // Get container ID 164 | for _, devcontainer := range devcontainerList { 165 | if devcontainer.ContainerName == containerIDOrName || 166 | devcontainer.DevcontainerName == containerIDOrName || 167 | devcontainer.ContainerID == containerIDOrName { 168 | containerID = devcontainer.ContainerID 169 | break 170 | } 171 | } 172 | 173 | if containerID == "" { 174 | return fmt.Errorf("Failed to find a matching (running) dev container for %q", containerIDOrName) 175 | } 176 | } else if argPromptForDevcontainer { 177 | // prompt user 178 | fmt.Println("Specify the devcontainer to use:") 179 | for index, devcontainer := range devcontainerList { 180 | fmt.Printf("%4d: %s (%s)\n", index, devcontainer.DevcontainerName, devcontainer.ContainerName) 181 | } 182 | selection := -1 183 | _, _ = fmt.Scanf("%d", &selection) 184 | if selection < 0 || selection >= len(devcontainerList) { 185 | return fmt.Errorf("Invalid option") 186 | } 187 | containerID = devcontainerList[selection].ContainerID 188 | } else { 189 | devcontainerPath := argDevcontainerPath 190 | // TODO - update to check for devcontainers in the path ancestry 191 | // Can't just check up the path for a .devcontainer folder as the container might 192 | // have been created via repository containers (https://github.com/microsoft/vscode-dev-containers/tree/main/repository-containers) 193 | devcontainer, err := devcontainers.GetClosestPathMatchForPath(devcontainerList, devcontainerPath) 194 | if err != nil { 195 | return err 196 | } 197 | containerID = devcontainer.ContainerID 198 | if workDir == "" { 199 | if devcontainerPath == "" { 200 | workDir = "." 201 | } else { 202 | workDir = devcontainerPath 203 | } 204 | } 205 | } 206 | 207 | return devcontainers.ExecInDevContainer(containerID, workDir, args) 208 | }, 209 | Args: cobra.ArbitraryArgs, 210 | DisableFlagsInUseLine: true, 211 | ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { 212 | return nil, cobra.ShellCompDirectiveNoFileComp 213 | }, 214 | } 215 | cmd.Flags().StringVarP(&argDevcontainerName, "name", "n", "", "name of dev container to exec into") 216 | cmd.Flags().StringVarP(&argDevcontainerPath, "path", "", "", "path containing the dev container to exec into") 217 | cmd.Flags().BoolVarP(&argPromptForDevcontainer, "prompt", "", false, "prompt for the dev container to exec into") 218 | cmd.Flags().StringVarP(&argWorkDir, "work-dir", "", "", "working directory to use in the dev container") 219 | 220 | _ = cmd.RegisterFlagCompletionFunc("name", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { 221 | devcontainers, err := devcontainers.ListDevcontainers() 222 | if err != nil { 223 | os.Exit(1) 224 | } 225 | names := []string{} 226 | for _, devcontainer := range devcontainers { 227 | names = append(names, devcontainer.DevcontainerName) 228 | } 229 | sort.Strings(names) 230 | return names, cobra.ShellCompDirectiveNoFileComp 231 | 232 | }) 233 | return cmd 234 | } 235 | -------------------------------------------------------------------------------- /internal/pkg/devcontainers/template_test.go: -------------------------------------------------------------------------------- 1 | package devcontainers 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestSetDevcontainerName(t *testing.T) { 13 | 14 | f, err := ioutil.TempFile("", "test.json") 15 | if !assert.NoError(t, err) { 16 | return 17 | } 18 | defer os.Remove(f.Name()) 19 | 20 | _, _ = f.WriteString(`{ 21 | "name": "initial", 22 | // here's a comment! 23 | "otherProperties": [ 24 | "something", 25 | "here" 26 | ] 27 | }`) 28 | 29 | err = SetDevcontainerName(f.Name(), "newName") 30 | if !assert.NoError(t, err) { 31 | return 32 | } 33 | 34 | buf, err := ioutil.ReadFile(f.Name()) 35 | if !assert.NoError(t, err) { 36 | return 37 | } 38 | 39 | assert.Equal(t, `{ 40 | "name": "newName", 41 | // here's a comment! 42 | "otherProperties": [ 43 | "something", 44 | "here" 45 | ] 46 | }`, string(buf)) 47 | } 48 | 49 | func TestGetDevContainerUserName_Uncommented(t *testing.T) { 50 | 51 | f, err := ioutil.TempFile("", "test.json") 52 | if !assert.NoError(t, err) { 53 | return 54 | } 55 | defer os.Remove(f.Name()) 56 | 57 | _, _ = f.WriteString(`{ 58 | "name": "initial", 59 | // here's a comment! 60 | "otherProperties": [ 61 | "something", 62 | "here" 63 | ], 64 | "remoteUser": "vscode" 65 | }`) 66 | 67 | user, err := GetDevContainerUserName(f.Name()) 68 | if !assert.NoError(t, err) { 69 | return 70 | } 71 | assert.Equal(t, "vscode", user) 72 | } 73 | 74 | func TestGetDevContainerUserName_NotSet(t *testing.T) { 75 | 76 | f, err := ioutil.TempFile("", "test.json") 77 | if !assert.NoError(t, err) { 78 | return 79 | } 80 | defer os.Remove(f.Name()) 81 | 82 | _, _ = f.WriteString(`{ 83 | "name": "initial", 84 | // here's a comment! 85 | "otherProperties": [ 86 | "something", 87 | "here" 88 | ] 89 | }`) 90 | 91 | user, err := GetDevContainerUserName(f.Name()) 92 | if !assert.NoError(t, err) { 93 | return 94 | } 95 | assert.Equal(t, "", user) 96 | } 97 | 98 | func TestGetDevContainerUserName_Commented(t *testing.T) { 99 | 100 | f, err := ioutil.TempFile("", "test.json") 101 | if !assert.NoError(t, err) { 102 | return 103 | } 104 | defer os.Remove(f.Name()) 105 | 106 | _, _ = f.WriteString(`{ 107 | "name": "initial", 108 | // here's a comment! 109 | "otherProperties": [ 110 | "something", 111 | "here" 112 | ], 113 | // "remoteUser": "vscode" 114 | }`) 115 | 116 | user, err := GetDevContainerUserName(f.Name()) 117 | if !assert.NoError(t, err) { 118 | return 119 | } 120 | assert.Equal(t, "", user) 121 | } 122 | 123 | func TestSetDevcontainerName_SubstitutionValue(t *testing.T) { 124 | 125 | f, err := ioutil.TempFile("", "test.json") 126 | if !assert.NoError(t, err) { 127 | return 128 | } 129 | defer os.Remove(f.Name()) 130 | 131 | _, _ = f.WriteString(`{ 132 | "name": "initial", 133 | // here's a comment! 134 | "otherProperties": [ 135 | "something-__DEVCONTAINER_NAME__", 136 | "here-__DEVCONTAINER_NAME__" 137 | ] 138 | }`) 139 | 140 | err = SetDevcontainerName(f.Name(), "newName") 141 | if !assert.NoError(t, err) { 142 | return 143 | } 144 | 145 | buf, err := ioutil.ReadFile(f.Name()) 146 | if !assert.NoError(t, err) { 147 | return 148 | } 149 | 150 | assert.Equal(t, `{ 151 | "name": "newName", 152 | // here's a comment! 153 | "otherProperties": [ 154 | "something-newName", 155 | "here-newName" 156 | ] 157 | }`, string(buf)) 158 | } 159 | 160 | func TestGetTemplateFolders_ListsFoldersWithDevcontainers(t *testing.T) { 161 | 162 | root, err := ioutil.TempDir("", "devcontainer*") 163 | if !assert.NoError(t, err) { 164 | return 165 | } 166 | defer os.RemoveAll(root) 167 | 168 | folders := []string{root} 169 | 170 | _ = os.MkdirAll(filepath.Join(root, "test1", ".devcontainer"), 0755) 171 | _ = ioutil.WriteFile(filepath.Join(root, "test1", ".devcontainer", "devcontainer.json"), []byte{}, 0755) 172 | 173 | _ = os.MkdirAll(filepath.Join(root, "test2", ".devcontainer"), 0755) 174 | _ = ioutil.WriteFile(filepath.Join(root, "test2", ".devcontainer", "devcontainer.json"), []byte{}, 0755) 175 | 176 | templates, err := getTemplatesFromFolders(folders) 177 | if !assert.NoError(t, err) { 178 | return 179 | } 180 | 181 | expectedTemplates := []DevcontainerTemplate{ 182 | { 183 | Name: "test1", 184 | Path: filepath.Join(root, "test1", ".devcontainer"), 185 | }, 186 | { 187 | Name: "test2", 188 | Path: filepath.Join(root, "test2", ".devcontainer"), 189 | }, 190 | } 191 | 192 | assert.ElementsMatch(t, expectedTemplates, templates) 193 | } 194 | func TestGetTemplateFolders_TakesFolderInPrioirtyOrder(t *testing.T) { 195 | 196 | root1, err := ioutil.TempDir("", "devcontainer*") 197 | if !assert.NoError(t, err) { 198 | return 199 | } 200 | defer os.RemoveAll(root1) 201 | 202 | root2, err := ioutil.TempDir("", "devcontainer*") 203 | if !assert.NoError(t, err) { 204 | return 205 | } 206 | defer os.RemoveAll(root2) 207 | 208 | folders := []string{root1, root2} 209 | 210 | _ = os.MkdirAll(filepath.Join(root1, "test1", ".devcontainer"), 0755) 211 | _ = ioutil.WriteFile(filepath.Join(root1, "test1", ".devcontainer", "devcontainer.json"), []byte{}, 0755) 212 | 213 | _ = os.MkdirAll(filepath.Join(root2, "test1", ".devcontainer"), 0755) 214 | _ = ioutil.WriteFile(filepath.Join(root2, "test1", ".devcontainer", "devcontainer.json"), []byte{}, 0755) 215 | 216 | templates, err := getTemplatesFromFolders(folders) 217 | if !assert.NoError(t, err) { 218 | return 219 | } 220 | 221 | expectedTemplates := []DevcontainerTemplate{ 222 | { 223 | Name: "test1", 224 | Path: filepath.Join(root1, "test1", ".devcontainer"), 225 | }, 226 | } 227 | 228 | assert.ElementsMatch(t, expectedTemplates, templates) 229 | } 230 | func TestGetTemplateFolders_IgnoresFolderWithoutDevcontainer(t *testing.T) { 231 | 232 | root1, err := ioutil.TempDir("", "devcontainer*") 233 | if !assert.NoError(t, err) { 234 | return 235 | } 236 | defer os.RemoveAll(root1) 237 | 238 | root2, err := ioutil.TempDir("", "devcontainer*") 239 | if !assert.NoError(t, err) { 240 | return 241 | } 242 | defer os.RemoveAll(root2) 243 | 244 | folders := []string{root1, root2} 245 | 246 | _ = os.MkdirAll(filepath.Join(root1, "test1", ".devcontainer"), 0755) 247 | _ = ioutil.WriteFile(filepath.Join(root1, "test1", ".devcontainer", "not-a-devcontainer.json"), []byte{}, 0755) 248 | 249 | _ = os.MkdirAll(filepath.Join(root2, "test1", ".devcontainer"), 0755) 250 | _ = ioutil.WriteFile(filepath.Join(root2, "test1", ".devcontainer", "devcontainer.json"), []byte{}, 0755) 251 | 252 | templates, err := getTemplatesFromFolders(folders) 253 | if !assert.NoError(t, err) { 254 | return 255 | } 256 | 257 | expectedTemplates := []DevcontainerTemplate{ 258 | { 259 | Name: "test1", 260 | Path: filepath.Join(root2, "test1", ".devcontainer"), // Takes root2 because root1 doesn't have devcontainer.json 261 | }, 262 | } 263 | 264 | assert.ElementsMatch(t, expectedTemplates, templates) 265 | } 266 | 267 | func TestAddTemplate_PerformsSubstitutionWithUserName(t *testing.T) { 268 | 269 | root, err := ioutil.TempDir("", "devcontainer*") 270 | if !assert.NoError(t, err) { 271 | return 272 | } 273 | defer os.RemoveAll(root) 274 | 275 | _ = os.MkdirAll(filepath.Join(root, "test1", ".devcontainer"), 0755) 276 | _ = ioutil.WriteFile(filepath.Join(root, "test1", ".devcontainer", "devcontainer.json"), []byte(`{ 277 | "name": "expect this to be replaced", 278 | "settings": { 279 | "DC_NAME": "__DEVCONTAINER_NAME__", 280 | "DC_USER_NAME": "__DEVCONTAINER_USER_NAME__", 281 | "DC_HOME": "__DEVCONTAINER_HOME__" 282 | }, 283 | "remoteUser": "dcuser" 284 | }`), 0755) 285 | _ = ioutil.WriteFile(filepath.Join(root, "test1", ".devcontainer", "Dockerfile"), []byte(`FROM foo 286 | RUN echo hi 287 | 288 | ENV DC_NAME=__DEVCONTAINER_NAME__ 289 | ENV DC_USER_NAME=__DEVCONTAINER_USER_NAME__ 290 | ENV DC_HOME=__DEVCONTAINER_HOME__ 291 | 292 | # __DEVCONTAINER_SNIPPET_INSERT__ 293 | 294 | RUN echo hi2 295 | `), 0755) 296 | 297 | // set up devcontainer 298 | targetFolder := filepath.Join(root, "target") 299 | _ = os.MkdirAll(targetFolder, 0755) 300 | 301 | // Add template 302 | err = CopyTemplateToFolder(filepath.Join(root, "test1", ".devcontainer"), targetFolder, "NewName") 303 | if !assert.NoError(t, err) { 304 | return 305 | } 306 | 307 | devcontainerFolder := filepath.Join(targetFolder, ".devcontainer") 308 | buf, err := ioutil.ReadFile(filepath.Join(devcontainerFolder, "Dockerfile")) 309 | if !assert.NoError(t, err) { 310 | return 311 | } 312 | assert.Equal(t, `FROM foo 313 | RUN echo hi 314 | 315 | ENV DC_NAME=NewName 316 | ENV DC_USER_NAME=dcuser 317 | ENV DC_HOME=/home/dcuser 318 | 319 | # __DEVCONTAINER_SNIPPET_INSERT__ 320 | 321 | RUN echo hi2 322 | `, string(buf)) 323 | 324 | buf, err = ioutil.ReadFile(filepath.Join(devcontainerFolder, "devcontainer.json")) 325 | if !assert.NoError(t, err) { 326 | return 327 | } 328 | stringContent := string(buf) 329 | assert.Equal(t, `{ 330 | "name": "NewName", 331 | "settings": { 332 | "DC_NAME": "NewName", 333 | "DC_USER_NAME": "dcuser", 334 | "DC_HOME": "/home/dcuser" 335 | }, 336 | "remoteUser": "dcuser" 337 | }`, stringContent) 338 | 339 | } 340 | func TestAddTemplate_PerformsSubstitutionWithoutUserName(t *testing.T) { 341 | 342 | root, err := ioutil.TempDir("", "devcontainer*") 343 | if !assert.NoError(t, err) { 344 | return 345 | } 346 | defer os.RemoveAll(root) 347 | 348 | _ = os.MkdirAll(filepath.Join(root, "test1", ".devcontainer"), 0755) 349 | _ = ioutil.WriteFile(filepath.Join(root, "test1", ".devcontainer", "devcontainer.json"), []byte(`{ 350 | "name": "expect this to be replaced", 351 | "settings": { 352 | "DC_NAME": "__DEVCONTAINER_NAME__", 353 | "DC_USER_NAME": "__DEVCONTAINER_USER_NAME__", 354 | "DC_HOME": "__DEVCONTAINER_HOME__" 355 | }, 356 | }`), 0755) 357 | _ = ioutil.WriteFile(filepath.Join(root, "test1", ".devcontainer", "Dockerfile"), []byte(`FROM foo 358 | RUN echo hi 359 | 360 | ENV DC_NAME=__DEVCONTAINER_NAME__ 361 | ENV DC_USER_NAME=__DEVCONTAINER_USER_NAME__ 362 | ENV DC_HOME=__DEVCONTAINER_HOME__ 363 | 364 | # __DEVCONTAINER_SNIPPET_INSERT__ 365 | 366 | RUN echo hi2 367 | `), 0755) 368 | 369 | // set up devcontainer 370 | targetFolder := filepath.Join(root, "target") 371 | _ = os.MkdirAll(targetFolder, 0755) 372 | 373 | // Add template 374 | err = CopyTemplateToFolder(filepath.Join(root, "test1", ".devcontainer"), targetFolder, "NewName") 375 | if !assert.NoError(t, err) { 376 | return 377 | } 378 | 379 | devcontainerFolder := filepath.Join(targetFolder, ".devcontainer") 380 | buf, err := ioutil.ReadFile(filepath.Join(devcontainerFolder, "Dockerfile")) 381 | if !assert.NoError(t, err) { 382 | return 383 | } 384 | assert.Equal(t, `FROM foo 385 | RUN echo hi 386 | 387 | ENV DC_NAME=NewName 388 | ENV DC_USER_NAME=root 389 | ENV DC_HOME=/root 390 | 391 | # __DEVCONTAINER_SNIPPET_INSERT__ 392 | 393 | RUN echo hi2 394 | `, string(buf)) 395 | 396 | buf, err = ioutil.ReadFile(filepath.Join(devcontainerFolder, "devcontainer.json")) 397 | if !assert.NoError(t, err) { 398 | return 399 | } 400 | stringContent := string(buf) 401 | assert.Equal(t, `{ 402 | "name": "NewName", 403 | "settings": { 404 | "DC_NAME": "NewName", 405 | "DC_USER_NAME": "root", 406 | "DC_HOME": "/root" 407 | }, 408 | }`, stringContent) 409 | 410 | } 411 | -------------------------------------------------------------------------------- /internal/pkg/devcontainers/snippet.go: -------------------------------------------------------------------------------- 1 | package devcontainers 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "os" 8 | "path/filepath" 9 | "sort" 10 | "strings" 11 | 12 | "github.com/stuartleeks/devcontainer-cli/internal/pkg/config" 13 | "github.com/stuartleeks/devcontainer-cli/internal/pkg/errors" 14 | ioutil2 "github.com/stuartleeks/devcontainer-cli/internal/pkg/ioutil" 15 | 16 | dora_ast "github.com/bradford-hamilton/dora/pkg/ast" 17 | "github.com/bradford-hamilton/dora/pkg/dora" 18 | dora_lexer "github.com/bradford-hamilton/dora/pkg/lexer" 19 | dora_merge "github.com/bradford-hamilton/dora/pkg/merge" 20 | dora_parser "github.com/bradford-hamilton/dora/pkg/parser" 21 | ) 22 | 23 | type SubstitutionValues struct { 24 | Name string 25 | UserName string 26 | HomeFolder string 27 | } 28 | 29 | type DevcontainerSnippetType string 30 | 31 | const ( 32 | DevcontainerSnippetTypeSingleFile = "Snippet:SingleFile" 33 | DevcontainerSnippetTypeFolder = "Snippet:Folder" 34 | ) 35 | 36 | // DevcontainerSnippet holds info on snippets for list/add etc 37 | // Snippets can be either single script files or a directory with a set of files 38 | type DevcontainerSnippet struct { 39 | Name string 40 | Type DevcontainerSnippetType 41 | // Path is the path to either the path to the single script file or to the directory for multi-file snippets 42 | Path string 43 | } 44 | 45 | type FolderSnippetActionType string 46 | 47 | const ( 48 | FolderSnippetActionMergeJSON FolderSnippetActionType = "mergeJSON" // merge JSON file from snippet with target JSON file 49 | FolderSnippetActionCopyAndRun FolderSnippetActionType = "copyAndRun" // COPY and RUN script from snippet in the Dockerfile (as with single-file snippet) 50 | FolderSnippetActionDockerfileSnippet FolderSnippetActionType = "dockerfileSnippet" // snippet to include as-is in the Dockerfile 51 | ) 52 | 53 | type FolderSnippetAction struct { 54 | Type FolderSnippetActionType `json:"type"` 55 | SourcePath string `json:"source"` // for mergeJSON this is snippet-relative path to JSON. for copyAndRun this is the script filename 56 | TargetPath string `json:"target"` // for mergeJSON this is project-relative path to JSON 57 | Content string `json:"content"` // for dockerfileSnippet this is the content to include 58 | ContentPath string `json:"contentPath"` // for dockerfileSnippet this is the path to content to include 59 | } 60 | 61 | // FolderSnippet maps to the content of the snippet.json file for folder-based snippets 62 | type FolderSnippet struct { 63 | Actions []FolderSnippetAction `json:"actions"` 64 | } 65 | 66 | // GetSnippetByName returns the template with the specified name or nil if not found 67 | func GetSnippetByName(name string) (*DevcontainerSnippet, error) { 68 | // TODO - could possibly make this quicker by searching using the name rather than listing all and filtering 69 | snippets, err := GetSnippets() 70 | if err != nil { 71 | return nil, err 72 | } 73 | for _, snippet := range snippets { 74 | if snippet.Name == name { 75 | return &snippet, nil 76 | } 77 | } 78 | return nil, nil 79 | } 80 | 81 | // GetSnippets returns a list of discovered templates 82 | func GetSnippets() ([]DevcontainerSnippet, error) { 83 | 84 | folders := config.GetSnippetFolders() 85 | if len(folders) == 0 { 86 | return []DevcontainerSnippet{}, &errors.StatusError{Message: "No snippet folders configured - see https://github.com/stuartleeks/devcontainer-cli/#working-with-devcontainer-snippets"} 87 | } 88 | 89 | snippets, err := getSnippetsFromFolders(folders) 90 | if err != nil { 91 | return []DevcontainerSnippet{}, err 92 | } 93 | return snippets, nil 94 | } 95 | 96 | func getSnippetsFromFolders(folders []string) ([]DevcontainerSnippet, error) { 97 | snippets := []DevcontainerSnippet{} 98 | snippetNames := map[string]bool{} 99 | for _, folder := range folders { 100 | folder := os.ExpandEnv(folder) 101 | newSnippets, err := getSnippetsFromFolder(folder) 102 | if err != nil { 103 | return []DevcontainerSnippet{}, err 104 | } 105 | for _, snippet := range newSnippets { 106 | if !snippetNames[snippet.Name] { 107 | snippetNames[snippet.Name] = true 108 | snippets = append(snippets, snippet) 109 | } 110 | } 111 | } 112 | sort.Slice(snippets, func(i int, j int) bool { return snippets[i].Name < snippets[j].Name }) 113 | return snippets, nil 114 | } 115 | 116 | func getSnippetsFromFolder(folder string) ([]DevcontainerSnippet, error) { 117 | c, err := ioutil.ReadDir(folder) 118 | 119 | if err != nil { 120 | return []DevcontainerSnippet{}, fmt.Errorf("Error reading snippet definitions: %s\n", err) 121 | } 122 | 123 | snippets := []DevcontainerSnippet{} 124 | for _, entry := range c { 125 | if strings.HasPrefix(entry.Name(), ".") || strings.HasPrefix(entry.Name(), "_") { 126 | // ignore files/directories starting with "_" or "." 127 | continue 128 | } 129 | if entry.IsDir() { 130 | // TODO! 131 | snippetJSONPath := filepath.Join(folder, entry.Name(), "snippet.json") 132 | snippetJSONInfo, err := os.Stat(snippetJSONPath) 133 | if err != nil || snippetJSONInfo.IsDir() { 134 | continue 135 | } 136 | snippet := DevcontainerSnippet{ 137 | Name: entry.Name(), 138 | Type: DevcontainerSnippetTypeFolder, 139 | Path: filepath.Join(folder, entry.Name()), 140 | } 141 | snippets = append(snippets, snippet) 142 | } else { 143 | if strings.HasSuffix(entry.Name(), ".sh") { 144 | snippet := DevcontainerSnippet{ 145 | Name: strings.TrimSuffix(entry.Name(), ".sh"), 146 | Type: DevcontainerSnippetTypeSingleFile, 147 | Path: filepath.Join(folder, entry.Name()), 148 | } 149 | snippets = append(snippets, snippet) 150 | } 151 | } 152 | } 153 | return snippets, nil 154 | } 155 | 156 | func AddSnippetToDevcontainer(projectFolder string, snippetName string) error { 157 | snippet, err := GetSnippetByName(snippetName) 158 | if err != nil { 159 | return err 160 | } 161 | if snippet == nil { 162 | return fmt.Errorf("Snippet '%s' not found\n", snippetName) 163 | } 164 | return addSnippetToDevcontainer(projectFolder, snippet) 165 | } 166 | func addSnippetToDevcontainer(projectFolder string, snippet *DevcontainerSnippet) error { 167 | switch snippet.Type { 168 | case DevcontainerSnippetTypeSingleFile: 169 | return addSingleFileSnippetToDevContainer(projectFolder, snippet) 170 | case DevcontainerSnippetTypeFolder: 171 | return addFolderSnippetToDevContainer(projectFolder, snippet) 172 | default: 173 | return fmt.Errorf("Unhandled snippet type: %q", snippet.Type) 174 | } 175 | } 176 | 177 | func addSingleFileSnippetToDevContainer(projectFolder string, snippet *DevcontainerSnippet) error { 178 | 179 | if snippet.Type != DevcontainerSnippetTypeSingleFile { 180 | return fmt.Errorf("Expected single file snippet") 181 | } 182 | snippetBasePath, scriptFilename := filepath.Split(snippet.Path) 183 | 184 | scriptFolderPath := filepath.Join(projectFolder, ".devcontainer", "scripts") 185 | err := copyAndRunScriptFile(projectFolder, snippet, snippetBasePath, scriptFolderPath, scriptFilename) 186 | return err 187 | } 188 | 189 | func addFolderSnippetToDevContainer(projectFolder string, snippet *DevcontainerSnippet) error { 190 | if snippet.Type != DevcontainerSnippetTypeFolder { 191 | return fmt.Errorf("Expected folder snippet") 192 | } 193 | 194 | snippetJSONPath := filepath.Join(snippet.Path, "snippet.json") 195 | buf, err := ioutil.ReadFile(snippetJSONPath) 196 | if err != nil { 197 | return err 198 | } 199 | var snippetJSON FolderSnippet 200 | err = json.Unmarshal(buf, &snippetJSON) 201 | if err != nil { 202 | return err 203 | } 204 | 205 | for _, action := range snippetJSON.Actions { 206 | switch action.Type { 207 | case FolderSnippetActionMergeJSON: 208 | if action.SourcePath == "" { 209 | return fmt.Errorf("source must be set for %s actions", action.Type) 210 | } 211 | if action.TargetPath == "" { 212 | return fmt.Errorf("target must be set for %s actions", action.Type) 213 | } 214 | err = mergeJSON(projectFolder, snippet, action.SourcePath, action.TargetPath) 215 | if err != nil { 216 | return err 217 | } 218 | case FolderSnippetActionCopyAndRun: 219 | if action.SourcePath == "" { 220 | return fmt.Errorf("source must be set for %s actions", action.Type) 221 | } 222 | targetPath := filepath.Join(projectFolder, ".devcontainer", "scripts") 223 | sourceParent, sourceFileName := filepath.Split(action.SourcePath) 224 | sourceBasePath := filepath.Join(snippet.Path, sourceParent) 225 | err = copyAndRunScriptFile(projectFolder, snippet, sourceBasePath, targetPath, sourceFileName) 226 | if err != nil { 227 | return err 228 | } 229 | case FolderSnippetActionDockerfileSnippet: 230 | var content string 231 | if action.Content != "" { 232 | if action.ContentPath != "" { 233 | return fmt.Errorf("can only set one of content and contentPath") 234 | } 235 | content = action.Content + "\n" 236 | } else if action.ContentPath != "" { 237 | buf, err = ioutil.ReadFile(filepath.Join(snippet.Path, action.ContentPath)) 238 | if err != nil { 239 | return err 240 | } 241 | content = string(buf) 242 | } else { 243 | return fmt.Errorf("one of content and contentPath must be set for %s actions", action.Type) 244 | } 245 | dockerfileFilename := filepath.Join(projectFolder, ".devcontainer", "Dockerfile") 246 | err = insertDockerfileSnippet(projectFolder, dockerfileFilename, content) 247 | if err != nil { 248 | return err 249 | } 250 | default: 251 | return fmt.Errorf("unhandled action type: %q", action.Type) 252 | } 253 | } 254 | 255 | return nil 256 | } 257 | 258 | func copyAndRunScriptFile(projectFolder string, snippet *DevcontainerSnippet, snippetBasePath string, targetPath, scriptFilename string) error { 259 | if err := os.MkdirAll(targetPath, 0755); err != nil { 260 | return err 261 | } 262 | if err := ioutil2.CopyFile(filepath.Join(snippetBasePath, scriptFilename), filepath.Join(targetPath, scriptFilename), 0755); err != nil { 263 | return err 264 | } 265 | 266 | snippetContent := fmt.Sprintf(`# %[1]s 267 | COPY scripts/%[2]s /tmp/ 268 | RUN /tmp/%[2]s 269 | `, snippet.Name, scriptFilename) 270 | dockerfileFilename := filepath.Join(projectFolder, ".devcontainer", "Dockerfile") 271 | 272 | err := insertDockerfileSnippet(projectFolder, dockerfileFilename, snippetContent) 273 | return err 274 | } 275 | 276 | func insertDockerfileSnippet(projectFolder string, dockerfileFilename string, snippetContent string) error { 277 | 278 | buf, err := ioutil.ReadFile(dockerfileFilename) 279 | if err != nil { 280 | return fmt.Errorf("Error reading Dockerfile: %s", err) 281 | } 282 | 283 | dockerfileContent := string(buf) 284 | dockerFileLines := strings.Split(dockerfileContent, "\n") 285 | addSeparator := false 286 | addedSnippetContent := false 287 | var newContent strings.Builder 288 | for _, line := range dockerFileLines { 289 | if addSeparator { 290 | if _, err = newContent.WriteString("\n"); err != nil { 291 | return err 292 | } 293 | } 294 | addSeparator = true 295 | 296 | if strings.Contains(line, "__DEVCONTAINER_SNIPPET_INSERT__") { 297 | if _, err = newContent.WriteString(snippetContent); err != nil { 298 | return err 299 | } 300 | if _, err = newContent.WriteString("\n"); err != nil { 301 | return err 302 | } 303 | line += "\n" 304 | addedSnippetContent = true 305 | addSeparator = false // avoid extra separator 306 | } 307 | 308 | if _, err = newContent.WriteString(line); err != nil { 309 | return err 310 | } 311 | } 312 | 313 | if !addedSnippetContent { 314 | if _, err = newContent.WriteString("\n"); err != nil { 315 | return err 316 | } 317 | if _, err = newContent.WriteString(snippetContent); err != nil { 318 | return err 319 | } 320 | } 321 | 322 | content := newContent.String() 323 | // TODO - decide whether to support .devcontainer.json or just remove snippet support 324 | values, err := getSubstitutionValuesFromFile(filepath.Join(projectFolder, ".devcontainer/devcontainer.json")) 325 | if err != nil { 326 | return fmt.Errorf("failed to get dev container values: %s", err) 327 | } 328 | content = performSubstitutionString(values, content) 329 | 330 | err = ioutil.WriteFile(dockerfileFilename, []byte(content), 0) 331 | 332 | return err 333 | 334 | } 335 | func mergeJSON(projectFolder string, snippet *DevcontainerSnippet, relativeMergePath string, relativeBasePath string) error { 336 | mergePath := filepath.Join(snippet.Path, relativeMergePath) 337 | _, err := os.Stat(mergePath) 338 | if err != nil { 339 | return err 340 | } 341 | basePath := filepath.Join(projectFolder, relativeBasePath) 342 | baseDocument, err := loadJSONDocument(basePath) 343 | if err != nil { 344 | return err 345 | } 346 | 347 | mergeDocument, err := loadJSONDocument(mergePath) 348 | if err != nil { 349 | return err 350 | } 351 | 352 | resultDocument, err := dora_merge.MergeJSON(*baseDocument, *mergeDocument) 353 | if err != nil { 354 | return err 355 | } 356 | 357 | resultJSON, err := dora_ast.WriteJSONString(resultDocument) 358 | if err != nil { 359 | return err 360 | } 361 | 362 | // TODO - decide whether to support .devcontainer.json or just remove snippet support 363 | values, err := getSubstitutionValuesFromFile(filepath.Join(projectFolder, ".devcontainer/devcontainer.json")) 364 | if err != nil { 365 | return fmt.Errorf("failed to get dev container values: %s", err) 366 | } 367 | resultJSON = performSubstitutionString(values, resultJSON) 368 | 369 | err = ioutil.WriteFile(basePath, []byte(resultJSON), 0666) 370 | if err != nil { 371 | return fmt.Errorf("failed to write file: %s", err) 372 | } 373 | 374 | return nil 375 | } 376 | 377 | func loadJSONDocument(path string) (*dora_ast.RootNode, error) { 378 | 379 | buf, err := ioutil.ReadFile(path) 380 | if err != nil { 381 | return nil, err 382 | } 383 | l := dora_lexer.New(string(buf)) 384 | p := dora_parser.New(l) 385 | baseDocument, err := p.ParseJSON() 386 | if err != nil { 387 | return nil, err 388 | } 389 | return &baseDocument, nil 390 | } 391 | 392 | func getSubstitutionValuesFromFile(devContainerJsonPath string) (*SubstitutionValues, error) { 393 | // This doesn't use standard `json` pkg as devcontainer.json permits comments (and the default templates include them!) 394 | 395 | buf, err := ioutil.ReadFile(devContainerJsonPath) 396 | if err != nil { 397 | return nil, err 398 | } 399 | 400 | c, err := dora.NewFromBytes(buf) 401 | if err != nil { 402 | return nil, err 403 | } 404 | 405 | name, err := c.GetString("$.name") 406 | if err != nil { 407 | name = "" 408 | } 409 | userName, err := c.GetString("$.remoteUser") 410 | if err != nil { 411 | userName = "root" 412 | } 413 | homeFolder := "/home/" + userName 414 | if userName == "root" { 415 | homeFolder = "/root" 416 | } 417 | 418 | return &SubstitutionValues{ 419 | Name: name, 420 | UserName: userName, 421 | HomeFolder: homeFolder, 422 | }, nil 423 | } 424 | 425 | func performSubstitutionFile(substitutionValues *SubstitutionValues, filename string) error { 426 | buf, err := ioutil.ReadFile(filename) 427 | if err != nil { 428 | return err 429 | } 430 | content := string(buf) 431 | content = performSubstitutionString(substitutionValues, content) 432 | err = ioutil.WriteFile(filename, []byte(content), 0) 433 | return err 434 | } 435 | 436 | func performSubstitutionString(substitutionValues *SubstitutionValues, content string) string { 437 | // replace __DEVCONTAINER_NAME__ with name etc 438 | content = strings.ReplaceAll(content, "__DEVCONTAINER_NAME__", substitutionValues.Name) 439 | content = strings.ReplaceAll(content, "__DEVCONTAINER_USER_NAME__", substitutionValues.UserName) 440 | content = strings.ReplaceAll(content, "__DEVCONTAINER_HOME__", substitutionValues.HomeFolder) 441 | return content 442 | } 443 | -------------------------------------------------------------------------------- /internal/pkg/devcontainers/dockerutils.go: -------------------------------------------------------------------------------- 1 | package devcontainers 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "encoding/json" 7 | "fmt" 8 | "os" 9 | "os/exec" 10 | "path" 11 | "path/filepath" 12 | "sort" 13 | "strings" 14 | 15 | "github.com/stuartleeks/devcontainer-cli/internal/pkg/terminal" 16 | "github.com/stuartleeks/devcontainer-cli/internal/pkg/wsl" 17 | ) 18 | 19 | // Devcontainer names are a derived property. 20 | // For Dockerfile-based devcontainers this is derived from the image name 21 | // E.g. vsc-vscode-remote-test-dockerfile-16020a1c793898f64e7c9cff44437369 => vscode-remote-test-dockerfile 22 | // For dockercompose-based devcontainers this is derived from the com.docker.compose.project 23 | // and com.docker.compose.service labels with a slash separator. E.g. vscode-remote-test-dockercompose_devcontainer\web 24 | 25 | // DevcontainerInfo holds details about a devcontainer 26 | type DevcontainerInfo struct { 27 | ContainerID string `json:"containerID"` 28 | ContainerName string `json:"containerName"` 29 | DevcontainerName string `json:"devcontainerName"` 30 | LocalFolderPath string `json:"localFolderPath"` 31 | } 32 | 33 | const ( 34 | listPartID int = 0 35 | listPartLocalFolder int = 1 36 | listPartComposeProject int = 2 37 | listPartComposeService int = 3 38 | listPartComposeContainerNumber int = 4 39 | listPartContainerName int = 5 40 | ) 41 | 42 | var _ = listPartComposeContainerNumber 43 | 44 | // ListDevcontainers returns a list of devcontainers 45 | func ListDevcontainers() ([]DevcontainerInfo, error) { 46 | cmd := exec.Command("docker", "ps", "--format", "{{.ID}}|{{.Label \"devcontainer.local_folder\"}}|{{.Label \"com.docker.compose.project\"}}|{{.Label \"com.docker.compose.service\"}}|{{.Label \"com.docker.compose.container-number\"}}|{{.Names}}") 47 | 48 | output, err := cmd.Output() 49 | if err != nil { 50 | return []DevcontainerInfo{}, fmt.Errorf("Failed to read docker stdout: %v", err) 51 | } 52 | 53 | reader := bytes.NewReader(output) 54 | scanner := bufio.NewScanner(reader) 55 | if scanner == nil { 56 | return []DevcontainerInfo{}, fmt.Errorf("Failed to parse stdout") 57 | } 58 | devcontainers := []DevcontainerInfo{} 59 | for scanner.Scan() { 60 | line := scanner.Text() 61 | parts := strings.Split(line, "|") 62 | localPath := parts[listPartLocalFolder] 63 | if localPath == "" { 64 | // not a dev container 65 | continue 66 | } 67 | if wsl.HasWslPathPrefix(localPath) && wsl.IsWsl() { 68 | localPath, err = wsl.ConvertWindowsPathToWslPath(localPath) 69 | if err != nil { 70 | return []DevcontainerInfo{}, fmt.Errorf("error converting path: %s", err) 71 | } 72 | } 73 | name := parts[listPartLocalFolder] 74 | if name == "" { 75 | // No local folder => use dockercompose parts 76 | name = fmt.Sprintf("%s/%s", parts[listPartComposeProject], parts[listPartComposeService]) 77 | } else { 78 | // get the last path segment for the name 79 | if index := strings.LastIndexAny(name, "/\\"); index >= 0 { 80 | name = name[index+1:] 81 | } 82 | } 83 | devcontainer := DevcontainerInfo{ 84 | ContainerID: parts[listPartID], 85 | ContainerName: parts[listPartContainerName], 86 | LocalFolderPath: localPath, 87 | DevcontainerName: name, 88 | } 89 | devcontainers = append(devcontainers, devcontainer) 90 | } 91 | return devcontainers, nil 92 | } 93 | 94 | // GetLocalFolderFromDevContainer looks up the local (host) folder name from the container labels 95 | func GetLocalFolderFromDevContainer(containerIDOrName string) (string, error) { 96 | 97 | cmd := exec.Command("docker", "inspect", containerIDOrName, "--format", "{{ index .Config.Labels \"devcontainer.local_folder\" }}") 98 | 99 | output, err := cmd.Output() 100 | if err != nil { 101 | return "", fmt.Errorf("Failed to read docker stdout: %v", err) 102 | } 103 | 104 | return strings.TrimSpace(string(output)), nil 105 | } 106 | 107 | // DockerMount represents mount info from Docker output 108 | type DockerMount struct { 109 | Source string `json:"Source"` 110 | Destination string `json:"Destination"` 111 | } 112 | 113 | // SourceInfo holds properties about the source mounted in a dev container 114 | type SourceInfo struct { 115 | DevcontainerFolder string 116 | DockerMount DockerMount 117 | } 118 | 119 | // getMountFolderFromFolder walks up the path hierarchy checking for a git repo. If found that is returned, if not the original folder is used 120 | func getMountFolderFromFolder(folder string) (string, error) { 121 | 122 | for currentFolder := folder; currentFolder != "" && currentFolder != "/"; currentFolder = path.Dir(currentFolder) { 123 | info, err := os.Stat(path.Join(currentFolder, ".git")) 124 | if os.IsNotExist(err) { 125 | continue // walk up the hierarchy 126 | } 127 | if err != nil { 128 | // other error - e.g. permissions issue 129 | // give up the search 130 | break 131 | } 132 | if info.IsDir() { 133 | // We've found a .git folder - use it 134 | return currentFolder, nil 135 | } 136 | 137 | } 138 | // didn't find anything - use original folder 139 | return folder, nil 140 | } 141 | 142 | // GetSourceInfoFromDevContainer inspects the specified container and returns the SourceInfo 143 | func GetSourceInfoFromDevContainer(containerIDOrName string) (SourceInfo, error) { 144 | localPath, err := GetLocalFolderFromDevContainer(containerIDOrName) 145 | if err != nil { 146 | return SourceInfo{}, err 147 | } 148 | 149 | if wsl.HasWslPathPrefix(localPath) && wsl.IsWsl() { 150 | localPath, err = wsl.ConvertWindowsPathToWslPath(localPath) 151 | if err != nil { 152 | return SourceInfo{}, fmt.Errorf("error converting path: %s", err) 153 | } 154 | } 155 | 156 | mountFolder, err := getMountFolderFromFolder(localPath) 157 | if err != nil { 158 | return SourceInfo{}, fmt.Errorf("search for mount folder failed: %s", err) 159 | } 160 | 161 | cmd := exec.Command("docker", "inspect", containerIDOrName, "--format", fmt.Sprintf("{{ range .Mounts }}{{if eq .Source \"%s\"}}{{json .}}{{end}}{{end}}", mountFolder)) 162 | 163 | output, err := cmd.Output() 164 | if err != nil { 165 | return SourceInfo{}, fmt.Errorf("Failed to read docker stdout: %v", err) 166 | } 167 | 168 | var mount DockerMount 169 | err = json.Unmarshal(output, &mount) 170 | if err != nil { 171 | return SourceInfo{}, fmt.Errorf("failed to parse JSON getting mount folder for container %q (path=%q): %s", containerIDOrName, mountFolder, err) 172 | } 173 | 174 | return SourceInfo{ 175 | DevcontainerFolder: localPath, 176 | DockerMount: mount, 177 | }, nil 178 | } 179 | 180 | type byLocalPathLength []DevcontainerInfo 181 | 182 | func (s byLocalPathLength) Len() int { 183 | return len(s) 184 | } 185 | func (s byLocalPathLength) Swap(i, j int) { 186 | s[i], s[j] = s[j], s[i] 187 | } 188 | func (s byLocalPathLength) Less(i, j int) bool { 189 | return len(s[i].LocalFolderPath) < len(s[j].LocalFolderPath) 190 | } 191 | 192 | // GetClosestPathMatchForPath returns the dev container with the closes match to the specified path 193 | func GetClosestPathMatchForPath(devContainers []DevcontainerInfo, devcontainerPath string) (DevcontainerInfo, error) { 194 | if devcontainerPath == "" { 195 | devcontainerPath = "." 196 | } 197 | absPath, err := filepath.Abs(devcontainerPath) 198 | if err != nil { 199 | return DevcontainerInfo{}, fmt.Errorf("Error handling path %q: %s", devcontainerPath, err) 200 | } 201 | 202 | matchingPaths := byLocalPathLength{} 203 | for _, devcontainer := range devContainers { 204 | // Treat as match if the specified path is within the devcontainer path 205 | testPath := devcontainer.LocalFolderPath 206 | if wsl.IsWsl() && wsl.HasWslPathPrefix(testPath) { 207 | testPath, err = wsl.ConvertWindowsPathToWslPath(testPath) 208 | fmt.Println("Converted to..") 209 | if err != nil { 210 | return DevcontainerInfo{}, fmt.Errorf("Error converting path from dev container list (%q): %s", testPath, err) 211 | } 212 | } 213 | if strings.HasPrefix(absPath, testPath) { 214 | matchingPaths = append(matchingPaths, devcontainer) 215 | } 216 | } 217 | if len(matchingPaths) == 0 { 218 | return DevcontainerInfo{}, fmt.Errorf("Could not find running container for path %q", devcontainerPath) 219 | } 220 | 221 | // return longest prefix match 222 | sort.Sort(matchingPaths) 223 | return matchingPaths[len(matchingPaths)-1], nil 224 | } 225 | 226 | func ExecInDevContainer(containerID string, workDir string, args []string) error { 227 | 228 | statusWriter := &terminal.UpdatingStatusWriter{} 229 | 230 | sourceInfo, err := GetSourceInfoFromDevContainer(containerID) 231 | if err != nil { 232 | return fmt.Errorf("failed to get source mount: %s", err) 233 | } 234 | localPath := sourceInfo.DevcontainerFolder 235 | 236 | statusWriter.Printf("Getting user name") 237 | devcontainerJSONPath, err := getDevContainerJsonPath(localPath) 238 | if err != nil { 239 | return err 240 | } 241 | userName, err := GetDevContainerUserName(devcontainerJSONPath) 242 | if err != nil { 243 | return err 244 | } 245 | if userName == "" { 246 | userName, err = getUserNameFromRunningContainer(containerID) 247 | if err != nil { 248 | return err 249 | } 250 | } 251 | 252 | statusWriter.Printf("Checking for SSH_AUTH_SOCK") 253 | sshAuthSockValue, err := getSshAuthSockValue(containerID) 254 | if err != nil { 255 | // output error and continue without SSH_AUTH_SOCK value 256 | sshAuthSockValue = "" 257 | fmt.Printf("Warning: Failed to get SSH_AUTH_SOCK value: %s\n", err) 258 | fmt.Println("Continuing without setting SSH_AUTH_SOCK...") 259 | } 260 | 261 | statusWriter.Printf("Getting container PATH") 262 | containerPath, err := getContainerEnvVar(containerID, "PATH") 263 | vscodeServerPath := "" 264 | if err == nil { 265 | // Got the PATH 266 | statusWriter.Printf("Getting code server path") 267 | vscodeServerPath, err = getVscodeServerPath(containerID, userName) 268 | if err == nil { 269 | // Got the VS Code server location - add bin subfolder to PATH 270 | containerPath = strings.TrimSpace(containerPath) 271 | containerPath = fmt.Sprintf("%s/bin:%s", vscodeServerPath, containerPath) 272 | } else { 273 | // output error and continue without adding to PATH value 274 | fmt.Printf("Warning: Failed to get VS Code server location: %s\n", err) 275 | fmt.Println("Continuing without adding VS Code Server to PATH...") 276 | } 277 | } else { 278 | // output error and continue without adding to PATH value 279 | containerPath = "" 280 | fmt.Printf("Warning: Failed to get PATH value for container: %s\n", err) 281 | fmt.Println("Continuing without overriding PATH...") 282 | } 283 | 284 | browser := "" 285 | if vscodeServerPath == "" { 286 | fmt.Printf("Warning: VS Code Server location not found. Continuing without setting BROWSER...") 287 | } else { 288 | browser = fmt.Sprintf("%s/helpers/browser.sh", vscodeServerPath) 289 | } 290 | 291 | statusWriter.Printf("Getting VSCODE_IPC_HOOK_CLI") 292 | vscodeIpcSock, err := getVscodeIpcSock(containerID) 293 | if err != nil { 294 | vscodeIpcSock = "" 295 | fmt.Printf("Warning; Failed to get VSCODE_IPC_HOOK_CLI: %s\n", err) 296 | fmt.Println("Continuing without setting VSCODE_IPC_HOOK_CLI...") 297 | } 298 | statusWriter.Printf("Getting REMOTE_CONTAINERS_IPC") 299 | remoteContainersIpcSock, err := getRemoteContainersIpcSock(containerID) 300 | if err != nil { 301 | remoteContainersIpcSock = "" 302 | fmt.Printf("Warning; Failed to get REMOTE_CONTAINERS_IPC: %s\n", err) 303 | fmt.Println("Continuing without setting REMOTE_CONTAINERS_IPC...") 304 | } 305 | statusWriter.Printf("Getting container User ID") 306 | vscodeGitIpcSock := "" 307 | userID, err := getContainerUserID(containerID, userName) 308 | if err == nil { 309 | statusWriter.Printf("Getting VSCODE_GIT_IPC_HANDLE") 310 | vscodeGitIpcSock, err = getGitIpcSock(containerID, userID) 311 | if err != nil { 312 | fmt.Printf("Warning; Failed to get VSCODE_GIT_IPC_HANDLE: %s\n", err) 313 | fmt.Println("Continuing without setting VSCODE_GIT_IPC_HANDLE...") 314 | } 315 | } else { 316 | fmt.Printf("Warning; Failed to get container User ID: %s\n", err) 317 | fmt.Println("Continuing without setting VSCODE_GIT_IPC_HANDLE...") 318 | } 319 | 320 | mountPath := sourceInfo.DockerMount.Destination 321 | if workDir == "" { 322 | workDir = sourceInfo.DevcontainerFolder 323 | } else if !filepath.IsAbs(workDir) { 324 | 325 | // Convert to absolute (local) path 326 | // This takes into account current directory (potentially within the dev container path) 327 | // We'll convert local to container path below 328 | workDir, err = filepath.Abs(workDir) 329 | if err != nil { 330 | return err 331 | } 332 | } 333 | 334 | statusWriter.Printf("Test container path") 335 | containerPathExists, err := testContainerPathExists(containerID, workDir) 336 | if err != nil { 337 | return fmt.Errorf("error checking container path: %s", err) 338 | } 339 | if !containerPathExists { 340 | // path not found - try converting from local path 341 | // ? Should we check here that the workDir has mountPath as a prefix? 342 | devContainerRelativePath, err := filepath.Rel(sourceInfo.DockerMount.Source, workDir) 343 | if err != nil { 344 | return fmt.Errorf("error getting path relative to mount dir: %s", err) 345 | } 346 | workDir = filepath.Join(mountPath, devContainerRelativePath) 347 | } 348 | 349 | statusWriter.Printf("Starting exec session\n") // newline to put container shell at start of line 350 | dockerArgs := []string{"exec", "-it", "--workdir", workDir} 351 | if userName != "" { 352 | dockerArgs = append(dockerArgs, "--user", userName) 353 | } 354 | if sshAuthSockValue != "" { 355 | dockerArgs = append(dockerArgs, "--env", "SSH_AUTH_SOCK="+sshAuthSockValue) 356 | } 357 | if containerPath != "" { 358 | dockerArgs = append(dockerArgs, "--env", "PATH="+containerPath) 359 | } 360 | if vscodeIpcSock != "" { 361 | dockerArgs = append(dockerArgs, "--env", "VSCODE_IPC_HOOK_CLI="+vscodeIpcSock) 362 | } 363 | if remoteContainersIpcSock != "" { 364 | dockerArgs = append(dockerArgs, "--env", "REMOTE_CONTAINERS_IPC="+remoteContainersIpcSock) 365 | } 366 | if vscodeGitIpcSock != "" { 367 | dockerArgs = append(dockerArgs, "--env", "VSCODE_GIT_IPC_HANDLE="+vscodeGitIpcSock) 368 | } 369 | if browser != "" { 370 | dockerArgs = append(dockerArgs, "--env", "BROWSER="+browser) 371 | } 372 | dockerArgs = append(dockerArgs, containerID) 373 | dockerArgs = append(dockerArgs, args...) 374 | 375 | dockerCmd := exec.Command("docker", dockerArgs...) 376 | dockerCmd.Stdin = os.Stdin 377 | dockerCmd.Stdout = os.Stdout 378 | 379 | err = dockerCmd.Start() 380 | if err != nil { 381 | return fmt.Errorf("Exec: start error: %s", err) 382 | } 383 | err = dockerCmd.Wait() 384 | if err != nil { 385 | return fmt.Errorf("Exec: wait error: %s", err) 386 | } 387 | return nil 388 | } 389 | 390 | // getSshAuthSockValue returns the value to use for the SSH_AUTH_SOCK env var when exec'ing into the container, or empty string if no value is found 391 | func getSshAuthSockValue(containerID string) (string, error) { 392 | 393 | // If the host has SSH_AUTH_SOCK set then VS Code spins up forwarding for key requests 394 | // inside the dev container to the SSH agent on the host. 395 | 396 | hostSshAuthSockValue := os.Getenv("SSH_AUTH_SOCK") 397 | if hostSshAuthSockValue == "" { 398 | // Nothing to see, move along 399 | return "", nil 400 | } 401 | 402 | // Host has SSH_AUTH_SOCK set, so expecting the dev container to have forwarding set up 403 | // Find the latest /tmp/vscode-ssh-auth-<...>.sock 404 | 405 | return getLatestFileMatch(containerID, "", "\"${TMPDIR:-/tmp}\"/vscode-ssh-auth-*") 406 | } 407 | 408 | func getVscodeServerPath(containerID string, userName string) (string, error) { 409 | path, err := getLatestFileMatch(containerID, userName, "${HOME}/.vscode-server/bin/*") 410 | if err == nil { 411 | return path, err 412 | } 413 | path, err = getLatestFileMatch(containerID, "", "/vscode/vscode-server/bin/linux-x64/*") 414 | if err == nil { 415 | return path, err 416 | } 417 | return getLatestFileMatch(containerID, "", "/vscode/vscode-server/bin/x64/*") 418 | } 419 | func getVscodeIpcSock(containerID string) (string, error) { 420 | return getLatestFileMatch(containerID, "", "\"${TMPDIR:-/tmp}\"/vscode-ipc-*") 421 | } 422 | func getRemoteContainersIpcSock(containerID string) (string, error) { 423 | return getLatestFileMatch(containerID, "", "\"${TMPDIR:-/tmp}\"/vscode-remote-containers-ipc-*") 424 | } 425 | func getGitIpcSock(containerID string, userID string) (string, error) { 426 | return getLatestFileMatch(containerID, "", fmt.Sprintf("\"${TMPDIR:-/tmp}\"/user/%s/vscode-git-*", userID)) 427 | } 428 | 429 | // getLatestFileMatch lists files matching `pattern` in the container and returns the latest filename 430 | func getLatestFileMatch(containerID string, userName string, pattern string) (string, error) { 431 | 432 | dockerArgs := []string{"exec"} 433 | if userName != "" { 434 | dockerArgs = append(dockerArgs, "--user", userName) 435 | } 436 | dockerArgs = append(dockerArgs, containerID, "bash", "-c", fmt.Sprintf("ls -t -d -1 %s", pattern)) 437 | dockerCmd := exec.Command("docker", dockerArgs...) 438 | buf, err := dockerCmd.CombinedOutput() 439 | if err != nil { 440 | errMessage := string(buf) 441 | return "", fmt.Errorf("Docker exec error: %s (%s)", err, strings.TrimSpace(errMessage)) 442 | } 443 | 444 | output := string(buf) 445 | lines := strings.Split(output, "\n") 446 | if len(lines) <= 0 { 447 | return "", nil 448 | } 449 | return strings.TrimSpace(lines[0]), nil 450 | } 451 | 452 | func getContainerEnvVar(containerID string, varName string) (string, error) { 453 | 454 | // could inspect the docker container as an alternative approach 455 | dockerArgs := []string{"exec", containerID, "bash", "-c", fmt.Sprintf("echo $%s", varName)} 456 | dockerCmd := exec.Command("docker", dockerArgs...) 457 | buf, err := dockerCmd.CombinedOutput() 458 | if err != nil { 459 | errMessage := string(buf) 460 | return "", fmt.Errorf("Docker exec error: %s (%s)", err, strings.TrimSpace(errMessage)) 461 | } 462 | 463 | return string(buf), nil 464 | } 465 | 466 | // getContainerUserID gets the UID of the specified user in the container 467 | func getContainerUserID(containerID string, userName string) (string, error) { 468 | 469 | dockerArgs := []string{"exec", containerID, "bash", "-c", fmt.Sprintf("id -u %s", userName)} 470 | dockerCmd := exec.Command("docker", dockerArgs...) 471 | buf, err := dockerCmd.CombinedOutput() 472 | if err != nil { 473 | errMessage := string(buf) 474 | return "", fmt.Errorf("Docker exec error: %s (%s)", err, strings.TrimSpace(errMessage)) 475 | } 476 | 477 | output := string(buf) 478 | lines := strings.Split(output, "\n") 479 | if len(lines) <= 0 { 480 | return "", nil 481 | } 482 | return strings.TrimSpace(lines[0]), nil 483 | } 484 | 485 | func testContainerPathExists(containerID string, path string) (bool, error) { 486 | dockerArgs := []string{"exec", containerID, "bash", "-c", fmt.Sprintf("[[ -d %s ]]; echo $?", path)} 487 | dockerCmd := exec.Command("docker", dockerArgs...) 488 | buf, err := dockerCmd.CombinedOutput() 489 | if err != nil { 490 | errMessage := string(buf) 491 | return false, fmt.Errorf("Docker exec error: %s (%s)", err, strings.TrimSpace(errMessage)) 492 | } 493 | 494 | response := strings.TrimSpace(string(buf)) 495 | return response == "0", nil 496 | } 497 | 498 | func getUserNameFromRunningContainer(containerID string) (string, error) { 499 | dockerArgs := []string{"inspect", containerID, "--format", "{{index .Config.Labels \"devcontainer.metadata\" }}"} 500 | dockerCmd := exec.Command("docker", dockerArgs...) 501 | buf, err := dockerCmd.CombinedOutput() 502 | if err != nil { 503 | errMessage := string(buf) 504 | return "", fmt.Errorf("Docker exec error: %s (%s)", err, strings.TrimSpace(errMessage)) 505 | } 506 | 507 | var metadata []interface{} 508 | err = json.Unmarshal(buf, &metadata) 509 | if err != nil { 510 | return "", nil 511 | } 512 | 513 | for _, value := range metadata { 514 | if valueMap, ok := value.(map[string]interface{}); ok { 515 | if userName, ok := valueMap["remoteUser"]; ok { 516 | return userName.(string), nil 517 | } 518 | } 519 | } 520 | return "", nil 521 | } 522 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 2 | github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= 3 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 4 | github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= 5 | github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= 6 | github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= 7 | github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= 8 | github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= 9 | github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= 10 | github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= 11 | github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= 12 | github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= 13 | github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= 14 | github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= 15 | github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= 16 | github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= 17 | github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= 18 | github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= 19 | github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 20 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 21 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 22 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 23 | github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= 24 | github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= 25 | github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= 26 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 27 | github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= 28 | github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= 29 | github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= 30 | github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= 31 | github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= 32 | github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= 33 | github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= 34 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 35 | github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 36 | github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 37 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 38 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 39 | github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs= 40 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 41 | github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= 42 | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 43 | github.com/google/go-github/v30 v30.1.0 h1:VLDx+UolQICEOKu2m4uAoMti1SxuEBAl7RSEG16L+Oo= 44 | github.com/google/go-github/v30 v30.1.0/go.mod h1:n8jBpHl45a/rlBUtRJMOG4GhNADUQFEufcolZ95JfU8= 45 | github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= 46 | github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= 47 | github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= 48 | github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= 49 | github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= 50 | github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= 51 | github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= 52 | github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 53 | github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= 54 | github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= 55 | github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf h1:WfD7VjIE6z8dIvMsI4/s+1qr5EL+zoIGev1BQj1eoJ8= 56 | github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf/go.mod h1:hyb9oH7vZsitZCiBt0ZvifOrB+qc8PS5IiilCIb87rg= 57 | github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= 58 | github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= 59 | github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= 60 | github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= 61 | github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= 62 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 63 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 64 | github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= 65 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 66 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 67 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 68 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 69 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 70 | github.com/kyoh86/richgo v0.3.10 h1:iSGvcjhtQN2IVrBDhPk0if0R/RMQnCN1E/9OyAW4UUs= 71 | github.com/kyoh86/richgo v0.3.10/go.mod h1:2Odx7Qx2iJxXEWmoXVWaNbUcBXhHY43qSgyK7dbTaII= 72 | github.com/kyoh86/richgo v0.3.12 h1:W66IRaaC1BWoCPIyI94t/PChWBQ38QFDRsF1nWu4904= 73 | github.com/kyoh86/richgo v0.3.12/go.mod h1:op8jQyhnxSZQelHDyupswwJGerEDB6zkKfzCnuqAwx4= 74 | github.com/kyoh86/xdg v1.2.0 h1:CERuT/ShdTDj+A2UaX3hQ3mOV369+Sj+wyn2nIRIIkI= 75 | github.com/kyoh86/xdg v1.2.0/go.mod h1:/mg8zwu1+qe76oTFUBnyS7rJzk7LLC0VGEzJyJ19DHs= 76 | github.com/magiconair/properties v1.8.0 h1:LLgXmsheXeRoUOBOjtwPQCWIYqM/LU1ayDtDePerRcY= 77 | github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= 78 | github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 79 | github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= 80 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 81 | github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= 82 | github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 83 | github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= 84 | github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 85 | github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= 86 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 87 | github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= 88 | github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= 89 | github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= 90 | github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= 91 | github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= 92 | github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= 93 | github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= 94 | github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= 95 | github.com/onsi/ginkgo v1.6.0 h1:Ix8l273rp3QzYgXSR+c8d1fTG7UPgYkOSELPhiY/YGw= 96 | github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 97 | github.com/onsi/gomega v1.4.2 h1:3mYCb7aPxS/RU7TI1y4rkEn1oKmPRjNJLNEXgw7MH2I= 98 | github.com/onsi/gomega v1.4.2/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= 99 | github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc= 100 | github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= 101 | github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 102 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 103 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 104 | github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= 105 | github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= 106 | github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= 107 | github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 108 | github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= 109 | github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= 110 | github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= 111 | github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= 112 | github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= 113 | github.com/rakyll/statik v0.1.7/go.mod h1:AlZONWzMtEnMs7W4e/1LURLiI49pIMmp6V9Unghqrcc= 114 | github.com/rhysd/go-github-selfupdate v1.2.2 h1:G+mNzkc1wEtpmM6sFS/Ghkeq+ad4Yp6EZEHyp//wGEo= 115 | github.com/rhysd/go-github-selfupdate v1.2.2/go.mod h1:khesvSyKcXDUxeySCedFh621iawCks0dS/QnHPcpCws= 116 | github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= 117 | github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 118 | github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= 119 | github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= 120 | github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= 121 | github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= 122 | github.com/spf13/afero v1.1.2 h1:m8/z1t7/fwjysjQRYbP0RD+bUIF/8tJwPdEZsI83ACI= 123 | github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= 124 | github.com/spf13/cast v1.3.0 h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8= 125 | github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= 126 | github.com/spf13/cobra v1.0.0 h1:6m/oheQuQ13N9ks4hubMG6BnvwOeaJrqSPLahSnczz8= 127 | github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE= 128 | github.com/spf13/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk= 129 | github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= 130 | github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= 131 | github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= 132 | github.com/spf13/viper v1.4.0 h1:yXHLWeravcrgGyFSyCgdYpXQ9dR9c/WED3pg1RhxqEU= 133 | github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= 134 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 135 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 136 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 137 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 138 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 139 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 140 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 141 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 142 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 143 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 144 | github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= 145 | github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 146 | github.com/stuartleeks/dora v0.1.5 h1:/t8/kPA2t1qMUI9W0cASGGScpgyNDQ5O+QOYud8+AlQ= 147 | github.com/stuartleeks/dora v0.1.5/go.mod h1:Xu0GCcuQv5e9Ta0U3PUiu/rgaXJnHn1+LTaQkCgYMhc= 148 | github.com/tcnksm/go-gitconfig v0.1.2 h1:iiDhRitByXAEyjgBqsKi9QU4o2TNtv9kPP3RgPgXBPw= 149 | github.com/tcnksm/go-gitconfig v0.1.2/go.mod h1:/8EhP4H7oJZdIPyT+/UIsG87kTzrzM4UsLGSItWYCpE= 150 | github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= 151 | github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= 152 | github.com/ulikunitz/xz v0.5.5 h1:pFrO0lVpTBXLpYw+pnLj6TbvHuyjXMfjGeCwSqCVwok= 153 | github.com/ulikunitz/xz v0.5.5/go.mod h1:2bypXElzHzzJZwzH67Y6wb67pO62Rzfn7BSiF4ABRW8= 154 | github.com/wacul/ptr v1.0.0 h1:FIKu08Wx0YUIf9MNsfF62OCmBSmz5A1Tk65zWhOIL/I= 155 | github.com/wacul/ptr v1.0.0/go.mod h1:BD0gjsZrCwtoR+yWDB9v2hQ8STlq9tT84qKfa+3txOc= 156 | github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= 157 | github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= 158 | go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= 159 | go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= 160 | go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= 161 | go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= 162 | golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 163 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M= 164 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 165 | golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 166 | golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 167 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 168 | golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 169 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 170 | golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 171 | golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 172 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 173 | golang.org/x/net v0.0.0-20190522155817-f3200d17e092 h1:4QSRKanuywn15aTZvI/mIDEgPQpswuFndXpOj3rKEco= 174 | golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= 175 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 176 | golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288 h1:JIqe8uIcRBHXDQVvZtHwp80ai3Lw3IJAeJEs55Dc1W0= 177 | golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 178 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 179 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 180 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 181 | golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 182 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 183 | golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 184 | golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 185 | golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 186 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 187 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 188 | golang.org/x/sys v0.0.0-20211030160813-b3129d9d1021/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 189 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 190 | golang.org/x/sys v0.0.0-20220926163933-8cfa568d3c25 h1:nwzwVf0l2Y/lkov/+IYgMMbFyI+QypZDds9RxlSmsFQ= 191 | golang.org/x/sys v0.0.0-20220926163933-8cfa568d3c25/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 192 | golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec h1:BkDtF2Ih9xZ7le9ndzTA7KJow28VbQW3odyk/8drmuI= 193 | golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 194 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 195 | golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= 196 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 197 | golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= 198 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 199 | golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA= 200 | golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 201 | golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= 202 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 203 | golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 204 | golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 205 | golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 206 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 207 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 208 | google.golang.org/appengine v1.3.0 h1:FBSsiFRMz3LBeXIomRnVzrQwSDj4ibvcRexLG0LZGQk= 209 | google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 210 | google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 211 | google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= 212 | google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= 213 | gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= 214 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 215 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= 216 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 217 | gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= 218 | gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= 219 | gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= 220 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= 221 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 222 | gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= 223 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 224 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 225 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 226 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 227 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 228 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 229 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 230 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 231 | honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 232 | -------------------------------------------------------------------------------- /internal/pkg/devcontainers/snippet_test.go: -------------------------------------------------------------------------------- 1 | package devcontainers 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestGetSnippets_ListsSingleFileTemplates(t *testing.T) { 13 | 14 | root, err := ioutil.TempDir("", "devcontainer*") 15 | if !assert.NoError(t, err) { 16 | return 17 | } 18 | defer os.RemoveAll(root) 19 | 20 | folders := []string{root} 21 | 22 | _ = ioutil.WriteFile(filepath.Join(root, "test1.sh"), []byte{}, 0755) 23 | _ = ioutil.WriteFile(filepath.Join(root, "test2.sh"), []byte{}, 0755) 24 | 25 | snippets, err := getSnippetsFromFolders(folders) 26 | if !assert.NoError(t, err) { 27 | return 28 | } 29 | 30 | expectedTemplates := []DevcontainerSnippet{ 31 | { 32 | Name: "test1", 33 | Type: DevcontainerSnippetTypeSingleFile, 34 | Path: filepath.Join(root, "test1.sh"), 35 | }, 36 | { 37 | Name: "test2", 38 | Type: DevcontainerSnippetTypeSingleFile, 39 | Path: filepath.Join(root, "test2.sh"), 40 | }, 41 | } 42 | 43 | assert.ElementsMatch(t, expectedTemplates, snippets) 44 | } 45 | func TestGetSnippets_IgnoresFilesWithIncorrectPrefix(t *testing.T) { 46 | 47 | root, err := ioutil.TempDir("", "devcontainer*") 48 | if !assert.NoError(t, err) { 49 | return 50 | } 51 | defer os.RemoveAll(root) 52 | 53 | folders := []string{root} 54 | 55 | _ = ioutil.WriteFile(filepath.Join(root, "_ignore.sh"), []byte{}, 0755) 56 | _ = ioutil.WriteFile(filepath.Join(root, ".ignore.sh"), []byte{}, 0755) 57 | _ = ioutil.WriteFile(filepath.Join(root, "test1.sh"), []byte{}, 0755) 58 | 59 | snippets, err := getSnippetsFromFolders(folders) 60 | if !assert.NoError(t, err) { 61 | return 62 | } 63 | 64 | expectedTemplates := []DevcontainerSnippet{ 65 | { 66 | Name: "test1", 67 | Type: DevcontainerSnippetTypeSingleFile, 68 | Path: filepath.Join(root, "test1.sh"), 69 | }, 70 | } 71 | 72 | assert.ElementsMatch(t, expectedTemplates, snippets) 73 | } 74 | func TestGetSnippets_ListsFolderTemplate(t *testing.T) { 75 | 76 | root1, err := ioutil.TempDir("", "devcontainer*") 77 | if !assert.NoError(t, err) { 78 | return 79 | } 80 | defer os.RemoveAll(root1) 81 | root2, err := ioutil.TempDir("", "devcontainer*") 82 | if !assert.NoError(t, err) { 83 | return 84 | } 85 | defer os.RemoveAll(root1) 86 | 87 | folders := []string{root1, root2} 88 | 89 | _ = os.MkdirAll(filepath.Join(root1, "test1"), 0755) 90 | _ = ioutil.WriteFile(filepath.Join(root1, "test1/snippet.json"), []byte{}, 0755) 91 | _ = ioutil.WriteFile(filepath.Join(root2, "test1.sh"), []byte{}, 0755) 92 | 93 | snippets, err := getSnippetsFromFolders(folders) 94 | if !assert.NoError(t, err) { 95 | return 96 | } 97 | 98 | expectedTemplates := []DevcontainerSnippet{ 99 | { 100 | Name: "test1", 101 | Type: DevcontainerSnippetTypeFolder, 102 | Path: filepath.Join(root1, "test1"), // Uses root1 as it is in the list first 103 | }, 104 | } 105 | 106 | assert.ElementsMatch(t, expectedTemplates, snippets) 107 | } 108 | func TestGetSnippets_TakesFilesInPriorityOrder(t *testing.T) { 109 | 110 | root1, err := ioutil.TempDir("", "devcontainer*") 111 | if !assert.NoError(t, err) { 112 | return 113 | } 114 | defer os.RemoveAll(root1) 115 | root2, err := ioutil.TempDir("", "devcontainer*") 116 | if !assert.NoError(t, err) { 117 | return 118 | } 119 | defer os.RemoveAll(root1) 120 | 121 | folders := []string{root1, root2} 122 | 123 | _ = ioutil.WriteFile(filepath.Join(root1, "test1.sh"), []byte{}, 0755) 124 | _ = ioutil.WriteFile(filepath.Join(root2, "test1.sh"), []byte{}, 0755) 125 | 126 | snippets, err := getSnippetsFromFolders(folders) 127 | if !assert.NoError(t, err) { 128 | return 129 | } 130 | 131 | expectedTemplates := []DevcontainerSnippet{ 132 | { 133 | Name: "test1", 134 | Type: DevcontainerSnippetTypeSingleFile, 135 | Path: filepath.Join(root1, "test1.sh"), // Uses root1 as it is in the list first 136 | }, 137 | } 138 | 139 | assert.ElementsMatch(t, expectedTemplates, snippets) 140 | } 141 | 142 | func TestSingleFileAddSnippet_NoInsertionPoint(t *testing.T) { 143 | 144 | root, _ := ioutil.TempDir("", "devcontainer*") 145 | defer os.RemoveAll(root) 146 | 147 | // set up snippet 148 | snippetFolder := filepath.Join(root, "snippets") 149 | _ = os.MkdirAll(snippetFolder, 0755) 150 | snippetFilename := filepath.Join(snippetFolder, "test1.sh") 151 | _ = ioutil.WriteFile(snippetFilename, []byte("# dummy file"), 0755) 152 | 153 | // set up devcontainer 154 | targetFolder := filepath.Join(root, "target") 155 | devcontainerFolder := filepath.Join(targetFolder, ".devcontainer") 156 | _ = os.MkdirAll(devcontainerFolder, 0755) 157 | 158 | _ = ioutil.WriteFile(filepath.Join(devcontainerFolder, "Dockerfile"), []byte(`FROM foo 159 | RUN echo hi 160 | `), 0755) 161 | _ = ioutil.WriteFile(filepath.Join(devcontainerFolder, "devcontainer.json"), []byte(`{ 162 | "name" : "testname" 163 | }`), 0755) 164 | 165 | // Add snippet 166 | snippet := DevcontainerSnippet{ 167 | Name: "test", 168 | Path: snippetFilename, 169 | Type: DevcontainerSnippetTypeSingleFile, 170 | } 171 | err := addSingleFileSnippetToDevContainer(targetFolder, &snippet) 172 | if !assert.NoError(t, err) { 173 | return 174 | } 175 | 176 | buf, err := ioutil.ReadFile(filepath.Join(devcontainerFolder, "scripts", "test1.sh")) 177 | if !assert.NoError(t, err) { 178 | return 179 | } 180 | assert.Equal(t, "# dummy file", string(buf)) 181 | 182 | buf, err = ioutil.ReadFile(filepath.Join(devcontainerFolder, "Dockerfile")) 183 | if !assert.NoError(t, err) { 184 | return 185 | } 186 | assert.Equal(t, `FROM foo 187 | RUN echo hi 188 | 189 | # test 190 | COPY scripts/test1.sh /tmp/ 191 | RUN /tmp/test1.sh 192 | `, string(buf)) 193 | } 194 | func TestSingleFileAddSnippet_WithInsertionPoint(t *testing.T) { 195 | 196 | root, _ := ioutil.TempDir("", "devcontainer*") 197 | defer os.RemoveAll(root) 198 | 199 | // set up snippet 200 | snippetFolder := filepath.Join(root, "snippets") 201 | _ = os.MkdirAll(snippetFolder, 0755) 202 | snippetFilename := filepath.Join(snippetFolder, "test1.sh") 203 | _ = ioutil.WriteFile(snippetFilename, []byte("# dummy file"), 0755) 204 | 205 | // set up devcontainer 206 | targetFolder := filepath.Join(root, "target") 207 | devcontainerFolder := filepath.Join(targetFolder, ".devcontainer") 208 | _ = os.MkdirAll(devcontainerFolder, 0755) 209 | 210 | _ = ioutil.WriteFile(filepath.Join(devcontainerFolder, "Dockerfile"), []byte(`FROM foo 211 | RUN echo hi 212 | # __DEVCONTAINER_SNIPPET_INSERT__ 213 | 214 | RUN echo hi2 215 | `), 0755) 216 | _ = ioutil.WriteFile(filepath.Join(devcontainerFolder, "devcontainer.json"), []byte(`{ 217 | "name" : "testname" 218 | }`), 0755) 219 | 220 | // Add snippet 221 | snippet := DevcontainerSnippet{ 222 | Name: "test", 223 | Path: snippetFilename, 224 | Type: DevcontainerSnippetTypeSingleFile, 225 | } 226 | err := addSingleFileSnippetToDevContainer(targetFolder, &snippet) 227 | if !assert.NoError(t, err) { 228 | return 229 | } 230 | 231 | buf, err := ioutil.ReadFile(filepath.Join(devcontainerFolder, "scripts", "test1.sh")) 232 | if !assert.NoError(t, err) { 233 | return 234 | } 235 | assert.Equal(t, "# dummy file", string(buf)) 236 | 237 | buf, err = ioutil.ReadFile(filepath.Join(devcontainerFolder, "Dockerfile")) 238 | if !assert.NoError(t, err) { 239 | return 240 | } 241 | assert.Equal(t, `FROM foo 242 | RUN echo hi 243 | # test 244 | COPY scripts/test1.sh /tmp/ 245 | RUN /tmp/test1.sh 246 | 247 | # __DEVCONTAINER_SNIPPET_INSERT__ 248 | 249 | RUN echo hi2 250 | `, string(buf)) 251 | } 252 | 253 | func TestFolderAddSnippet_MergesDevcontainerJSON(t *testing.T) { 254 | 255 | root, _ := ioutil.TempDir("", "devcontainer*") 256 | defer os.RemoveAll(root) 257 | 258 | // set up snippet 259 | snippetFolder := filepath.Join(root, "snippets/test1") 260 | _ = os.MkdirAll(snippetFolder, 0755) 261 | snippetJSONFilename := filepath.Join(snippetFolder, "snippet.json") 262 | _ = ioutil.WriteFile(snippetJSONFilename, []byte(`{ 263 | "actions": [ 264 | { 265 | "type": "mergeJSON", 266 | "source": "devcontainer.json", 267 | "target": ".devcontainer/devcontainer.json" 268 | } 269 | ] 270 | }`), 0755) 271 | 272 | snippetDevcontainerFilename := filepath.Join(snippetFolder, "devcontainer.json") 273 | _ = ioutil.WriteFile(snippetDevcontainerFilename, []byte(`// For format details, see https://aka.ms/vscode-remote/devcontainer.json or this file's README at: 274 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.117.1/containers/go 275 | { 276 | "runArgs": [ 277 | // Mount go mod cache 278 | "-v", "devcontainer-cli-gomodcache:/go/pkg", 279 | ], 280 | 281 | // Set *default* container specific settings.json values on container create. 282 | "settings": { 283 | "go.gopath": "/go", 284 | "go.useLanguageServer": true, 285 | "[go]": { 286 | "editor.snippetSuggestions": "none", 287 | "editor.formatOnSave": true, 288 | "editor.codeActionsOnSave": { 289 | "source.organizeImports": true, 290 | } 291 | }, 292 | "gopls": { 293 | "usePlaceholders": true, // add parameter placeholders when completing a function 294 | // Experimental settings 295 | "completeUnimported": true, // autocomplete unimported packages 296 | "watchFileChanges": true, // watch file changes outside of the editor 297 | "deepCompletion": true, // enable deep completion 298 | }, 299 | }, 300 | 301 | // Add the IDs of extensions you want installed when the container is created. 302 | "extensions": [ 303 | "stuartleeks.vscode-go-by-example", 304 | "golang.go", 305 | ], 306 | }`), 0755) 307 | 308 | // set up devcontainer 309 | targetFolder := filepath.Join(root, "target") 310 | devcontainerFolder := filepath.Join(targetFolder, ".devcontainer") 311 | _ = os.MkdirAll(devcontainerFolder, 0755) 312 | 313 | _ = ioutil.WriteFile(filepath.Join(devcontainerFolder, "devcontainer.json"), []byte(`// For format details, see https://aka.ms/vscode-remote/devcontainer.json or this file's README at: 314 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.117.1/containers/go 315 | { 316 | "name": "test", 317 | "dockerFile": "Dockerfile", 318 | "runArgs": [ 319 | // Use host network 320 | "--network=host", 321 | ], 322 | 323 | // Set *default* container specific settings.json values on container create. 324 | "settings": { 325 | "terminal.integrated.shell.linux": "/bin/bash", 326 | }, 327 | 328 | // Add the IDs of extensions you want installed when the container is created. 329 | "extensions": [ 330 | "example.test", 331 | ], 332 | 333 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 334 | // "forwardPorts": [], 335 | 336 | // Uncomment to connect as a non-root user. See https://aka.ms/vscode-remote/containers/non-root. 337 | "remoteUser": "vscode" 338 | }`), 0755) 339 | 340 | // Add snippet 341 | snippet := DevcontainerSnippet{ 342 | Name: "test", 343 | Path: snippetFolder, 344 | Type: DevcontainerSnippetTypeFolder, 345 | } 346 | err := addSnippetToDevcontainer(targetFolder, &snippet) 347 | if !assert.NoError(t, err) { 348 | return 349 | } 350 | 351 | buf, err := ioutil.ReadFile(filepath.Join(devcontainerFolder, "devcontainer.json")) 352 | if !assert.NoError(t, err) { 353 | return 354 | } 355 | stringContent := string(buf) 356 | assert.Equal(t, `// For format details, see https://aka.ms/vscode-remote/devcontainer.json or this file's README at: 357 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.117.1/containers/go 358 | { 359 | "name": "test", 360 | "dockerFile": "Dockerfile", 361 | "runArgs": [ 362 | // Use host network 363 | "--network=host", 364 | // Mount go mod cache 365 | "-v", "devcontainer-cli-gomodcache:/go/pkg", 366 | ], 367 | 368 | // Set *default* container specific settings.json values on container create. 369 | "settings": { 370 | "terminal.integrated.shell.linux": "/bin/bash", 371 | "go.gopath": "/go", 372 | "go.useLanguageServer": true, 373 | "[go]": { 374 | "editor.snippetSuggestions": "none", 375 | "editor.formatOnSave": true, 376 | "editor.codeActionsOnSave": { 377 | "source.organizeImports": true, 378 | } 379 | }, 380 | "gopls": { 381 | "usePlaceholders": true, // add parameter placeholders when completing a function 382 | // Experimental settings 383 | "completeUnimported": true, // autocomplete unimported packages 384 | "watchFileChanges": true, // watch file changes outside of the editor 385 | "deepCompletion": true, // enable deep completion 386 | }, 387 | }, 388 | 389 | // Add the IDs of extensions you want installed when the container is created. 390 | "extensions": [ 391 | "example.test", 392 | "stuartleeks.vscode-go-by-example", 393 | "golang.go", 394 | ], 395 | 396 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 397 | // "forwardPorts": [], 398 | 399 | // Uncomment to connect as a non-root user. See https://aka.ms/vscode-remote/containers/non-root. 400 | "remoteUser": "vscode" 401 | }`, stringContent) 402 | } 403 | 404 | func TestFolderAddSnippet_CopiesScriptAndUpdatesDockerfile(t *testing.T) { 405 | 406 | root, _ := ioutil.TempDir("", "devcontainer*") 407 | defer os.RemoveAll(root) 408 | 409 | // set up snippet 410 | snippetFolder := filepath.Join(root, "snippets/test1") 411 | _ = os.MkdirAll(snippetFolder, 0755) 412 | snippetJSONFilename := filepath.Join(snippetFolder, "snippet.json") 413 | _ = ioutil.WriteFile(snippetJSONFilename, []byte(`{ 414 | "actions": [ 415 | { 416 | "type": "copyAndRun", 417 | "source": "script.sh" 418 | } 419 | ] 420 | }`), 0755) 421 | 422 | scriptFilename := filepath.Join(snippetFolder, "script.sh") 423 | _ = ioutil.WriteFile(scriptFilename, []byte("# dummy file"), 0755) 424 | 425 | // set up devcontainer 426 | targetFolder := filepath.Join(root, "target") 427 | devcontainerFolder := filepath.Join(targetFolder, ".devcontainer") 428 | _ = os.MkdirAll(devcontainerFolder, 0755) 429 | 430 | _ = ioutil.WriteFile(filepath.Join(devcontainerFolder, "Dockerfile"), []byte(`FROM foo 431 | RUN echo hi 432 | 433 | # __DEVCONTAINER_SNIPPET_INSERT__ 434 | 435 | RUN echo hi2 436 | `), 0755) 437 | _ = ioutil.WriteFile(filepath.Join(devcontainerFolder, "devcontainer.json"), []byte(`{ 438 | "name" : "testname" 439 | }`), 0755) 440 | 441 | // Add snippet 442 | snippet := DevcontainerSnippet{ 443 | Name: "test", 444 | Path: snippetFolder, 445 | Type: DevcontainerSnippetTypeFolder, 446 | } 447 | err := addSnippetToDevcontainer(targetFolder, &snippet) 448 | if !assert.NoError(t, err) { 449 | return 450 | } 451 | 452 | buf, err := ioutil.ReadFile(filepath.Join(devcontainerFolder, "scripts", "script.sh")) 453 | if !assert.NoError(t, err) { 454 | return 455 | } 456 | assert.Equal(t, "# dummy file", string(buf)) 457 | 458 | buf, err = ioutil.ReadFile(filepath.Join(devcontainerFolder, "Dockerfile")) 459 | if !assert.NoError(t, err) { 460 | return 461 | } 462 | assert.Equal(t, `FROM foo 463 | RUN echo hi 464 | 465 | # test 466 | COPY scripts/script.sh /tmp/ 467 | RUN /tmp/script.sh 468 | 469 | # __DEVCONTAINER_SNIPPET_INSERT__ 470 | 471 | RUN echo hi2 472 | `, string(buf)) 473 | } 474 | 475 | func TestFolderAddSnippet_InsertsTextSnippetsInDockerfile(t *testing.T) { 476 | 477 | root, _ := ioutil.TempDir("", "devcontainer*") 478 | defer os.RemoveAll(root) 479 | 480 | // set up snippet 481 | snippetFolder := filepath.Join(root, "snippets/test1") 482 | _ = os.MkdirAll(snippetFolder, 0755) 483 | snippetJSONFilename := filepath.Join(snippetFolder, "snippet.json") 484 | _ = ioutil.WriteFile(snippetJSONFilename, []byte(`{ 485 | "actions": [ 486 | { 487 | "type": "dockerfileSnippet", 488 | "content": "ENV FOO=BAR" 489 | }, 490 | { 491 | "type": "dockerfileSnippet", 492 | "content": "# testing\nENV WIBBLE=BIBBLE" 493 | } 494 | ] 495 | }`), 0755) 496 | 497 | // set up devcontainer 498 | targetFolder := filepath.Join(root, "target") 499 | devcontainerFolder := filepath.Join(targetFolder, ".devcontainer") 500 | _ = os.MkdirAll(devcontainerFolder, 0755) 501 | 502 | _ = ioutil.WriteFile(filepath.Join(devcontainerFolder, "Dockerfile"), []byte(`FROM foo 503 | RUN echo hi 504 | 505 | # __DEVCONTAINER_SNIPPET_INSERT__ 506 | 507 | RUN echo hi2 508 | `), 0755) 509 | _ = ioutil.WriteFile(filepath.Join(devcontainerFolder, "devcontainer.json"), []byte(`{ 510 | "name" : "testname" 511 | }`), 0755) 512 | 513 | // Add snippet 514 | snippet := DevcontainerSnippet{ 515 | Name: "test", 516 | Path: snippetFolder, 517 | Type: DevcontainerSnippetTypeFolder, 518 | } 519 | err := addSnippetToDevcontainer(targetFolder, &snippet) 520 | if !assert.NoError(t, err) { 521 | return 522 | } 523 | 524 | buf, err := ioutil.ReadFile(filepath.Join(devcontainerFolder, "Dockerfile")) 525 | if !assert.NoError(t, err) { 526 | return 527 | } 528 | assert.Equal(t, `FROM foo 529 | RUN echo hi 530 | 531 | ENV FOO=BAR 532 | 533 | # testing 534 | ENV WIBBLE=BIBBLE 535 | 536 | # __DEVCONTAINER_SNIPPET_INSERT__ 537 | 538 | RUN echo hi2 539 | `, string(buf)) 540 | } 541 | 542 | func TestFolderAddSnippet_InsertsFileSnippetInDockerfile(t *testing.T) { 543 | 544 | root, _ := ioutil.TempDir("", "devcontainer*") 545 | defer os.RemoveAll(root) 546 | 547 | // set up snippet 548 | snippetFolder := filepath.Join(root, "snippets/test1") 549 | _ = os.MkdirAll(snippetFolder, 0755) 550 | snippetJSONFilename := filepath.Join(snippetFolder, "snippet.json") 551 | _ = ioutil.WriteFile(snippetJSONFilename, []byte(`{ 552 | "actions": [ 553 | { 554 | "type": "dockerfileSnippet", 555 | "content": "ENV FOO=BAR" 556 | }, 557 | { 558 | "type": "dockerfileSnippet", 559 | "contentPath": "Dockerfile" 560 | } 561 | ] 562 | }`), 0755) 563 | snippetDockerfileFilename := filepath.Join(snippetFolder, "Dockerfile") 564 | _ = ioutil.WriteFile(snippetDockerfileFilename, []byte(`# from snippet file 565 | ENV WIBBLE BIBBLE 566 | `), 0755) 567 | 568 | // set up devcontainer 569 | targetFolder := filepath.Join(root, "target") 570 | devcontainerFolder := filepath.Join(targetFolder, ".devcontainer") 571 | _ = os.MkdirAll(devcontainerFolder, 0755) 572 | 573 | _ = ioutil.WriteFile(filepath.Join(devcontainerFolder, "Dockerfile"), []byte(`FROM foo 574 | RUN echo hi 575 | 576 | # __DEVCONTAINER_SNIPPET_INSERT__ 577 | 578 | RUN echo hi2 579 | `), 0755) 580 | _ = ioutil.WriteFile(filepath.Join(devcontainerFolder, "devcontainer.json"), []byte(`{ 581 | "name" : "testname" 582 | }`), 0755) 583 | 584 | // Add snippet 585 | snippet := DevcontainerSnippet{ 586 | Name: "test", 587 | Path: snippetFolder, 588 | Type: DevcontainerSnippetTypeFolder, 589 | } 590 | err := addSnippetToDevcontainer(targetFolder, &snippet) 591 | if !assert.NoError(t, err) { 592 | return 593 | } 594 | 595 | buf, err := ioutil.ReadFile(filepath.Join(devcontainerFolder, "Dockerfile")) 596 | if !assert.NoError(t, err) { 597 | return 598 | } 599 | assert.Equal(t, `FROM foo 600 | RUN echo hi 601 | 602 | ENV FOO=BAR 603 | 604 | # from snippet file 605 | ENV WIBBLE BIBBLE 606 | 607 | # __DEVCONTAINER_SNIPPET_INSERT__ 608 | 609 | RUN echo hi2 610 | `, string(buf)) 611 | } 612 | 613 | func TestFolderAddSnippet_PerformsSubstitutionWithoutUserName(t *testing.T) { 614 | 615 | root, _ := ioutil.TempDir("", "devcontainer*") 616 | defer os.RemoveAll(root) 617 | 618 | // set up snippet 619 | snippetFolder := filepath.Join(root, "snippets/test1") 620 | _ = os.MkdirAll(snippetFolder, 0755) 621 | snippetJSONFilename := filepath.Join(snippetFolder, "snippet.json") 622 | _ = ioutil.WriteFile(snippetJSONFilename, []byte(`{ 623 | "actions": [ 624 | { 625 | "type": "mergeJSON", 626 | "source": "devcontainer.json", 627 | "target": ".devcontainer/devcontainer.json" 628 | }, 629 | { 630 | "type": "dockerfileSnippet", 631 | "content": "ENV DC_NAME=__DEVCONTAINER_NAME__\nENV DC_USER_NAME=__DEVCONTAINER_USER_NAME__\nENV DC_HOME=__DEVCONTAINER_HOME__" 632 | } 633 | ] 634 | }`), 0755) 635 | 636 | snippetDevcontainerFilename := filepath.Join(snippetFolder, "devcontainer.json") 637 | _ = ioutil.WriteFile(snippetDevcontainerFilename, []byte(`{ 638 | "settings": { 639 | "DC_NAME": "__DEVCONTAINER_NAME__", 640 | "DC_USER_NAME": "__DEVCONTAINER_USER_NAME__", 641 | "DC_HOME": "__DEVCONTAINER_HOME__" 642 | }, 643 | }`), 0755) 644 | 645 | // set up devcontainer 646 | targetFolder := filepath.Join(root, "target") 647 | devcontainerFolder := filepath.Join(targetFolder, ".devcontainer") 648 | _ = os.MkdirAll(devcontainerFolder, 0755) 649 | 650 | _ = ioutil.WriteFile(filepath.Join(devcontainerFolder, "Dockerfile"), []byte(`FROM foo 651 | RUN echo hi 652 | 653 | # __DEVCONTAINER_SNIPPET_INSERT__ 654 | 655 | RUN echo hi2 656 | `), 0755) 657 | _ = ioutil.WriteFile(filepath.Join(devcontainerFolder, "devcontainer.json"), []byte(`{ 658 | "name" : "testname" 659 | }`), 0755) 660 | 661 | // Add snippet 662 | snippet := DevcontainerSnippet{ 663 | Name: "test", 664 | Path: snippetFolder, 665 | Type: DevcontainerSnippetTypeFolder, 666 | } 667 | err := addSnippetToDevcontainer(targetFolder, &snippet) 668 | if !assert.NoError(t, err) { 669 | return 670 | } 671 | 672 | buf, err := ioutil.ReadFile(filepath.Join(devcontainerFolder, "Dockerfile")) 673 | if !assert.NoError(t, err) { 674 | return 675 | } 676 | assert.Equal(t, `FROM foo 677 | RUN echo hi 678 | 679 | ENV DC_NAME=testname 680 | ENV DC_USER_NAME=root 681 | ENV DC_HOME=/root 682 | 683 | # __DEVCONTAINER_SNIPPET_INSERT__ 684 | 685 | RUN echo hi2 686 | `, string(buf)) 687 | 688 | buf, err = ioutil.ReadFile(filepath.Join(devcontainerFolder, "devcontainer.json")) 689 | if !assert.NoError(t, err) { 690 | return 691 | } 692 | stringContent := string(buf) 693 | assert.Equal(t, `{ 694 | "name" : "testname", 695 | "settings": { 696 | "DC_NAME": "testname", 697 | "DC_USER_NAME": "root", 698 | "DC_HOME": "/root" 699 | }, 700 | }`, stringContent) 701 | 702 | } 703 | func TestFolderAddSnippet_PerformsSubstitutionWithUserName(t *testing.T) { 704 | 705 | root, _ := ioutil.TempDir("", "devcontainer*") 706 | defer os.RemoveAll(root) 707 | 708 | // set up snippet 709 | snippetFolder := filepath.Join(root, "snippets/test1") 710 | _ = os.MkdirAll(snippetFolder, 0755) 711 | snippetJSONFilename := filepath.Join(snippetFolder, "snippet.json") 712 | _ = ioutil.WriteFile(snippetJSONFilename, []byte(`{ 713 | "actions": [ 714 | { 715 | "type": "mergeJSON", 716 | "source": "devcontainer.json", 717 | "target": ".devcontainer/devcontainer.json" 718 | }, 719 | { 720 | "type": "dockerfileSnippet", 721 | "content": "ENV DC_NAME=__DEVCONTAINER_NAME__\nENV DC_USER_NAME=__DEVCONTAINER_USER_NAME__\nENV DC_HOME=__DEVCONTAINER_HOME__" 722 | } 723 | ] 724 | }`), 0755) 725 | 726 | snippetDevcontainerFilename := filepath.Join(snippetFolder, "devcontainer.json") 727 | _ = ioutil.WriteFile(snippetDevcontainerFilename, []byte(`{ 728 | "settings": { 729 | "DC_NAME": "__DEVCONTAINER_NAME__", 730 | "DC_USER_NAME": "__DEVCONTAINER_USER_NAME__", 731 | "DC_HOME": "__DEVCONTAINER_HOME__" 732 | }, 733 | }`), 0755) 734 | 735 | // set up devcontainer 736 | targetFolder := filepath.Join(root, "target") 737 | devcontainerFolder := filepath.Join(targetFolder, ".devcontainer") 738 | _ = os.MkdirAll(devcontainerFolder, 0755) 739 | 740 | _ = ioutil.WriteFile(filepath.Join(devcontainerFolder, "Dockerfile"), []byte(`FROM foo 741 | RUN echo hi 742 | 743 | # __DEVCONTAINER_SNIPPET_INSERT__ 744 | 745 | RUN echo hi2 746 | `), 0755) 747 | _ = ioutil.WriteFile(filepath.Join(devcontainerFolder, "devcontainer.json"), []byte(`{ 748 | "name" : "testname", 749 | "remoteUser": "dcuser" 750 | }`), 0755) 751 | 752 | // Add snippet 753 | snippet := DevcontainerSnippet{ 754 | Name: "test", 755 | Path: snippetFolder, 756 | Type: DevcontainerSnippetTypeFolder, 757 | } 758 | err := addSnippetToDevcontainer(targetFolder, &snippet) 759 | if !assert.NoError(t, err) { 760 | return 761 | } 762 | 763 | buf, err := ioutil.ReadFile(filepath.Join(devcontainerFolder, "Dockerfile")) 764 | if !assert.NoError(t, err) { 765 | return 766 | } 767 | assert.Equal(t, `FROM foo 768 | RUN echo hi 769 | 770 | ENV DC_NAME=testname 771 | ENV DC_USER_NAME=dcuser 772 | ENV DC_HOME=/home/dcuser 773 | 774 | # __DEVCONTAINER_SNIPPET_INSERT__ 775 | 776 | RUN echo hi2 777 | `, string(buf)) 778 | 779 | buf, err = ioutil.ReadFile(filepath.Join(devcontainerFolder, "devcontainer.json")) 780 | if !assert.NoError(t, err) { 781 | return 782 | } 783 | stringContent := string(buf) 784 | assert.Equal(t, `{ 785 | "name" : "testname", 786 | "remoteUser": "dcuser", 787 | "settings": { 788 | "DC_NAME": "testname", 789 | "DC_USER_NAME": "dcuser", 790 | "DC_HOME": "/home/dcuser" 791 | }, 792 | }`, stringContent) 793 | 794 | } 795 | --------------------------------------------------------------------------------