├── .github └── workflows │ ├── build.yml │ └── release.yml ├── .gitignore ├── .go-version ├── .goreleaser.yml ├── Dockerfile ├── Makefile ├── README.md ├── cli ├── any.go ├── any_test.go ├── color.go ├── config.go ├── console.go ├── dispatch.go ├── error.go ├── generate_docs.go ├── generate_docs_default.go ├── init.go ├── init_test.go ├── log.go ├── login.go ├── main.go ├── main_test.go ├── python.go ├── run.go ├── run_darwin.go ├── run_default.go ├── run_linux.go ├── run_test.go ├── style.go ├── switch.go ├── switch_test.go ├── text.go ├── tui.go ├── verification.go ├── version.go └── version_test.go ├── go.mod ├── go.sum ├── main.go └── proto ├── buf.lock └── buf.yaml /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.event.number || github.ref }} 11 | cancel-in-progress: true 12 | 13 | env: 14 | GOPRIVATE: github.com/dispatchrun,buf.build/gen/go 15 | GOVERSION: 1.22.0 16 | 17 | jobs: 18 | lint: 19 | runs-on: ubuntu-latest 20 | permissions: 21 | id-token: write 22 | contents: read 23 | steps: 24 | - uses: bufbuild/buf-setup-action@v1.28.1 25 | with: 26 | buf_user: ${{ secrets.BUF_USER }} 27 | buf_api_token: ${{ secrets.BUF_TOKEN }} 28 | - uses: actions/checkout@v4 29 | - uses: actions/setup-go@v4 30 | with: 31 | go-version: ${{ env.GOVERSION }} 32 | - run: git config --global url.https://${{ secrets.PRIVATE_REPO }}@github.com.insteadOf https://github.com 33 | - run: go mod download 34 | - uses: golangci/golangci-lint-action@v3 35 | with: 36 | version: v1.54.2 37 | args: --timeout 4m 38 | # Disable caching as a workaround for https://github.com/golangci/golangci-lint-action/issues/135. 39 | skip-pkg-cache: true 40 | 41 | test: 42 | runs-on: ${{ matrix.os }} 43 | concurrency: 44 | group: ${{ matrix.os }}-${{ github.workflow }}-${{ github.event.number || github.ref }} 45 | cancel-in-progress: true 46 | strategy: 47 | matrix: 48 | os: [ubuntu-latest, windows-latest] 49 | permissions: 50 | id-token: write 51 | contents: read 52 | steps: 53 | - uses: bufbuild/buf-setup-action@v1.28.1 54 | with: 55 | buf_user: ${{ secrets.BUF_USER }} 56 | buf_api_token: ${{ secrets.BUF_TOKEN }} 57 | - uses: actions/checkout@v4 58 | - uses: actions/setup-go@v4 59 | with: 60 | go-version: ${{ env.GOVERSION }} 61 | - run: git config --global url.https://${{ secrets.PRIVATE_REPO }}@github.com.insteadOf https://github.com 62 | - run: go mod download 63 | - run: make test-cover 64 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # Stable release of the Dispatch CLI 2 | name: release 3 | 4 | on: 5 | push: 6 | tags: 7 | - "v*" 8 | 9 | permissions: 10 | contents: write 11 | 12 | jobs: 13 | goreleaser: 14 | env: 15 | GH_TOKEN_HOMEBREW_DISPATCH: ${{ secrets.GH_TOKEN_HOMEBREW_DISPATCH }} 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v3 19 | with: 20 | fetch-depth: 0 21 | 22 | - uses: actions/setup-go@v4 23 | with: 24 | go-version-file: .go-version 25 | - uses: goreleaser/goreleaser-action@v4 26 | with: 27 | distribution: goreleaser 28 | version: latest 29 | args: release --clean 30 | env: 31 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 32 | GH_TOKEN_HOMEBREW_DISPATCH: ${{ env.GH_TOKEN_HOMEBREW_DISPATCH }} 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | /build 11 | /dispatch 12 | 13 | # Test binary, built with `go test -c` 14 | *.test 15 | *.py 16 | 17 | # Output of the go coverage tool, specifically when used with LiteIDE 18 | *.out 19 | 20 | # Dependency directories (remove the comment below to include it) 21 | vendor/ 22 | 23 | # Go workspace file 24 | go.work 25 | go.work.sum 26 | 27 | # goreleaser 28 | goreleaser/ 29 | 30 | # Emacs 31 | *~ 32 | 33 | # Secrets 34 | .netrc 35 | 36 | .dispatch 37 | 38 | docs/ 39 | -------------------------------------------------------------------------------- /.go-version: -------------------------------------------------------------------------------- 1 | 1.22.0 2 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | project_name: dispatch 2 | dist: ./goreleaser/dist 3 | version: 2 4 | 5 | before: 6 | hooks: 7 | - go mod tidy 8 | 9 | gomod: 10 | proxy: true 11 | 12 | builds: 13 | - id: dispatch 14 | main: . 15 | binary: dispatch 16 | mod_timestamp: "{{ .CommitTimestamp }}" 17 | 18 | goarch: 19 | - amd64 20 | - arm64 21 | 22 | goos: 23 | - darwin 24 | - linux 25 | - windows 26 | 27 | - id: dispatch-docs 28 | main: . 29 | binary: dispatch-docs 30 | mod_timestamp: "{{ .CommitTimestamp }}" 31 | tags: docs 32 | 33 | goarch: 34 | - amd64 35 | 36 | goos: 37 | - linux 38 | 39 | archives: 40 | - id: dispatch 41 | builds: [dispatch] 42 | format_overrides: 43 | - goos: windows 44 | format: zip 45 | 46 | - id: dispatch-docs 47 | builds: [dispatch-docs] 48 | name_template: "{{ .ProjectName }}_docs_{{ .Version }}_{{ .Os }}_{{ .Arch }}" 49 | 50 | release: 51 | github: 52 | owner: dispatchrun 53 | name: dispatch 54 | draft: false 55 | prerelease: auto 56 | mode: replace 57 | changelog: 58 | use: github-native 59 | 60 | brews: 61 | - name: dispatch 62 | ids: 63 | - dispatch 64 | url_template: "https://github.com/dispatchrun/dispatch/releases/download/{{ .Tag }}/{{ .ArtifactName }}" 65 | 66 | commit_author: 67 | name: stealthrocket-bot 68 | email: bot@stealthrocket.tech 69 | 70 | directory: Formula 71 | 72 | homepage: "https://dispatch.run" 73 | 74 | description: "A platform for developing scalable & reliable distributed systems." 75 | 76 | license: "Apache-2.0" 77 | 78 | skip_upload: false 79 | 80 | test: | 81 | system "#{bin}/dispatch", "--version" 82 | 83 | repository: 84 | owner: dispatchrun 85 | name: homebrew-dispatch 86 | branch: main 87 | token: "{{ .Env.GH_TOKEN_HOMEBREW_DISPATCH }}" 88 | pull_request: 89 | enabled: true 90 | draft: true 91 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.22.0-alpine3.19 2 | RUN apk update 3 | RUN apk add --no-cache ca-certificates 4 | ENV CGO_ENABLED=0 5 | COPY go.mod go.sum . 6 | COPY . . 7 | RUN go build -o /bin/dispatch . 8 | ENTRYPOINT ["/bin/dispatch"] 9 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test lint fmt dispatch clean image push 2 | 3 | BUILD = build/$(GOOS)/$(GOARCH) 4 | GOOS ?= $(shell go env GOOS) 5 | GOARCH ?= $(shell go env GOARCH) 6 | GOEXE ?= $(shell go env GOEXE) 7 | GO ?= go 8 | 9 | DOCKER ?= docker 10 | TAG ?= $(shell git log --pretty=format:'%h' -n 1) 11 | REGISTRY ?= 714918108619.dkr.ecr.us-west-2.amazonaws.com 12 | DISPATCH = $(BUILD)/dispatch$(GOEXE) 13 | IMAGE = $(REGISTRY)/dispatch:$(TAG) 14 | 15 | test: dispatch 16 | $(GO) test ./... 17 | 18 | test-cover: dispatch 19 | $(GO) test -cover ./... 20 | 21 | lint: 22 | golangci-lint run ./... 23 | 24 | fmt: 25 | $(GO) fmt ./... 26 | 27 | dispatch: 28 | $(GO) build -o $(DISPATCH) . 29 | 30 | clean: 31 | rm -rf ./build 32 | 33 | image: 34 | $(DOCKER) build -t $(IMAGE) . 35 | 36 | push: image 37 | $(DOCKER) push $(IMAGE) 38 | 39 | update: 40 | for ref in $$(yq -r '.deps[] | .remote + "/gen/go/" + .owner + "/" + .repository + "/protocolbuffers/go@" + .commit' proto/buf.lock); do go get $$ref; done 41 | go mod tidy 42 | 43 | dispatch-docs: 44 | ${GO} build -tags docs -o ${DISPATCH} . 45 | ${DISPATCH} 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | dispatch logo 5 | 6 |

7 | 8 |
9 | 10 | [![Build](https://github.com/dispatchrun/dispatch/actions/workflows/build.yml/badge.svg?branch=main)](https://github.com/dispatchrun/dispatch/actions/workflows/build.yml) 11 | [![Reference](https://img.shields.io/badge/API-Reference-lightblue.svg)](https://docs.dispatch.run/) 12 | 13 | **Dispatch** is a platform for developing scalable & reliable distributed systems. 14 | 15 | ## Getting Started 16 | 17 | ### Install Dispatch 18 | 19 | Install with Homebrew on macOS: 20 | 21 | ```console 22 | brew tap dispatchrun/dispatch 23 | brew install dispatch 24 | ``` 25 | 26 | Install with Go: 27 | 28 | ```console 29 | go install github.com/dispatchrun/dispatch@latest 30 | ``` 31 | 32 | Alternatively, you can download the latest `dispatch` binary from the 33 | [Releases](https://github.com/dispatchrun/dispatch/releases) page. 34 | 35 | ### Create an Account 36 | 37 | To create a **Dispatch** account, or login to an existing account: 38 | 39 | ```console 40 | dispatch login 41 | ``` 42 | 43 | To manage your account and functions, visit the [Dispatch Console](https://console.dispatch.run). 44 | 45 | ### Create a Function 46 | 47 | To create your first **Dispatch** function, see our 48 | [Getting Started](https://docs.dispatch.run/getting-started/) guide. 49 | 50 | ## Getting Help 51 | 52 | See `dispatch help` or our [documentation](https://docs.dispatch.run) for 53 | further information, or reach out on [Discord](https://dispatch.run/discord). 54 | -------------------------------------------------------------------------------- /cli/any.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "strconv" 7 | "strings" 8 | 9 | pythonv1 "buf.build/gen/go/stealthrocket/dispatch-proto/protocolbuffers/go/dispatch/sdk/python/v1" 10 | "google.golang.org/protobuf/types/known/anypb" 11 | "google.golang.org/protobuf/types/known/durationpb" 12 | "google.golang.org/protobuf/types/known/emptypb" 13 | "google.golang.org/protobuf/types/known/structpb" 14 | "google.golang.org/protobuf/types/known/timestamppb" 15 | "google.golang.org/protobuf/types/known/wrapperspb" 16 | ) 17 | 18 | func anyString(any *anypb.Any) string { 19 | if any == nil { 20 | return "nil" 21 | } 22 | 23 | m, err := any.UnmarshalNew() 24 | if err != nil { 25 | return unsupportedAny(any, err) 26 | } 27 | 28 | switch mm := m.(type) { 29 | case *wrapperspb.BytesValue: 30 | // The Python SDK originally wrapped pickled values in a 31 | // wrapperspb.BytesValue. Try to unpickle the bytes first, 32 | // and return literal bytes if they cannot be unpickled. 33 | s, err := pythonPickleString(mm.Value) 34 | if err != nil { 35 | s = fmt.Sprintf("bytes(%s)", truncateBytes(mm.Value)) 36 | } 37 | return s 38 | 39 | case *wrapperspb.Int32Value: 40 | return strconv.FormatInt(int64(mm.Value), 10) 41 | 42 | case *wrapperspb.Int64Value: 43 | return strconv.FormatInt(mm.Value, 10) 44 | 45 | case *wrapperspb.UInt32Value: 46 | return strconv.FormatUint(uint64(mm.Value), 10) 47 | 48 | case *wrapperspb.UInt64Value: 49 | return strconv.FormatUint(mm.Value, 10) 50 | 51 | case *wrapperspb.StringValue: 52 | return fmt.Sprintf("%q", mm.Value) 53 | 54 | case *wrapperspb.BoolValue: 55 | return strconv.FormatBool(mm.Value) 56 | 57 | case *wrapperspb.FloatValue: 58 | return fmt.Sprintf("%v", mm.Value) 59 | 60 | case *wrapperspb.DoubleValue: 61 | return fmt.Sprintf("%v", mm.Value) 62 | 63 | case *emptypb.Empty: 64 | return "empty()" 65 | 66 | case *timestamppb.Timestamp: 67 | return mm.AsTime().String() 68 | 69 | case *durationpb.Duration: 70 | return mm.AsDuration().String() 71 | 72 | case *structpb.Struct: 73 | return structpbStructString(mm) 74 | 75 | case *structpb.ListValue: 76 | return structpbListString(mm) 77 | 78 | case *structpb.Value: 79 | return structpbValueString(mm) 80 | 81 | case *pythonv1.Pickled: 82 | s, err := pythonPickleString(mm.PickledValue) 83 | if err != nil { 84 | return unsupportedAny(any, fmt.Errorf("pickle error: %w", err)) 85 | } 86 | return s 87 | 88 | default: 89 | return unsupportedAny(any, fmt.Errorf("not implemented: %T", m)) 90 | } 91 | } 92 | 93 | func structpbStructString(s *structpb.Struct) string { 94 | var b strings.Builder 95 | b.WriteByte('{') 96 | i := 0 97 | for name, value := range s.Fields { 98 | if i > 0 { 99 | b.WriteString(", ") 100 | } 101 | b.WriteString(fmt.Sprintf("%q", name)) 102 | b.WriteString(": ") 103 | b.WriteString(structpbValueString(value)) 104 | i++ 105 | } 106 | b.WriteByte('}') 107 | return b.String() 108 | } 109 | 110 | func structpbListString(s *structpb.ListValue) string { 111 | var b strings.Builder 112 | b.WriteByte('[') 113 | for i, value := range s.Values { 114 | if i > 0 { 115 | b.WriteString(", ") 116 | } 117 | b.WriteString(structpbValueString(value)) 118 | } 119 | b.WriteByte(']') 120 | return b.String() 121 | } 122 | 123 | func structpbValueString(s *structpb.Value) string { 124 | switch v := s.Kind.(type) { 125 | case *structpb.Value_StructValue: 126 | return structpbStructString(v.StructValue) 127 | case *structpb.Value_ListValue: 128 | return structpbListString(v.ListValue) 129 | case *structpb.Value_BoolValue: 130 | return strconv.FormatBool(v.BoolValue) 131 | case *structpb.Value_NumberValue: 132 | return fmt.Sprintf("%v", v.NumberValue) 133 | case *structpb.Value_StringValue: 134 | return fmt.Sprintf("%q", v.StringValue) 135 | case *structpb.Value_NullValue: 136 | return "null" 137 | default: 138 | panic("unreachable") 139 | } 140 | } 141 | 142 | func unsupportedAny(any *anypb.Any, err error) string { 143 | if err != nil { 144 | slog.Debug("cannot parse input/output value", "error", err) 145 | } 146 | return fmt.Sprintf("%s(?)", any.TypeUrl) 147 | } 148 | 149 | func truncateBytes(b []byte) []byte { 150 | const n = 4 151 | if len(b) < n { 152 | return b 153 | } 154 | return append(b[:n:n], "..."...) 155 | } 156 | -------------------------------------------------------------------------------- /cli/any_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | pythonv1 "buf.build/gen/go/stealthrocket/dispatch-proto/protocolbuffers/go/dispatch/sdk/python/v1" 8 | "google.golang.org/protobuf/proto" 9 | "google.golang.org/protobuf/types/known/anypb" 10 | "google.golang.org/protobuf/types/known/durationpb" 11 | "google.golang.org/protobuf/types/known/emptypb" 12 | "google.golang.org/protobuf/types/known/structpb" 13 | "google.golang.org/protobuf/types/known/timestamppb" 14 | "google.golang.org/protobuf/types/known/wrapperspb" 15 | ) 16 | 17 | func TestAnyString(t *testing.T) { 18 | for _, test := range []struct { 19 | input *anypb.Any 20 | want string 21 | }{ 22 | { 23 | input: asAny(wrapperspb.Bool(true)), 24 | want: "true", 25 | }, 26 | { 27 | input: asAny(wrapperspb.Int32(-1)), 28 | want: "-1", 29 | }, 30 | { 31 | input: asAny(wrapperspb.Int64(2)), 32 | want: "2", 33 | }, 34 | { 35 | input: asAny(wrapperspb.UInt32(3)), 36 | want: "3", 37 | }, 38 | { 39 | input: asAny(wrapperspb.UInt64(4)), 40 | want: "4", 41 | }, 42 | { 43 | input: asAny(wrapperspb.Float(1.25)), 44 | want: "1.25", 45 | }, 46 | { 47 | input: asAny(wrapperspb.Double(3.14)), 48 | want: "3.14", 49 | }, 50 | { 51 | input: asAny(wrapperspb.String("foo")), 52 | want: `"foo"`, 53 | }, 54 | { 55 | input: asAny(wrapperspb.Bytes([]byte("foobar"))), 56 | want: "bytes(foob...)", 57 | }, 58 | { 59 | input: asAny(timestamppb.New(time.Date(2024, time.June, 25, 10, 56, 11, 1234, time.UTC))), 60 | want: "2024-06-25 10:56:11.000001234 +0000 UTC", 61 | }, 62 | { 63 | input: asAny(durationpb.New(1 * time.Second)), 64 | want: "1s", 65 | }, 66 | { 67 | // $ python3 -c 'import pickle; print(pickle.dumps(1))' 68 | // b'\x80\x04K\x01.' 69 | input: pickled([]byte("\x80\x04K\x01.")), 70 | want: "1", 71 | }, 72 | { 73 | // Legacy way that the Python SDK wrapped pickled values: 74 | input: asAny(wrapperspb.Bytes([]byte("\x80\x04K\x01."))), 75 | want: "1", 76 | }, 77 | { 78 | // $ python3 -c 'import pickle; print(pickle.dumps("bar"))' 79 | // b'\x80\x04\x95\x07\x00\x00\x00\x00\x00\x00\x00\x8c\x03foo\x94.' 80 | input: pickled([]byte("\x80\x04\x95\x07\x00\x00\x00\x00\x00\x00\x00\x8c\x03bar\x94.")), 81 | want: `"bar"`, 82 | }, 83 | { 84 | input: pickled([]byte("!!!invalid!!!")), 85 | want: "buf.build/stealthrocket/dispatch-proto/dispatch.sdk.python.v1.Pickled(?)", 86 | }, 87 | { 88 | input: &anypb.Any{TypeUrl: "com.example/some.Message"}, 89 | want: "com.example/some.Message(?)", 90 | }, 91 | { 92 | input: asAny(&emptypb.Empty{}), 93 | want: "empty()", 94 | }, 95 | { 96 | input: asAny(structpb.NewNullValue()), 97 | want: "null", 98 | }, 99 | { 100 | input: asAny(structpb.NewBoolValue(false)), 101 | want: "false", 102 | }, 103 | { 104 | input: asAny(structpb.NewNumberValue(1111)), 105 | want: "1111", 106 | }, 107 | { 108 | input: asAny(structpb.NewNumberValue(3.14)), 109 | want: "3.14", 110 | }, 111 | { 112 | input: asAny(structpb.NewStringValue("foobar")), 113 | want: `"foobar"`, 114 | }, 115 | { 116 | input: asStructValue([]any{1, true, "abc", nil, map[string]any{}, []any{}}), 117 | want: `[1, true, "abc", null, {}, []]`, 118 | }, 119 | { 120 | input: asStructValue(map[string]any{"foo": []any{"bar", "baz"}}), 121 | want: `{"foo": ["bar", "baz"]}`, 122 | }, 123 | } { 124 | t.Run(test.want, func(*testing.T) { 125 | got := anyString(test.input) 126 | if got != test.want { 127 | t.Errorf("unexpected string: got %v, want %v", got, test.want) 128 | } 129 | }) 130 | } 131 | } 132 | 133 | func asAny(m proto.Message) *anypb.Any { 134 | any, err := anypb.New(m) 135 | if err != nil { 136 | panic(err) 137 | } 138 | return any 139 | } 140 | 141 | func asStructValue(v any) *anypb.Any { 142 | m, err := structpb.NewValue(v) 143 | if err != nil { 144 | panic(err) 145 | } 146 | return asAny(m) 147 | } 148 | 149 | func pickled(b []byte) *anypb.Any { 150 | m := &pythonv1.Pickled{PickledValue: b} 151 | mb, err := proto.Marshal(m) 152 | if err != nil { 153 | panic(err) 154 | } 155 | return &anypb.Any{ 156 | TypeUrl: "buf.build/stealthrocket/dispatch-proto/" + string(m.ProtoReflect().Descriptor().FullName()), 157 | Value: mb, 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /cli/color.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import "github.com/charmbracelet/lipgloss" 4 | 5 | var ( 6 | defaultColor = lipgloss.NoColor{} 7 | 8 | // See https://www.hackitu.de/termcolor256/ 9 | grayColor = lipgloss.ANSIColor(102) 10 | redColor = lipgloss.ANSIColor(160) 11 | greenColor = lipgloss.ANSIColor(34) 12 | yellowColor = lipgloss.ANSIColor(142) 13 | magentaColor = lipgloss.ANSIColor(127) 14 | ) 15 | -------------------------------------------------------------------------------- /cli/config.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "bufio" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "log/slog" 9 | "os" 10 | "path/filepath" 11 | 12 | "github.com/joho/godotenv" 13 | "github.com/pelletier/go-toml/v2" 14 | "golang.org/x/term" 15 | ) 16 | 17 | var ( 18 | DispatchApiKey string 19 | DispatchApiKeyCli string 20 | DispatchApiKeyLocation string 21 | 22 | DispatchApiUrl string 23 | DispatchBridgeUrl string 24 | DispatchBridgeHostHeader string 25 | DispatchConsoleUrl string 26 | 27 | DispatchConfigPath string 28 | 29 | DotEnvFilePath string 30 | ) 31 | 32 | func init() { 33 | setVariables() 34 | } 35 | 36 | func setVariables() { 37 | DispatchApiUrl = os.Getenv("DISPATCH_API_URL") 38 | if DispatchApiUrl == "" { 39 | DispatchApiUrl = "https://api.dispatch.run" 40 | } 41 | DispatchBridgeUrl = os.Getenv("DISPATCH_BRIDGE_URL") 42 | if DispatchBridgeUrl == "" { 43 | DispatchBridgeUrl = "https://bridge.dispatch.run" 44 | } 45 | DispatchBridgeHostHeader = os.Getenv("DISPATCH_BRIDGE_HOST_HEADER") 46 | 47 | DispatchConsoleUrl = os.Getenv("DISPATCH_CONSOLE_URL") 48 | if DispatchConsoleUrl == "" { 49 | DispatchConsoleUrl = "https://console.dispatch.run" 50 | } 51 | 52 | if configPath := os.Getenv("DISPATCH_CONFIG_PATH"); configPath != "" { 53 | DispatchConfigPath = configPath 54 | } else { 55 | // https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html 56 | configHome := os.Getenv("XDG_CONFIG_HOME") 57 | if configHome == "" { 58 | configHome = "$HOME/.config" 59 | } 60 | DispatchConfigPath = filepath.Join(os.ExpandEnv(configHome), "dispatch/config.toml") 61 | } 62 | } 63 | 64 | func isTerminal(f *os.File) bool { 65 | return term.IsTerminal(int(f.Fd())) 66 | } 67 | 68 | type Config struct { 69 | // Warning is printed as a comment at the beginning of the configuration file. 70 | Warning string `toml:",commented"` 71 | 72 | // Active is the active organization. 73 | Active string `toml:"active,omitempty"` 74 | 75 | // Organization is the set of organizations and their API keys. 76 | Organization map[string]Organization `toml:"Organizations"` 77 | } 78 | 79 | type Organization struct { 80 | APIKey string `toml:"api_key"` 81 | } 82 | 83 | func CreateConfig(path string, config *Config) error { 84 | pathdir := filepath.Dir(path) 85 | if err := os.MkdirAll(pathdir, 0755); err != nil { 86 | return fmt.Errorf("failed to create config directory %v: %w", pathdir, err) 87 | } 88 | fh, err := os.Create(path) 89 | if err != nil { 90 | return fmt.Errorf("failed to create config file %v: %w", path, err) 91 | } 92 | defer fh.Close() 93 | return writeConfig(fh, config) 94 | } 95 | 96 | func writeConfig(w io.Writer, config *Config) error { 97 | e := toml.NewEncoder(w) 98 | return e.Encode(config) 99 | } 100 | 101 | // TODO: validate configuration to ensure only one organization is active. 102 | func LoadConfig(path string) (*Config, error) { 103 | fh, err := os.Open(path) 104 | if err != nil { 105 | return nil, err 106 | } 107 | defer fh.Close() 108 | return loadConfig(bufio.NewReader(fh)) 109 | } 110 | 111 | func loadConfig(r io.Reader) (*Config, error) { 112 | d := toml.NewDecoder(r) 113 | var c Config 114 | if err := d.Decode(&c); err != nil { 115 | return nil, err 116 | } 117 | return &c, nil 118 | } 119 | 120 | func runConfigFlow() error { 121 | config, err := LoadConfig(DispatchConfigPath) 122 | if err != nil { 123 | if !errors.Is(err, os.ErrNotExist) { 124 | return fmt.Errorf("failed to load configuration from %s: %v", DispatchConfigPath, err) 125 | } 126 | } 127 | 128 | if config != nil && config.Active != "" { 129 | org, ok := config.Organization[config.Active] 130 | if !ok { 131 | return fmt.Errorf("invalid active organization '%s' found in configuration. Please run `dispatch login` or `dispatch switch`", config.Active) 132 | } 133 | DispatchApiKey = org.APIKey 134 | DispatchApiKeyLocation = "config" 135 | } 136 | 137 | if key := os.Getenv("DISPATCH_API_KEY"); key != "" { 138 | DispatchApiKey = key 139 | DispatchApiKeyLocation = "env" 140 | } 141 | 142 | if key := DispatchApiKeyCli; key != "" { 143 | DispatchApiKey = key 144 | DispatchApiKeyLocation = "cli" 145 | } 146 | 147 | if DispatchApiKey == "" { 148 | if config != nil && len(config.Organization) > 0 { 149 | return fmt.Errorf("No organization selected. Please run `dispatch switch` to select one.") 150 | } 151 | return fmt.Errorf("Please run `dispatch login` to login to Dispatch. Alternatively, set the DISPATCH_API_KEY environment variable, or provide an --api-key (-k) on the command line.") 152 | } 153 | return nil 154 | } 155 | 156 | func loadEnvFromFile(path string) error { 157 | if path != "" { 158 | absolutePath, err := filepath.Abs(path) 159 | if err != nil { 160 | return fmt.Errorf("failed to get absolute path for %s: %v", path, err) 161 | } 162 | if err := godotenv.Load(path); err != nil { 163 | return fmt.Errorf("failed to load env file from %s: %v", absolutePath, err) 164 | } 165 | slog.Info("loading environment variables from file", "path", absolutePath) 166 | } 167 | setVariables() 168 | return nil 169 | } 170 | -------------------------------------------------------------------------------- /cli/console.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "time" 9 | ) 10 | 11 | type console struct{} 12 | 13 | func (c *console) Login(token string) error { 14 | clilogin := &clilogin{} 15 | 16 | for { 17 | url := fmt.Sprintf("%s/cli-login/token", DispatchConsoleUrl) 18 | req, err := http.NewRequest("GET", url, nil) 19 | if err != nil { 20 | return err 21 | } 22 | req.Header.Set("Content-Type", "application/json") 23 | 24 | values := req.URL.Query() 25 | values.Add("token", token) 26 | req.URL.RawQuery = values.Encode() 27 | 28 | resp, err := http.DefaultClient.Do(req) 29 | if err != nil { 30 | return err 31 | } 32 | defer resp.Body.Close() 33 | 34 | // If 204, the token was not created yet, retry 35 | if resp.StatusCode == http.StatusNoContent { 36 | time.Sleep(1 * time.Second) 37 | continue 38 | } 39 | 40 | if resp.StatusCode != http.StatusOK { 41 | return fmt.Errorf("login failed with status %d", resp.StatusCode) 42 | } 43 | 44 | data, err := io.ReadAll(resp.Body) 45 | if err != nil { 46 | return err 47 | } 48 | 49 | if err := json.Unmarshal(data, clilogin); err != nil { 50 | return fmt.Errorf("failed to unmarshal login response: %w", err) 51 | } 52 | break 53 | } 54 | 55 | var config Config 56 | config.Warning = "THIS FILE IS GENERATED. DO NOT EDIT!" 57 | config.Organization = map[string]Organization{} 58 | 59 | for i, org := range clilogin.Organizations { 60 | config.Organization[org.Slug] = Organization{APIKey: org.ApiKey} 61 | if i == 0 { 62 | config.Active = org.Slug 63 | } 64 | } 65 | 66 | if err := CreateConfig(DispatchConfigPath, &config); err != nil { 67 | return fmt.Errorf("failed to create config: %w", err) 68 | } 69 | return nil 70 | } 71 | 72 | type clilogin struct { 73 | Organizations []struct { 74 | Slug string `json:"slug"` 75 | ApiKey string `json:"api_key"` 76 | } `json:"organizations"` 77 | } 78 | -------------------------------------------------------------------------------- /cli/dispatch.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "io" 8 | "net/http" 9 | ) 10 | 11 | type dispatchApi struct { 12 | client *http.Client 13 | apiKey string 14 | } 15 | 16 | func (d *dispatchApi) ListSigningKeys() (*ListSigningKeys, error) { 17 | req, err := http.NewRequest( 18 | "POST", 19 | DispatchApiUrl+"/dispatch.v1.SigningKeyService/ListSigningKeys", 20 | bytes.NewBuffer([]byte("{}")), 21 | ) 22 | if err != nil { 23 | return nil, err 24 | } 25 | req.Header.Set("Content-Type", "application/json") 26 | req.Header.Set("Authorization", "Bearer "+d.apiKey) 27 | resp, err := d.client.Do(req) 28 | if err != nil { 29 | return nil, err 30 | } 31 | switch resp.StatusCode { 32 | case http.StatusUnauthorized: 33 | return nil, authError{} 34 | case http.StatusOK: 35 | // continue 36 | default: 37 | return nil, errors.New("failed to list signing keys, status: " + resp.Status) 38 | } 39 | body, err := io.ReadAll(resp.Body) 40 | defer resp.Body.Close() 41 | if err != nil { 42 | return nil, err 43 | } 44 | skeys := &ListSigningKeys{} 45 | if err := json.Unmarshal(body, skeys); err != nil { 46 | return nil, err 47 | } 48 | return skeys, nil 49 | } 50 | 51 | func (d *dispatchApi) CreateSigningKey() (*SigningKey, error) { 52 | req, err := http.NewRequest( 53 | "POST", 54 | DispatchApiUrl+"/dispatch.v1.SigningKeyService/CreateSigningKey", 55 | bytes.NewBuffer([]byte("{}")), 56 | ) 57 | if err != nil { 58 | return nil, err 59 | } 60 | req.Header.Set("Content-Type", "application/json") 61 | req.Header.Set("Authorization", "Bearer "+d.apiKey) 62 | resp, err := d.client.Do(req) 63 | if err != nil { 64 | return nil, err 65 | } 66 | switch resp.StatusCode { 67 | case http.StatusUnauthorized: 68 | return nil, authError{} 69 | case http.StatusOK: 70 | // continue 71 | default: 72 | return nil, errors.New("failed to list signing keys, status: " + resp.Status) 73 | } 74 | body, err := io.ReadAll(resp.Body) 75 | defer resp.Body.Close() 76 | if err != nil { 77 | return nil, err 78 | } 79 | skey := &SigningKey{} 80 | if err := json.Unmarshal(body, skey); err != nil { 81 | return nil, err 82 | } 83 | return skey, nil 84 | } 85 | -------------------------------------------------------------------------------- /cli/error.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import "fmt" 4 | 5 | type authError struct{} 6 | 7 | func (authError) Error() string { 8 | const message = "Authentication error" 9 | var detail string 10 | switch DispatchApiKeyLocation { 11 | case "env": 12 | detail = "check DISPATCH_API_KEY environment variable" 13 | case "cli": 14 | detail = "check the -k,--api-key command-line option" 15 | default: 16 | detail = "please login again using: dispatch login" 17 | } 18 | return fmt.Sprintf("%s (%s)", message, detail) 19 | } 20 | -------------------------------------------------------------------------------- /cli/generate_docs.go: -------------------------------------------------------------------------------- 1 | //go:build docs 2 | 3 | package cli 4 | 5 | import ( 6 | "bytes" 7 | "os" 8 | "path" 9 | "strings" 10 | 11 | "github.com/spf13/cobra" 12 | "github.com/spf13/cobra/doc" 13 | ) 14 | 15 | const DispatchCmdLong = "This is the main command for Dispatch CLI. Add a subcommand to make it useful." 16 | 17 | const RunExampleText = "```\ndispatch run [options] -- \n```" 18 | 19 | func generateDocs(cmd *cobra.Command, title string) { 20 | cmd.DisableAutoGenTag = true 21 | 22 | // create docs directory 23 | _ = os.Mkdir("./docs", 0755) 24 | 25 | out := new(bytes.Buffer) 26 | 27 | err := doc.GenMarkdownCustom(cmd, out, func(name string) string { 28 | // err := doc.GenMarkdownCustom(cmd, out, func(name string) string { 29 | base := strings.TrimSuffix(name, path.Ext(name)) 30 | return "/cli/" + strings.ToLower(base) + "/" 31 | }) 32 | if err != nil { 33 | panic(err) 34 | } 35 | 36 | // Define the text to be replaced and the replacement text 37 | oldText := []byte("## " + title) 38 | newText := []byte("---\ntitle: " + title + "\n---") 39 | 40 | // Perform the replacement on the buffer's content 41 | updatedContent := bytes.Replace(out.Bytes(), oldText, newText, 1) 42 | 43 | // Reset the buffer and write the updated content back to it 44 | out.Reset() 45 | out.Write(updatedContent) 46 | 47 | // write markdown to file 48 | file, err := os.Create("./docs/" + strings.ReplaceAll(title, " ", "_") + ".md") 49 | if err != nil { 50 | panic(err) 51 | } 52 | 53 | _, err = file.Write(out.Bytes()) 54 | if err != nil { 55 | panic(err) 56 | } 57 | 58 | defer file.Close() 59 | 60 | // if command has subcommands, generate markdown for each subcommand 61 | if cmd.HasSubCommands() { 62 | for _, c := range cmd.Commands() { 63 | // if c.Use starts with "help", skip it 64 | if c.Name() == "help" { 65 | continue 66 | } 67 | generateDocs(c, title+" "+c.Name()) 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /cli/generate_docs_default.go: -------------------------------------------------------------------------------- 1 | //go:build !docs 2 | 3 | package cli 4 | 5 | import "github.com/spf13/cobra" 6 | 7 | const DispatchCmdLong = `Welcome to Dispatch! 8 | 9 | To get started, use the login command to authenticate with Dispatch or create an account. 10 | 11 | Documentation: https://docs.dispatch.run 12 | Discord: https://dispatch.run/discord 13 | Support: support@dispatch.run 14 | ` 15 | 16 | const RunExampleText = " dispatch run [options] -- " 17 | 18 | func generateDocs(_ *cobra.Command, _ string) { 19 | // do nothing if the build tag "docs" is not set 20 | } 21 | -------------------------------------------------------------------------------- /cli/init.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "archive/tar" 5 | "bufio" 6 | "compress/gzip" 7 | "encoding/json" 8 | "fmt" 9 | "io" 10 | "net/http" 11 | "os" 12 | "path/filepath" 13 | "runtime" 14 | "strings" 15 | 16 | "github.com/spf13/cobra" 17 | ) 18 | 19 | const ( 20 | githubTarballURL = "https://github.com/%s/tarball/main" 21 | githubAPIURL = "https://api.github.com/repos/%s/branches/main" 22 | repo = "dispatchrun/dispatch-templates" 23 | dispatchUserDir = "dispatch" 24 | ) 25 | 26 | func directoryExists(path string) (bool, error) { 27 | info, err := os.Stat(path) 28 | if os.IsNotExist(err) { 29 | return false, nil 30 | } 31 | if err != nil { 32 | return false, err 33 | } 34 | return info.IsDir(), nil 35 | } 36 | 37 | func isDirectoryEmpty(path string) (bool, error) { 38 | dir, err := os.Open(path) 39 | if err != nil { 40 | return false, err 41 | } 42 | defer dir.Close() 43 | 44 | // Read directory names, limiting to one to check if it's not empty 45 | _, err = dir.Readdirnames(1) 46 | if err == nil { 47 | // The directory is not empty 48 | return false, nil 49 | } 50 | if err == io.EOF { 51 | // The directory is empty 52 | return true, nil 53 | } 54 | // Some other error occurred 55 | return false, err 56 | } 57 | 58 | func downloadAndExtractTemplates(destDir string) error { 59 | url := fmt.Sprintf(githubTarballURL, repo) 60 | resp, err := http.Get(url) 61 | if err != nil { 62 | return err 63 | } 64 | defer resp.Body.Close() 65 | 66 | if resp.StatusCode != http.StatusOK { 67 | return fmt.Errorf("failed to download templates: %s", resp.Status) 68 | } 69 | 70 | return extractTarball(resp.Body, destDir) 71 | } 72 | 73 | func extractTarball(r io.Reader, destDir string) error { 74 | gzr, err := gzip.NewReader(r) 75 | if err != nil { 76 | return err 77 | } 78 | defer gzr.Close() 79 | 80 | tr := tar.NewReader(gzr) 81 | 82 | var topLevelDir string 83 | 84 | for { 85 | header, err := tr.Next() 86 | switch { 87 | case err == io.EOF: 88 | return nil 89 | case err != nil: 90 | return err 91 | case header == nil: 92 | continue 93 | } 94 | 95 | // We need to strip the top-level directory from the file paths 96 | // It contains the repository name and the commit SHA which we don't need 97 | // Get the top-level directory name 98 | if topLevelDir == "" { 99 | parts := strings.Split(header.Name, "/") 100 | if len(parts) > 1 { 101 | topLevelDir = parts[0] 102 | } 103 | } 104 | 105 | // Strip the top-level directory from the file path 106 | relPath := strings.TrimPrefix(header.Name, topLevelDir+"/") 107 | target := filepath.Join(destDir, relPath) 108 | 109 | // fmt.Printf("Extracting to %s\n", target) 110 | 111 | switch header.Typeflag { 112 | case tar.TypeDir: 113 | if err := os.MkdirAll(target, os.FileMode(header.Mode)); err != nil { 114 | return err 115 | } 116 | case tar.TypeReg: 117 | if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { 118 | return err 119 | } 120 | file, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY, os.FileMode(header.Mode)) 121 | if err != nil { 122 | return err 123 | } 124 | if _, err := io.Copy(file, tr); err != nil { 125 | file.Close() 126 | return err 127 | } 128 | file.Close() 129 | } 130 | } 131 | } 132 | 133 | func getAppDataDir(appName string) (string, error) { 134 | var configDir string 135 | var err error 136 | 137 | switch runtime.GOOS { 138 | case "windows": 139 | configDir, err = os.UserConfigDir() 140 | if err != nil { 141 | return "", err 142 | } 143 | case "darwin": 144 | configDir, err = os.UserConfigDir() 145 | if err != nil { 146 | return "", err 147 | } 148 | default: // "linux" and other Unix-like systems 149 | configDir = os.Getenv("XDG_CONFIG_HOME") 150 | if configDir == "" { 151 | configDir, err = os.UserConfigDir() 152 | if err != nil { 153 | return "", err 154 | } 155 | } 156 | } 157 | 158 | appDataDir := filepath.Join(configDir, appName) 159 | err = os.MkdirAll(appDataDir, 0755) 160 | if err != nil { 161 | return "", err 162 | } 163 | 164 | return appDataDir, nil 165 | } 166 | 167 | func getLatestCommitSHA(url string) (string, error) { 168 | resp, err := http.Get(url) 169 | if err != nil { 170 | return "", err 171 | } 172 | defer resp.Body.Close() 173 | 174 | if resp.StatusCode != http.StatusOK { 175 | return "", fmt.Errorf("failed to get latest commit SHA: %s", resp.Status) 176 | } 177 | 178 | var result struct { 179 | Commit struct { 180 | SHA string `json:"sha"` 181 | } `json:"commit"` 182 | } 183 | err = json.NewDecoder(resp.Body).Decode(&result) 184 | if err != nil { 185 | return "", err 186 | } 187 | 188 | return result.Commit.SHA, nil 189 | } 190 | 191 | func readDirectories(path string) ([]string, error) { 192 | files, err := os.ReadDir(path) 193 | if err != nil { 194 | return nil, err 195 | } 196 | 197 | var directories []string 198 | for _, file := range files { 199 | if file.IsDir() { 200 | directories = append(directories, file.Name()) 201 | } 202 | } 203 | 204 | return directories, nil 205 | } 206 | 207 | func copyDir(src string, dst string) error { 208 | return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { 209 | if err != nil { 210 | return err 211 | } 212 | 213 | // Construct the destination path 214 | relPath, err := filepath.Rel(src, path) 215 | if err != nil { 216 | return err 217 | } 218 | dstPath := filepath.Join(dst, relPath) 219 | 220 | if info.IsDir() { 221 | // Create the directory 222 | if err := os.MkdirAll(dstPath, info.Mode()); err != nil { 223 | return err 224 | } 225 | } else { 226 | // Copy the file 227 | if err := copyFile(path, dstPath); err != nil { 228 | return err 229 | } 230 | } 231 | return nil 232 | }) 233 | } 234 | 235 | func copyFile(srcFile string, dstFile string) error { 236 | src, err := os.Open(srcFile) 237 | if err != nil { 238 | return err 239 | } 240 | defer src.Close() 241 | 242 | dst, err := os.Create(dstFile) 243 | if err != nil { 244 | return err 245 | } 246 | defer dst.Close() 247 | 248 | if _, err := io.Copy(dst, src); err != nil { 249 | return err 250 | } 251 | 252 | // Copy file permissions 253 | srcInfo, err := os.Stat(srcFile) 254 | if err != nil { 255 | return err 256 | } 257 | return os.Chmod(dstFile, srcInfo.Mode()) 258 | } 259 | 260 | func prepareGoTemplate(path string) error { 261 | moduleName := filepath.Base(path) 262 | goModPath := filepath.Join(path, "go.mod") 263 | moduleHeader := fmt.Sprintf("module %s", moduleName) 264 | 265 | // Update the go.mod file with the correct module name 266 | file, err := os.Open(goModPath) 267 | if err != nil { 268 | return err 269 | } 270 | defer file.Close() 271 | 272 | // Read all lines from the file 273 | scanner := bufio.NewScanner(file) 274 | var lines []string 275 | for scanner.Scan() { 276 | lines = append(lines, scanner.Text()) 277 | } 278 | 279 | if err := scanner.Err(); err != nil { 280 | return err 281 | } 282 | 283 | // replace the module header 284 | lines[0] = moduleHeader 285 | 286 | // Join the lines back into a single string with newlines 287 | output := strings.Join(lines, "\n") + "\n" 288 | 289 | // Write the modified content back to the file 290 | err = os.WriteFile(goModPath, []byte(output), 0644) 291 | if err != nil { 292 | return err 293 | } 294 | 295 | // TODO: create .gitignore file? 296 | 297 | return nil 298 | } 299 | 300 | func initRunE(cmd *cobra.Command, args []string) error { 301 | // get or create the Dispatch templates directory 302 | dispatchUserDirPath, err := getAppDataDir(dispatchUserDir) 303 | if err != nil { 304 | fmt.Printf("failed to get Dispatch templates directory: %s", err) 305 | } 306 | 307 | // well-known paths for Dispatch templates 308 | dispatchTemplatesDirPath := filepath.Join(dispatchUserDirPath, "templates") 309 | dispatchTemplatesHashPath := filepath.Join(dispatchUserDirPath, "templates.sha") 310 | 311 | // read the latest commit SHA 312 | sha, err := os.ReadFile(dispatchTemplatesHashPath) 313 | if err != nil { 314 | if !os.IsNotExist(err) { 315 | cmd.SilenceUsage = true 316 | cmd.PrintErrf("failed to read templates SHA: %s", err) 317 | } 318 | } 319 | 320 | // get the latest commit SHA from the templates repository 321 | url := fmt.Sprintf(githubAPIURL, repo) 322 | remoteSHA, err := getLatestCommitSHA(url) 323 | if err != nil { 324 | cmd.Printf("failed to get latest commit SHA: %v", err) 325 | } 326 | 327 | // update the templates if the latest commit SHA is different 328 | if remoteSHA != "" && string(sha) != remoteSHA { 329 | cmd.Printf("Downloading templates update...\n") 330 | err = downloadAndExtractTemplates(dispatchTemplatesDirPath) 331 | if err != nil { 332 | cmd.Printf("failed to download and extract templates: %v", err) 333 | } else { 334 | cmd.Print("Templates have been updated\n\n") 335 | // TODO: possible improvement: 336 | // find which templates have been added/removed/modified 337 | // and/or 338 | // show last n commit messages as changes 339 | } 340 | 341 | // save the latest commit SHA 342 | err = os.WriteFile(dispatchTemplatesHashPath, []byte(remoteSHA), 0644) 343 | if err != nil { 344 | cmd.Printf("failed to save templates SHA: %v", err) 345 | } 346 | } 347 | 348 | // read the available templates 349 | templates, err := readDirectories(dispatchTemplatesDirPath) 350 | 351 | if err != nil { 352 | cmd.SilenceUsage = true 353 | if os.IsNotExist(err) { 354 | cmd.PrintErrf("templates directory does not exist in %s. Please run `dispatch init` to download the templates", dispatchTemplatesDirPath) 355 | } 356 | cmd.PrintErrf("failed to read templates directory. : %s", err) 357 | } 358 | 359 | if len(templates) == 0 { 360 | cmd.SilenceUsage = true 361 | return fmt.Errorf("templates directory %s is corrupted. Please clean it and try again", dispatchTemplatesDirPath) 362 | } 363 | 364 | var templatesList string = "" 365 | 366 | for _, template := range templates { 367 | templatesList += " " + template + "\n" 368 | } 369 | cmd.SetUsageTemplate(cmd.UsageTemplate() + "\nAvailable templates:\n" + templatesList) 370 | 371 | // if no arguments are provided (user wants to download/update templates only), print the usage 372 | if len(args) == 0 { 373 | cmd.Print(cmd.UsageString()) 374 | return nil 375 | } 376 | 377 | var directory string 378 | var exists = true 379 | 380 | wantedTemplate := args[0] 381 | isTemplateFound := false 382 | 383 | // find template in the available templates 384 | for _, template := range templates { 385 | if template == wantedTemplate { 386 | isTemplateFound = true 387 | break 388 | } 389 | } 390 | 391 | if !isTemplateFound { 392 | cmd.SilenceUsage = true 393 | cmd.Printf("Template %s is not supported.\n\nAvailable templates:\n%s", wantedTemplate, templatesList) 394 | return nil 395 | } 396 | 397 | // check if a directory is provided 398 | if len(args) > 1 { 399 | directory = args[1] 400 | flag, err := directoryExists(directory) 401 | exists = flag 402 | 403 | if err != nil { 404 | cmd.SilenceUsage = true 405 | return fmt.Errorf("failed to check if directory exists: %w", err) 406 | } 407 | 408 | // create the directory if it doesn't exist 409 | if !exists { 410 | err := os.MkdirAll(directory, 0755) 411 | if err != nil { 412 | cmd.SilenceUsage = true 413 | return fmt.Errorf("failed to create directory %v: %w", directory, err) 414 | } 415 | exists = true 416 | } 417 | } else { 418 | directory = "." 419 | } 420 | 421 | // check if the if directory exists and is empty 422 | if exists { 423 | isEmpty, err := isDirectoryEmpty(directory) 424 | cmd.SilenceUsage = true 425 | if err != nil { 426 | return fmt.Errorf("failed to check if directory is empty: %w", err) 427 | } 428 | if !isEmpty { 429 | return fmt.Errorf("could not create template in %s: directory is not empty", directory) 430 | } 431 | } 432 | path, err := filepath.Abs(directory) 433 | if err != nil { 434 | cmd.SilenceUsage = true 435 | return fmt.Errorf("failed to get absolute path: %w", err) 436 | } 437 | 438 | cmd.Printf("Template %s was created in %s\n", wantedTemplate, path) 439 | 440 | // copy the template to the destination 441 | err = copyDir(filepath.Join(dispatchTemplatesDirPath, wantedTemplate), path) 442 | if err != nil { 443 | cmd.SilenceUsage = true 444 | return fmt.Errorf("failed to copy template: %w", err) 445 | } 446 | 447 | switch wantedTemplate { 448 | case "go": 449 | err := prepareGoTemplate(path) 450 | if err != nil { 451 | cmd.SilenceUsage = true 452 | return fmt.Errorf("failed to prepare Go template: %w", err) 453 | } 454 | } 455 | 456 | return nil 457 | } 458 | 459 | func initCommand() *cobra.Command { 460 | cmd := &cobra.Command{ 461 | Use: "init