├── .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 |
5 |
6 |
7 |
8 |
9 |
10 | [](https://github.com/dispatchrun/dispatch/actions/workflows/build.yml)
11 | [](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 [path]",
462 | Short: "Initialize a new Dispatch project",
463 | GroupID: "dispatch",
464 | RunE: initRunE,
465 | }
466 |
467 | return cmd
468 | }
469 |
--------------------------------------------------------------------------------
/cli/init_test.go:
--------------------------------------------------------------------------------
1 | package cli
2 |
3 | import (
4 | "bufio"
5 | "os"
6 | "testing"
7 |
8 | "github.com/stretchr/testify/assert"
9 | )
10 |
11 | func TestInitCommand(t *testing.T) {
12 | t.Run("directoryExists returns false for non-existent directory", func(t *testing.T) {
13 | t.Parallel()
14 |
15 | result, _ := directoryExists("nonexistentdirectory")
16 | assert.False(t, result)
17 | })
18 |
19 | t.Run("directoryExists returns true for existing directory", func(t *testing.T) {
20 | t.Parallel()
21 |
22 | tempDir := t.TempDir()
23 |
24 | result, _ := directoryExists(tempDir)
25 | assert.True(t, result)
26 | })
27 |
28 | t.Run("directoryExists returns false for file", func(t *testing.T) {
29 | t.Parallel()
30 |
31 | tempFile := t.TempDir() + "/tempfile"
32 | file, err := os.Create(tempFile)
33 | assert.Nil(t, err)
34 |
35 | result, _ := directoryExists(tempFile)
36 | assert.False(t, result)
37 |
38 | // Clean up
39 | err = file.Close()
40 | assert.Nil(t, err)
41 | })
42 |
43 | t.Run("isDirectoryEmpty returns true for empty directory", func(t *testing.T) {
44 | t.Parallel()
45 |
46 | tempDir := t.TempDir()
47 |
48 | result, _ := isDirectoryEmpty(tempDir)
49 | assert.True(t, result)
50 | })
51 |
52 | t.Run("isDirectoryEmpty returns false for non-empty directory", func(t *testing.T) {
53 | t.Parallel()
54 |
55 | tempDir := t.TempDir()
56 |
57 | tempFile := tempDir + "/tempfile"
58 | file, err := os.Create(tempFile)
59 | assert.Nil(t, err)
60 |
61 | result, _ := isDirectoryEmpty(tempDir)
62 | assert.False(t, result)
63 |
64 | // Clean up
65 | err = file.Close()
66 | assert.Nil(t, err)
67 | })
68 |
69 | t.Run("downloadAndExtractTemplates downloads and extracts templates", func(t *testing.T) {
70 | t.Parallel()
71 |
72 | tempDir := t.TempDir()
73 |
74 | err := downloadAndExtractTemplates(tempDir)
75 | assert.Nil(t, err)
76 |
77 | // Check if the templates directory was created
78 | result, _ := isDirectoryEmpty(tempDir)
79 | assert.False(t, result)
80 | })
81 |
82 | t.Run("getLatestCommitSHA returns the latest commit SHA", func(t *testing.T) {
83 | t.Parallel()
84 |
85 | sha, err := getLatestCommitSHA("https://api.github.com/repos/dispatchrun/dispatch/branches/main")
86 | assert.Nil(t, err)
87 | assert.Regexp(t, "^[a-f0-9]{40}$", sha)
88 | })
89 |
90 | t.Run("getLatestCommitSHA returns an error for invalid URL", func(t *testing.T) {
91 | t.Parallel()
92 |
93 | _, err := getLatestCommitSHA("invalidurl")
94 | assert.NotNil(t, err)
95 | })
96 |
97 | t.Run("copyFile copies file", func(t *testing.T) {
98 | t.Parallel()
99 |
100 | tempDir := t.TempDir()
101 |
102 | src := tempDir + "/srcfile"
103 | dest := tempDir + "/destfile"
104 |
105 | file, err := os.Create(src)
106 | assert.Nil(t, err)
107 |
108 | err = copyFile(src, dest)
109 | assert.Nil(t, err)
110 |
111 | _, err = os.Stat(dest)
112 | assert.Nil(t, err)
113 |
114 | // Clean up
115 | err = file.Close()
116 | assert.Nil(t, err)
117 | })
118 |
119 | t.Run("copyDir copies directory", func(t *testing.T) {
120 | t.Parallel()
121 |
122 | tempDir := t.TempDir()
123 |
124 | src := tempDir + "/srcdir"
125 | dest := tempDir + "/destdir"
126 |
127 | err := os.Mkdir(src, 0755)
128 | assert.Nil(t, err)
129 |
130 | err = copyDir(src, dest)
131 | assert.Nil(t, err)
132 |
133 | _, err = os.Stat(dest)
134 | assert.Nil(t, err)
135 | })
136 |
137 | t.Run("readDirectories returns all subdirectories", func(t *testing.T) {
138 | t.Parallel()
139 |
140 | tempDir := t.TempDir()
141 |
142 | dir1 := tempDir + "/dir1"
143 | err := os.Mkdir(dir1, 0755)
144 | assert.Nil(t, err)
145 |
146 | dir2 := tempDir + "/dir2"
147 | err = os.Mkdir(dir2, 0755)
148 | assert.Nil(t, err)
149 |
150 | dirs, err := readDirectories(tempDir)
151 | assert.Nil(t, err)
152 | assert.ElementsMatch(t, []string{"dir1", "dir2"}, dirs)
153 | })
154 |
155 | t.Run("prepareGoTemplate updates go.mod", func(t *testing.T) {
156 | t.Parallel()
157 |
158 | tempDir := t.TempDir()
159 | projectName := "alpha"
160 |
161 | // create project directory and go.mod file
162 | projectDir := tempDir + "/" + projectName
163 | goModFile := projectDir + "/go.mod"
164 |
165 | err := os.Mkdir(projectDir, 0755)
166 | assert.Nil(t, err)
167 |
168 | file, err := os.Create(goModFile)
169 | assert.Nil(t, err)
170 |
171 | // write some content to the file
172 | _, err = file.WriteString("module randommodule")
173 | assert.Nil(t, err)
174 |
175 | // Clean up
176 | err = file.Close()
177 | assert.Nil(t, err)
178 |
179 | err = prepareGoTemplate(projectDir)
180 | assert.Nil(t, err)
181 |
182 | // read first line of the file using scanner
183 | file, err = os.Open(goModFile)
184 | assert.Nil(t, err)
185 |
186 | scanner := bufio.NewScanner(file)
187 | scanner.Scan()
188 | firstLine := scanner.Text()
189 |
190 | assert.Equal(t, "module "+projectName, firstLine)
191 |
192 | // Clean up
193 | err = file.Close()
194 | assert.Nil(t, err)
195 | })
196 | }
197 |
--------------------------------------------------------------------------------
/cli/log.go:
--------------------------------------------------------------------------------
1 | package cli
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "io"
7 | "log/slog"
8 | "slices"
9 | "sync"
10 |
11 | "github.com/charmbracelet/lipgloss"
12 | )
13 |
14 | var (
15 | logTimeStyle = lipgloss.NewStyle().Foreground(grayColor)
16 | logAttrKeyStyle = lipgloss.NewStyle().Foreground(grayColor)
17 | logAttrValStyle = lipgloss.NewStyle().Foreground(defaultColor)
18 |
19 | logDebugStyle = lipgloss.NewStyle().Foreground(defaultColor)
20 | logInfoStyle = lipgloss.NewStyle().Foreground(defaultColor)
21 | logWarnStyle = lipgloss.NewStyle().Foreground(yellowColor)
22 | logErrorStyle = lipgloss.NewStyle().Foreground(redColor)
23 | )
24 |
25 | type slogHandler struct {
26 | mu sync.Mutex
27 | stream io.Writer
28 |
29 | parent *slogHandler
30 | attrs []slog.Attr
31 | }
32 |
33 | func (h *slogHandler) Enabled(ctx context.Context, level slog.Level) bool {
34 | if Verbose {
35 | return level >= slog.LevelDebug
36 | }
37 | return level >= slog.LevelInfo
38 | }
39 |
40 | func (h *slogHandler) Handle(ctx context.Context, record slog.Record) error {
41 | h.mu.Lock()
42 | defer h.mu.Unlock()
43 |
44 | var b bytes.Buffer
45 | b.WriteString(logTimeStyle.Render(record.Time.Format("2006-01-02 15:04:05.000")))
46 | if record.Level >= slog.LevelWarn {
47 | b.WriteByte(' ')
48 | b.WriteString(levelString(record.Level))
49 | }
50 | b.WriteByte(' ')
51 | b.WriteString(record.Message)
52 | record.Attrs(func(attr slog.Attr) bool {
53 | b.WriteByte(' ')
54 | writeAttr(&b, attr)
55 | return true
56 | })
57 | for _, attr := range h.attrs {
58 | b.WriteByte(' ')
59 | writeAttr(&b, attr)
60 | }
61 | b.WriteByte('\n')
62 |
63 | _, err := h.stream.Write(b.Bytes())
64 | return err
65 | }
66 |
67 | func levelString(level slog.Level) string {
68 | switch level {
69 | case slog.LevelDebug:
70 | return logDebugStyle.Render(level.String())
71 | case slog.LevelInfo:
72 | return logInfoStyle.Render(level.String())
73 | case slog.LevelWarn:
74 | return logWarnStyle.Render(level.String())
75 | case slog.LevelError:
76 | return logErrorStyle.Render(level.String())
77 | default:
78 | return level.String()
79 | }
80 | }
81 |
82 | func writeAttr(b *bytes.Buffer, attr slog.Attr) {
83 | b.WriteString(logAttrKeyStyle.Render(attr.Key + "="))
84 | b.WriteString(logAttrValStyle.Render(attr.Value.String()))
85 | }
86 |
87 | func (h *slogHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
88 | parent := h
89 | if parent.parent != nil {
90 | parent = parent.parent
91 | }
92 | return &slogHandler{
93 | stream: h.stream,
94 | parent: parent,
95 | attrs: append(slices.Clip(parent.attrs), attrs...),
96 | }
97 | }
98 |
99 | func (h *slogHandler) WithGroup(group string) slog.Handler {
100 | panic("not implemented")
101 | }
102 |
103 | type prefixLogWriter struct {
104 | stream io.Writer
105 | prefix []byte
106 | }
107 |
108 | func (p *prefixLogWriter) Write(b []byte) (int, error) {
109 | var buffer bytes.Buffer
110 | if _, err := buffer.Write(p.prefix); err != nil {
111 | return 0, err
112 | }
113 | if _, err := buffer.Write(b); err != nil {
114 | return 0, err
115 | }
116 | return p.stream.Write(buffer.Bytes())
117 | }
118 |
--------------------------------------------------------------------------------
/cli/login.go:
--------------------------------------------------------------------------------
1 | package cli
2 |
3 | import (
4 | "crypto/rand"
5 | "encoding/hex"
6 | "fmt"
7 | "os/exec"
8 | "runtime"
9 |
10 | tea "github.com/charmbracelet/bubbletea"
11 | "github.com/spf13/cobra"
12 | )
13 |
14 | func loginCommand() *cobra.Command {
15 | cmd := &cobra.Command{
16 | Use: "login",
17 | Short: "Login to Dispatch",
18 | Long: `Login to Dispatch.
19 |
20 | The login command will open a browser window where you can create a Dispatch
21 | account or login to an existing account.
22 |
23 | After authenticating with Dispatch, the API key will be persisted locally.`,
24 | GroupID: "management",
25 | RunE: func(cmd *cobra.Command, args []string) error {
26 | token, err := generateToken()
27 | if err != nil {
28 | return err
29 | }
30 |
31 | _ = open(fmt.Sprintf("%s/cli-login?token=%s", DispatchConsoleUrl, token))
32 |
33 | dialog(`Opening the browser for you to sign in to Dispatch.
34 |
35 | If the browser does not open, please visit the following URL:
36 |
37 | %s`, DispatchConsoleUrl+"/cli-login?token="+token)
38 |
39 | console := &console{}
40 |
41 | var loginErr error
42 | var loggedIn bool
43 |
44 | p := tea.NewProgram(newSpinnerModel("Logging in...", func() (tea.Msg, error) {
45 | if err := console.Login(token); err != nil {
46 | loginErr = err
47 | return nil, err
48 | }
49 | loggedIn = true
50 | return nil, nil
51 | }))
52 | if _, err = p.Run(); err != nil {
53 | return err
54 | }
55 |
56 | if loginErr != nil {
57 | failure(cmd, "Authentication failed. Please contact support at support@dispatch.run")
58 | fmt.Printf("Error: %s\n", loginErr)
59 | } else if loggedIn {
60 | success("Authentication successful")
61 | fmt.Printf(
62 | "Configuration file created at %s\n",
63 | DispatchConfigPath,
64 | )
65 | }
66 | return nil
67 | },
68 | }
69 | return cmd
70 | }
71 |
72 | func generateToken() (string, error) {
73 | bytes := make([]byte, 32)
74 | _, err := rand.Read(bytes)
75 | if err != nil {
76 | return "", err
77 | }
78 | return hex.EncodeToString(bytes), nil
79 | }
80 |
81 | func open(url string) error {
82 | var cmd string
83 | var args []string
84 |
85 | switch runtime.GOOS {
86 | case "windows":
87 | cmd = "cmd"
88 | args = []string{"/c", "start"}
89 | case "darwin":
90 | cmd = "open"
91 | default: // "linux", "freebsd", "openbsd", "netbsd"
92 | cmd = "xdg-open"
93 | }
94 | args = append(args, url)
95 | return exec.Command(cmd, args...).Start()
96 | }
97 |
--------------------------------------------------------------------------------
/cli/main.go:
--------------------------------------------------------------------------------
1 | package cli
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/spf13/cobra"
7 | )
8 |
9 | func createMainCommand() *cobra.Command {
10 | cmd := &cobra.Command{
11 | Version: version(),
12 | Use: "dispatch",
13 | Long: DispatchCmdLong,
14 | Short: "Main command for Dispatch CLI",
15 | PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
16 | return loadEnvFromFile(DotEnvFilePath)
17 | },
18 | RunE: func(cmd *cobra.Command, args []string) error {
19 | return cmd.Help()
20 | },
21 | }
22 |
23 | cmd.PersistentFlags().StringVarP(&DispatchApiKeyCli, "api-key", "k", "", "Dispatch API key (env: DISPATCH_API_KEY)")
24 | cmd.PersistentFlags().StringVarP(&DotEnvFilePath, "env-file", "", "", "Path to .env file")
25 |
26 | cmd.AddGroup(&cobra.Group{
27 | ID: "management",
28 | Title: "Account Management Commands:",
29 | })
30 | cmd.AddGroup(&cobra.Group{
31 | ID: "dispatch",
32 | Title: "Dispatch Commands:",
33 | })
34 |
35 | // Passing the global variables to the commands make testing in parallel possible.
36 | cmd.AddCommand(loginCommand())
37 | cmd.AddCommand(initCommand())
38 | cmd.AddCommand(switchCommand(DispatchConfigPath))
39 | cmd.AddCommand(verificationCommand())
40 | cmd.AddCommand(runCommand())
41 | cmd.AddCommand(versionCommand())
42 |
43 | // Generate markdown documentation
44 | generateDocs(cmd, "dispatch")
45 |
46 | return cmd
47 | }
48 |
49 | // Main is the entry point of the command line.
50 | func Main() error {
51 | return createMainCommand().ExecuteContext(context.Background())
52 | }
53 |
--------------------------------------------------------------------------------
/cli/main_test.go:
--------------------------------------------------------------------------------
1 | package cli
2 |
3 | import (
4 | "sort"
5 | "testing"
6 |
7 | "github.com/stretchr/testify/assert"
8 | )
9 |
10 | var expectedCommands = []string{"login", "switch [organization]", "verification", "run", "version", "init [path]"}
11 |
12 | func TestMainCommand(t *testing.T) {
13 | t.Run("Main command", func(t *testing.T) {
14 | t.Parallel()
15 |
16 | cmd := createMainCommand()
17 | assert.NotNil(t, cmd, "Expected main command to be created")
18 |
19 | groups := cmd.Groups()
20 | assert.Len(t, groups, 2, "Expected 2 groups")
21 | assert.Equal(t, "management", groups[0].ID, "Expected first group to be 'management'")
22 | assert.Equal(t, "dispatch", groups[1].ID, "Expected second group to be 'dispatch'")
23 |
24 | commands := cmd.Commands()
25 |
26 | // Extract the command IDs
27 | commandIDs := make([]string, 0, len(commands))
28 | for _, command := range commands {
29 | commandIDs = append(commandIDs, command.Use)
30 | }
31 |
32 | // Sort slices alphabetically
33 | sort.Strings(expectedCommands)
34 | assert.Equal(t, expectedCommands, commandIDs, "All commands should be present in the main command")
35 | })
36 | }
37 |
--------------------------------------------------------------------------------
/cli/python.go:
--------------------------------------------------------------------------------
1 | package cli
2 |
3 | import (
4 | "bytes"
5 | "fmt"
6 | "log/slog"
7 | "strings"
8 |
9 | "github.com/charmbracelet/lipgloss"
10 | "github.com/nlpodyssey/gopickle/pickle"
11 | "github.com/nlpodyssey/gopickle/types"
12 | )
13 |
14 | var (
15 | kwargStyle = lipgloss.NewStyle().Foreground(grayColor)
16 | )
17 |
18 | func pythonPickleString(b []byte) (string, error) {
19 | u := pickle.NewUnpickler(bytes.NewReader(b))
20 | u.FindClass = findPythonClass
21 |
22 | value, err := u.Load()
23 | if err != nil {
24 | return "", err
25 | }
26 | return pythonValueString(value)
27 | }
28 |
29 | func pythonValueString(value interface{}) (string, error) {
30 | switch v := value.(type) {
31 | case nil:
32 | return "None", nil
33 | case bool:
34 | if v {
35 | return "True", nil
36 | }
37 | return "False", nil
38 | case string:
39 | return fmt.Sprintf("%q", v), nil
40 | case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, float32, float64:
41 | return fmt.Sprintf("%v", v), nil
42 | case *types.List:
43 | return pythonListString(v)
44 | case *types.Tuple:
45 | return pythonTupleString(v)
46 | case *types.Dict:
47 | return pythonDictString(v)
48 | case *types.Set:
49 | return pythonSetString(v)
50 | case *pythonArgumentsObject:
51 | return pythonArgumentsString(v)
52 | case *genericClass:
53 | return fmt.Sprintf("%s.%s", v.Module, v.Name), nil
54 | case *genericObject:
55 | return pythonGenericObjectString(v)
56 | default:
57 | return "", fmt.Errorf("unsupported Python value: %T", value)
58 | }
59 | }
60 |
61 | func pythonListString(list *types.List) (string, error) {
62 | var b strings.Builder
63 | b.WriteByte('[')
64 | for i, entry := range *list {
65 | if i > 0 {
66 | b.WriteString(", ")
67 | }
68 | s, err := pythonValueString(entry)
69 | if err != nil {
70 | return "", err
71 | }
72 | b.WriteString(s)
73 | }
74 | b.WriteByte(']')
75 | return b.String(), nil
76 | }
77 |
78 | func pythonTupleString(tuple *types.Tuple) (string, error) {
79 | var b strings.Builder
80 | b.WriteByte('(')
81 | for i, entry := range *tuple {
82 | if i > 0 {
83 | b.WriteString(", ")
84 | }
85 | s, err := pythonValueString(entry)
86 | if err != nil {
87 | return "", err
88 | }
89 | b.WriteString(s)
90 | }
91 | b.WriteByte(')')
92 | return b.String(), nil
93 | }
94 |
95 | func pythonDictString(dict *types.Dict) (string, error) {
96 | var b strings.Builder
97 | b.WriteByte('{')
98 | for i, entry := range *dict {
99 | if i > 0 {
100 | b.WriteString(", ")
101 | }
102 | keyStr, err := pythonValueString(entry.Key)
103 | if err != nil {
104 | return "", err
105 | }
106 | b.WriteString(keyStr)
107 | b.WriteString(": ")
108 |
109 | valueStr, err := pythonValueString(entry.Value)
110 | if err != nil {
111 | return "", err
112 | }
113 | b.WriteString(valueStr)
114 | }
115 | b.WriteByte('}')
116 | return b.String(), nil
117 | }
118 |
119 | func pythonSetString(set *types.Set) (string, error) {
120 | var b strings.Builder
121 | b.WriteByte('{')
122 | var i int
123 | for entry := range *set {
124 | if i > 0 {
125 | b.WriteString(", ")
126 | }
127 | s, err := pythonValueString(entry)
128 | if err != nil {
129 | return "", err
130 | }
131 | b.WriteString(s)
132 | i++
133 | }
134 | b.WriteByte('}')
135 | return b.String(), nil
136 | }
137 |
138 | func pythonArgumentsString(a *pythonArgumentsObject) (string, error) {
139 | var b strings.Builder
140 | b.WriteByte('(')
141 |
142 | var argsLen int
143 | if a.args != nil {
144 | argsLen = a.args.Len()
145 | for i := 0; i < argsLen; i++ {
146 | if i > 0 {
147 | b.WriteString(", ")
148 | }
149 | arg := a.args.Get(i)
150 | s, err := pythonValueString(arg)
151 | if err != nil {
152 | return "", err
153 | }
154 | b.WriteString(s)
155 | }
156 | }
157 |
158 | if a.kwargs != nil {
159 | for i, entry := range *a.kwargs {
160 | if i > 0 || argsLen > 0 {
161 | b.WriteString(", ")
162 | }
163 | var keyStr string
164 | if s, ok := entry.Key.(string); ok {
165 | keyStr = s
166 | } else {
167 | var err error
168 | keyStr, err = pythonValueString(entry.Key)
169 | if err != nil {
170 | return "", err
171 | }
172 | }
173 | b.WriteString(kwargStyle.Render(keyStr + "="))
174 |
175 | valueStr, err := pythonValueString(entry.Value)
176 | if err != nil {
177 | return "", err
178 | }
179 | b.WriteString(valueStr)
180 | }
181 | }
182 |
183 | b.WriteByte(')')
184 | return b.String(), nil
185 | }
186 |
187 | func pythonGenericObjectString(o *genericObject) (string, error) {
188 | var b strings.Builder
189 | b.WriteString(o.class.Name)
190 | b.WriteByte('(')
191 |
192 | for i, e := 0, o.dict.List.Front(); e != nil; i++ {
193 | if i > 0 {
194 | b.WriteString(", ")
195 | }
196 | entry := e.Value.(*types.OrderedDictEntry)
197 |
198 | var keyStr string
199 | if s, ok := entry.Key.(string); ok {
200 | keyStr = s
201 | } else {
202 | var err error
203 | keyStr, err = pythonValueString(entry.Key)
204 | if err != nil {
205 | return "", err
206 | }
207 | }
208 | b.WriteString(kwargStyle.Render(keyStr + "="))
209 |
210 | valueStr, err := pythonValueString(entry.Value)
211 | if err != nil {
212 | return "", err
213 | }
214 | b.WriteString(valueStr)
215 |
216 | e = e.Next()
217 | }
218 |
219 | b.WriteByte(')')
220 | return b.String(), nil
221 |
222 | }
223 |
224 | func findPythonClass(module, name string) (interface{}, error) {
225 | // https://github.com/dispatchrun/dispatch-py/blob/0a482491/src/dispatch/proto.py#L175
226 | if module == "dispatch.proto" && name == "Arguments" {
227 | return &pythonArgumentsClass{}, nil
228 | }
229 | // If a custom class is encountered, we don't have enough information
230 | // to be able to format it. In many cases though (e.g. dataclasses),
231 | // it's sufficient to collect and format the module/name of the class,
232 | // and then data that arrives through PyDictSettable interface.
233 | slog.Debug("deserializing Python class", "module", module, "name", name)
234 | return &genericClass{&types.GenericClass{Module: module, Name: name}}, nil
235 | }
236 |
237 | type pythonArgumentsClass struct{}
238 |
239 | func (a *pythonArgumentsClass) PyNew(args ...interface{}) (interface{}, error) {
240 | return &pythonArgumentsObject{}, nil
241 | }
242 |
243 | type pythonArgumentsObject struct {
244 | args *types.Tuple
245 | kwargs *types.Dict
246 | }
247 |
248 | var _ types.PyDictSettable = (*pythonArgumentsObject)(nil)
249 |
250 | func (a *pythonArgumentsObject) PyDictSet(key, value interface{}) error {
251 | var ok bool
252 | switch key {
253 | case "args":
254 | if a.args, ok = value.(*types.Tuple); !ok {
255 | return fmt.Errorf("invalid Arguments.args: %T", value)
256 | }
257 | case "kwargs":
258 | if a.kwargs, ok = value.(*types.Dict); !ok {
259 | return fmt.Errorf("invalid Arguments.kwargs: %T", value)
260 | }
261 | default:
262 | return fmt.Errorf("unexpected key: %v", key)
263 | }
264 | return nil
265 | }
266 |
267 | type genericClass struct {
268 | *types.GenericClass
269 | }
270 |
271 | func (c *genericClass) PyNew(args ...interface{}) (interface{}, error) {
272 | return &genericObject{c, types.NewOrderedDict()}, nil
273 | }
274 |
275 | type genericObject struct {
276 | class *genericClass
277 | dict *types.OrderedDict
278 | }
279 |
280 | func (o *genericObject) PyDictSet(key, value interface{}) error {
281 | o.dict.Set(key, value)
282 | return nil
283 | }
284 |
--------------------------------------------------------------------------------
/cli/run.go:
--------------------------------------------------------------------------------
1 | package cli
2 |
3 | import (
4 | "bufio"
5 | "bytes"
6 | "context"
7 | "crypto/rand"
8 | "errors"
9 | "fmt"
10 | "io"
11 | "log/slog"
12 | "math/big"
13 | "net"
14 | "net/http"
15 | "os"
16 | "os/exec"
17 | "os/signal"
18 | "path/filepath"
19 | "runtime"
20 | "slices"
21 | "strconv"
22 | "strings"
23 | "sync"
24 | "sync/atomic"
25 | "syscall"
26 | "time"
27 |
28 | sdkv1 "buf.build/gen/go/stealthrocket/dispatch-proto/protocolbuffers/go/dispatch/sdk/v1"
29 | tea "github.com/charmbracelet/bubbletea"
30 | "github.com/charmbracelet/lipgloss"
31 | "github.com/spf13/cobra"
32 | "google.golang.org/protobuf/proto"
33 | )
34 |
35 | var (
36 | BridgeSession string
37 | LocalEndpoint string
38 | Verbose bool
39 | )
40 |
41 | const defaultEndpoint = "127.0.0.1:8000"
42 |
43 | const (
44 | pollTimeout = 30 * time.Second
45 | cleanupTimeout = 5 * time.Second
46 | )
47 |
48 | var httpClient = &http.Client{
49 | Transport: http.DefaultTransport,
50 | Timeout: pollTimeout,
51 | }
52 |
53 | var (
54 | dispatchLogPrefixStyle = lipgloss.NewStyle().Foreground(greenColor)
55 | appLogPrefixStyle = lipgloss.NewStyle().Foreground(magentaColor)
56 | logPrefixSeparatorStyle = lipgloss.NewStyle().Foreground(grayColor)
57 | )
58 |
59 | func runCommand() *cobra.Command {
60 | cmd := &cobra.Command{
61 | Use: "run",
62 | Short: "Run a Dispatch application",
63 | Long: fmt.Sprintf(`Run a Dispatch application.
64 |
65 | The command to start the local application endpoint should be
66 | specified after the run command and its options:
67 |
68 | `+RunExampleText+`
69 |
70 | Dispatch spawns the local application endpoint and then dispatches
71 | function calls to it continuously.
72 |
73 | Dispatch connects to the local application endpoint on http://%s.
74 | If the local application is listening on a different host or port,
75 | please set the --endpoint option appropriately. The value passed to
76 | this option will be exported as the DISPATCH_ENDPOINT_ADDR environment
77 | variable to the local application.
78 |
79 | A new session is created each time the command is run. A session is
80 | a pristine environment in which function calls can be dispatched and
81 | handled by the local application. To start the command using a previous
82 | session, use the --session option to specify a session ID from a
83 | previous run.`, defaultEndpoint),
84 | Args: cobra.MinimumNArgs(1),
85 | GroupID: "dispatch",
86 | PreRunE: func(cmd *cobra.Command, args []string) error {
87 | return runConfigFlow()
88 | },
89 | RunE: func(c *cobra.Command, args []string) error {
90 | arg0 := filepath.Base(args[0])
91 |
92 | prefixWidth := max(len("dispatch"), len(arg0))
93 |
94 | if checkEndpoint(LocalEndpoint, time.Second) {
95 | return fmt.Errorf("cannot start local application on address that's already in use: %v", LocalEndpoint)
96 | }
97 |
98 | // Enable the TUI if this is an interactive session and
99 | // stdout/stderr aren't redirected.
100 | var tui *TUI
101 | var logWriter io.Writer = os.Stderr
102 | var observer FunctionCallObserver
103 | if isTerminal(os.Stdin) && isTerminal(os.Stdout) && isTerminal(os.Stderr) {
104 | tui = &TUI{}
105 | logWriter = tui
106 | observer = tui
107 | }
108 |
109 | // Add a prefix to Dispatch logs.
110 | slog.SetDefault(slog.New(&slogHandler{
111 | stream: &prefixLogWriter{
112 | stream: logWriter,
113 | prefix: []byte(dispatchLogPrefixStyle.Render(pad("dispatch", prefixWidth)) + logPrefixSeparatorStyle.Render(" | ")),
114 | },
115 | }))
116 |
117 | if BridgeSession == "" {
118 | BridgeSession = randomSessionID()
119 | }
120 |
121 | if !Verbose && tui == nil {
122 | dialog(`Starting Dispatch session: %v
123 |
124 | Run 'dispatch help run' to learn about Dispatch sessions.`, BridgeSession)
125 | }
126 |
127 | slog.Info("starting session", "session_id", BridgeSession)
128 |
129 | ctx, cancel := context.WithCancel(context.Background())
130 | defer cancel()
131 |
132 | // Execute the command, forwarding the environment and
133 | // setting the necessary extra DISPATCH_* variables.
134 | cmd := exec.Command(args[0], args[1:]...)
135 |
136 | cleanup := func() {
137 | if err := recover(); err != nil {
138 | // Don't leave behind a dangling process if a panic occurs.
139 | if cmd != nil && cmd.Process != nil {
140 | _ = cmd.Process.Kill()
141 | }
142 | panic(err)
143 | }
144 | }
145 | defer cleanup()
146 |
147 | var wg sync.WaitGroup
148 | backgroundGoroutine := func(fn func()) {
149 | wg.Add(1)
150 | go func() {
151 | defer wg.Done()
152 | defer cleanup()
153 |
154 | fn()
155 | }()
156 | }
157 |
158 | cmd.Stdin = os.Stdin
159 |
160 | // Pipe stdout/stderr streams through a writer that adds a prefix,
161 | // so that it's easier to disambiguate Dispatch logs from the local
162 | // application's logs.
163 | stdout, err := cmd.StdoutPipe()
164 | if err != nil {
165 | return fmt.Errorf("failed to create stdout pipe: %v", err)
166 | }
167 | defer stdout.Close()
168 |
169 | stderr, err := cmd.StderrPipe()
170 | if err != nil {
171 | return fmt.Errorf("failed to create stderr pipe: %v", err)
172 | }
173 | defer stderr.Close()
174 |
175 | // Pass on environment variables to the local application.
176 | // Pass on the configured API key, and set a special endpoint
177 | // URL for the session. Unset the verification key, so that
178 | // it doesn't conflict with the session. A verification key
179 | // is not required here, since function calls are retrieved
180 | // from an authenticated API endpoint.
181 | cmd.Env = append(
182 | withoutEnv(os.Environ(), "DISPATCH_VERIFICATION_KEY="),
183 | "DISPATCH_API_KEY="+DispatchApiKey,
184 | "DISPATCH_ENDPOINT_URL=bridge://"+BridgeSession,
185 | "DISPATCH_ENDPOINT_ADDR="+LocalEndpoint,
186 | )
187 |
188 | // Set OS-specific process attributes.
189 | cmd.SysProcAttr = &syscall.SysProcAttr{}
190 | setSysProcAttr(cmd.SysProcAttr)
191 |
192 | // Setup signal handler.
193 | signals := make(chan os.Signal, 2)
194 | signal.Notify(signals, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM)
195 | var signaled bool
196 | backgroundGoroutine(func() {
197 | for {
198 | select {
199 | case <-ctx.Done():
200 | return
201 | case s := <-signals:
202 | if !signaled {
203 | signaled = true
204 | } else {
205 | s = os.Kill
206 | }
207 | if cmd.Process != nil && cmd.Process.Pid > 0 {
208 | killProcess(cmd.Process, s.(syscall.Signal))
209 | }
210 | }
211 | }
212 | })
213 |
214 | // Initialize the TUI.
215 | if tui != nil {
216 | p := tea.NewProgram(tui,
217 | tea.WithContext(ctx),
218 | tea.WithoutSignalHandler(),
219 | tea.WithoutCatchPanics())
220 |
221 | backgroundGoroutine(func() {
222 | if _, err := p.Run(); err != nil && !errors.Is(err, tea.ErrProgramKilled) {
223 | panic(err)
224 | }
225 | // Quitting the TUI sends an implicit interrupt.
226 | select {
227 | case signals <- syscall.SIGINT:
228 | default:
229 | }
230 | })
231 | }
232 |
233 | bridgeSessionURL := fmt.Sprintf("%s/sessions/%s", DispatchBridgeUrl, BridgeSession)
234 |
235 | // Poll for work in the background.
236 | var successfulPolls int64
237 |
238 | backgroundGoroutine(func() {
239 | for ctx.Err() == nil {
240 | // Fetch a request from the API.
241 | requestID, res, err := poll(ctx, httpClient, bridgeSessionURL)
242 | if err != nil {
243 | if ctx.Err() != nil {
244 | return
245 | }
246 | slog.Warn(err.Error())
247 |
248 | if tui != nil {
249 | if _, ok := err.(authError); ok {
250 | tui.SetError(err)
251 | }
252 | }
253 |
254 | time.Sleep(1 * time.Second)
255 | continue
256 | } else if res == nil {
257 | continue
258 | }
259 |
260 | atomic.AddInt64(&successfulPolls, +1)
261 |
262 | // Asynchronously send the request to invoke a function to
263 | // the local application.
264 | wg.Add(1)
265 | go func() {
266 | defer wg.Done()
267 |
268 | err := invoke(ctx, httpClient, bridgeSessionURL, requestID, res, observer)
269 | res.Body.Close()
270 | if err != nil {
271 | if ctx.Err() == nil {
272 | slog.Warn(err.Error())
273 | }
274 |
275 | // Notify upstream if we're unable to generate a response,
276 | // either because the local application can't be contacted,
277 | // is misbehaving, or a shutdown sequence has been initiated.
278 | ctx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
279 | defer cancel()
280 | if err := deleteRequest(ctx, httpClient, bridgeSessionURL, requestID); err != nil {
281 | slog.Debug(err.Error())
282 | }
283 | }
284 | }()
285 | }
286 | })
287 |
288 | runtime.LockOSThread()
289 | defer runtime.UnlockOSThread()
290 |
291 | if err = cmd.Start(); err != nil {
292 | return fmt.Errorf("failed to start %s: %v", strings.Join(args, " "), err)
293 | }
294 |
295 | // Add a prefix to the local application's logs.
296 | appLogPrefix := []byte(appLogPrefixStyle.Render(pad(arg0, prefixWidth)) + logPrefixSeparatorStyle.Render(" | "))
297 | backgroundGoroutine(func() { printPrefixedLines(logWriter, stdout, appLogPrefix) })
298 | backgroundGoroutine(func() { printPrefixedLines(logWriter, stderr, appLogPrefix) })
299 |
300 | err = cmd.Wait()
301 | cmd = nil
302 |
303 | // Cancel the context and wait for all goroutines to return.
304 | cancel()
305 | wg.Wait()
306 |
307 | // If the command was halted by a signal rather than some other error,
308 | // assume that the command invocation succeeded and that the user may
309 | // want to resume this session.
310 | if signaled {
311 | err = nil
312 |
313 | if atomic.LoadInt64(&successfulPolls) > 0 && !Verbose {
314 | dispatchArg0 := os.Args[0]
315 | dialog("To resume this Dispatch session:\n\n\t%s run --session %s -- %s",
316 | dispatchArg0, BridgeSession, strings.Join(args, " "))
317 | }
318 | }
319 |
320 | if err != nil {
321 | dumpLogs(logWriter)
322 | return fmt.Errorf("failed to invoke command '%s': %v", strings.Join(args, " "), err)
323 | } else if !signaled && successfulPolls == 0 {
324 | dumpLogs(logWriter)
325 | return fmt.Errorf("command '%s' exited unexpectedly", strings.Join(args, " "))
326 | }
327 | return nil
328 | },
329 | }
330 |
331 | cmd.Flags().StringVarP(&BridgeSession, "session", "s", "", "Optional session to resume")
332 | cmd.Flags().StringVarP(&LocalEndpoint, "endpoint", "e", defaultEndpoint, "Host:port that the local application endpoint is listening on")
333 | cmd.Flags().BoolVarP(&Verbose, "verbose", "", false, "Enable verbose logging")
334 |
335 | return cmd
336 | }
337 |
338 | func dumpLogs(logWriter io.Writer) {
339 | if r, ok := logWriter.(io.Reader); ok {
340 | time.Sleep(100 * time.Millisecond)
341 | _, _ = io.Copy(os.Stderr, r)
342 | _, _ = os.Stderr.Write([]byte{'\n'})
343 | }
344 | }
345 |
346 | func poll(ctx context.Context, client *http.Client, url string) (string, *http.Response, error) {
347 | slog.Debug("getting request from Dispatch", "url", url)
348 |
349 | req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
350 | if err != nil {
351 | panic(err)
352 | }
353 | req.Header.Add("Authorization", "Bearer "+DispatchApiKey)
354 | req.Header.Add("Request-Timeout", strconv.FormatInt(int64(pollTimeout.Seconds()), 10))
355 | if DispatchBridgeHostHeader != "" {
356 | req.Host = DispatchBridgeHostHeader
357 | }
358 |
359 | res, err := client.Do(req)
360 | if err != nil {
361 | return "", nil, fmt.Errorf("failed to contact Dispatch API (%s): %v", DispatchBridgeUrl, err)
362 | }
363 | if res.StatusCode != http.StatusOK {
364 | res.Body.Close()
365 |
366 | switch res.StatusCode {
367 | case http.StatusUnauthorized:
368 | return "", nil, authError{}
369 | case http.StatusGatewayTimeout:
370 | // A 504 is expected when long polling and no requests
371 | // are available. Return a nil in this case and let the
372 | // caller try again.
373 | return "", nil, nil
374 | default:
375 | return "", nil, fmt.Errorf("failed to contact Dispatch API (%s): response code %d", DispatchBridgeUrl, res.StatusCode)
376 | }
377 | }
378 |
379 | requestID := res.Header.Get("X-Request-Id")
380 |
381 | return requestID, res, nil
382 | }
383 |
384 | // FunctionCallObserver observes function call requests and responses.
385 | //
386 | // The observer may be invoked concurrently from many goroutines.
387 | type FunctionCallObserver interface {
388 | // ObserveRequest observes a RunRequest as it passes from the API through
389 | // the CLI to the local application.
390 | ObserveRequest(time.Time, *sdkv1.RunRequest)
391 |
392 | // ObserveResponse observes a response to the RunRequest.
393 | //
394 | // If the RunResponse is nil, it means the local application did not return
395 | // a valid response. If the http.Response is not nil, it means an HTTP
396 | // response was generated, but it wasn't a valid RunResponse. The error may
397 | // be present if there was either an error making the HTTP request, or parsing
398 | // the response.
399 | //
400 | // ObserveResponse always comes after a call to ObserveRequest for any given
401 | // RunRequest.
402 | ObserveResponse(time.Time, *sdkv1.RunRequest, error, *http.Response, *sdkv1.RunResponse)
403 | }
404 |
405 | func invoke(ctx context.Context, client *http.Client, url, requestID string, bridgeGetRes *http.Response, observer FunctionCallObserver) error {
406 | logger := slog.Default()
407 | if Verbose {
408 | logger = slog.With("request_id", requestID)
409 | }
410 |
411 | logger.Debug("sending request to local application", "endpoint", LocalEndpoint)
412 |
413 | // Extract the nested request header/body.
414 | endpointReq, err := http.ReadRequest(bufio.NewReader(bridgeGetRes.Body))
415 | if err != nil {
416 | return fmt.Errorf("invalid response from Dispatch API: %v", err)
417 | }
418 | endpointReq = endpointReq.WithContext(ctx)
419 |
420 | // Buffer the request body in memory.
421 | endpointReqBody := &bytes.Buffer{}
422 | if endpointReq.ContentLength > 0 {
423 | endpointReqBody.Grow(int(endpointReq.ContentLength))
424 | }
425 | endpointReq.ContentLength, err = io.Copy(endpointReqBody, endpointReq.Body)
426 | endpointReq.Body.Close()
427 | bridgeGetRes.Body.Close()
428 | if err != nil {
429 | return fmt.Errorf("failed to read response from Dispatch API: %v", err)
430 | }
431 | endpointReq.GetBody = func() (io.ReadCloser, error) {
432 | return io.NopCloser(bytes.NewReader(endpointReqBody.Bytes())), nil
433 | }
434 | endpointReq.Body, _ = endpointReq.GetBody()
435 |
436 | // Parse the request body from the API.
437 | var runRequest sdkv1.RunRequest
438 | if err := proto.Unmarshal(endpointReqBody.Bytes(), &runRequest); err != nil {
439 | return fmt.Errorf("invalid response from Dispatch API: %v", err)
440 | }
441 | logger.Debug("parsed request", "function", runRequest.Function, "dispatch_id", runRequest.DispatchId)
442 | switch d := runRequest.Directive.(type) {
443 | case *sdkv1.RunRequest_Input:
444 | if Verbose {
445 | logger.Info("calling function", "function", runRequest.Function, "input", anyString(d.Input))
446 | } else {
447 | logger.Info("calling function", "function", runRequest.Function)
448 | }
449 | case *sdkv1.RunRequest_PollResult:
450 | logger.Info("resuming function", "function", runRequest.Function)
451 | }
452 | if observer != nil {
453 | observer.ObserveRequest(time.Now(), &runRequest)
454 | }
455 |
456 | // The RequestURI field must be cleared for client.Do() to
457 | // accept the request below.
458 | endpointReq.RequestURI = ""
459 |
460 | // Forward the request to the local application endpoint.
461 | endpointReq.Host = LocalEndpoint
462 | endpointReq.URL.Scheme = "http"
463 | endpointReq.URL.Host = LocalEndpoint
464 | endpointRes, err := client.Do(endpointReq)
465 | now := time.Now()
466 | if err != nil {
467 | err = fmt.Errorf("can't connect to %s: %v (check that -e,--endpoint is correct)", LocalEndpoint, tidyErr(err))
468 | if observer != nil {
469 | observer.ObserveResponse(now, &runRequest, err, nil, nil)
470 | }
471 | return err
472 | }
473 |
474 | // Buffer the response body in memory.
475 | endpointResBody := &bytes.Buffer{}
476 | if endpointRes.ContentLength > 0 {
477 | endpointResBody.Grow(int(endpointRes.ContentLength))
478 | }
479 | _, err = io.Copy(endpointResBody, endpointRes.Body)
480 | endpointRes.Body.Close()
481 | if err != nil {
482 | err = fmt.Errorf("read error from %s: %v", LocalEndpoint, tidyErr(err))
483 | if observer != nil {
484 | observer.ObserveResponse(now, &runRequest, err, endpointRes, nil)
485 | }
486 | return err
487 | }
488 | endpointRes.Body = io.NopCloser(endpointResBody)
489 | endpointRes.ContentLength = int64(endpointResBody.Len())
490 |
491 | // Parse the response body from the API.
492 | if endpointRes.StatusCode == http.StatusOK && endpointRes.Header.Get("Content-Type") == "application/proto" {
493 | var runResponse sdkv1.RunResponse
494 | if err := proto.Unmarshal(endpointResBody.Bytes(), &runResponse); err != nil {
495 | err = fmt.Errorf("invalid response from %s: %v", LocalEndpoint, tidyErr(err))
496 | if observer != nil {
497 | observer.ObserveResponse(now, &runRequest, err, endpointRes, nil)
498 | }
499 | return err
500 | }
501 | switch runResponse.Status {
502 | case sdkv1.Status_STATUS_OK:
503 | switch d := runResponse.Directive.(type) {
504 | case *sdkv1.RunResponse_Exit:
505 | if d.Exit.TailCall != nil {
506 | logger.Info("function tail-called", "function", runRequest.Function, "tail_call", d.Exit.TailCall.Function)
507 | } else if Verbose && d.Exit.Result != nil {
508 | logger.Info("function call succeeded", "function", runRequest.Function, "output", anyString(d.Exit.Result.Output))
509 | } else {
510 | logger.Info("function call succeeded", "function", runRequest.Function)
511 | }
512 | case *sdkv1.RunResponse_Poll:
513 | logger.Info("function yielded", "function", runRequest.Function)
514 | }
515 | default:
516 | err := runResponse.GetExit().GetResult().GetError()
517 | logger.Warn("function call failed", "function", runRequest.Function, "status", statusString(runResponse.Status), "error_type", err.GetType(), "error_message", err.GetMessage())
518 | }
519 | if observer != nil {
520 | observer.ObserveResponse(now, &runRequest, nil, endpointRes, &runResponse)
521 | }
522 | } else {
523 | // The response might indicate some other issue, e.g. it could be a 404 if the function can't be found
524 | logger.Warn("function call failed", "function", runRequest.Function, "http_status", endpointRes.StatusCode)
525 | if observer != nil {
526 | observer.ObserveResponse(now, &runRequest, nil, endpointRes, nil)
527 | }
528 | }
529 |
530 | // Use io.Pipe to convert the response writer into an io.Reader.
531 | pr, pw := io.Pipe()
532 | go func() {
533 | err := endpointRes.Write(pw)
534 | pw.CloseWithError(err)
535 | }()
536 |
537 | logger.Debug("sending response to Dispatch")
538 |
539 | // Send the response back to the API.
540 | bridgePostReq, err := http.NewRequestWithContext(ctx, "POST", url, bufio.NewReader(pr))
541 | if err != nil {
542 | panic(err)
543 | }
544 | bridgePostReq.Header.Add("Authorization", "Bearer "+DispatchApiKey)
545 | bridgePostReq.Header.Add("X-Request-ID", requestID)
546 | if DispatchBridgeHostHeader != "" {
547 | bridgePostReq.Host = DispatchBridgeHostHeader
548 | }
549 | bridgePostRes, err := client.Do(bridgePostReq)
550 | if err != nil {
551 | return fmt.Errorf("failed to contact Dispatch API or send response: %v", err)
552 | }
553 | switch bridgePostRes.StatusCode {
554 | case http.StatusAccepted:
555 | return nil
556 | case http.StatusNotFound:
557 | // A 404 is expected if there's a timeout upstream that's hit
558 | // before the response can be sent.
559 | logger.Debug("request is no longer available", "method", "post")
560 | return nil
561 | default:
562 | return fmt.Errorf("failed to contact Dispatch API to send response: response code %d", bridgePostRes.StatusCode)
563 | }
564 | }
565 |
566 | func deleteRequest(ctx context.Context, client *http.Client, url, requestID string) error {
567 | slog.Debug("cleaning up request", "request_id", requestID)
568 |
569 | req, err := http.NewRequestWithContext(ctx, "DELETE", url, nil)
570 | if err != nil {
571 | panic(err)
572 | }
573 | req.Header.Add("Authorization", "Bearer "+DispatchApiKey)
574 | req.Header.Add("X-Request-ID", requestID)
575 | if DispatchBridgeHostHeader != "" {
576 | req.Host = DispatchBridgeHostHeader
577 | }
578 | res, err := client.Do(req)
579 | if err != nil {
580 | return fmt.Errorf("failed to contact Dispatch API to cleanup request: %v", err)
581 | }
582 | switch res.StatusCode {
583 | case http.StatusOK:
584 | return nil
585 | case http.StatusNotFound:
586 | // A 404 can occur if the request is cleaned up concurrently, either
587 | // because a response was received upstream but the CLI didn't realize
588 | // the response went through, or because a timeout was reached upstream.
589 | slog.Debug("request is no longer available", "request_id", requestID, "method", "delete")
590 | return nil
591 | default:
592 | return fmt.Errorf("failed to contact Dispatch API to cleanup request: response code %d", res.StatusCode)
593 | }
594 | }
595 |
596 | func checkEndpoint(addr string, timeout time.Duration) bool {
597 | slog.Debug("checking endpoint", "addr", addr)
598 | conn, err := net.DialTimeout("tcp", addr, timeout)
599 | if err != nil {
600 | slog.Debug("endpoint could not be contacted", "addr", addr, "err", err)
601 | return false
602 | }
603 | slog.Debug("endpoint contacted successfully", "addr", addr)
604 | conn.Close()
605 | return true
606 | }
607 |
608 | func withoutEnv(env []string, prefixes ...string) []string {
609 | return slices.DeleteFunc(env, func(v string) bool {
610 | for _, prefix := range prefixes {
611 | if strings.HasPrefix(v, prefix) {
612 | return true
613 | }
614 | }
615 | return false
616 | })
617 | }
618 |
619 | func printPrefixedLines(w io.Writer, r io.Reader, prefix []byte) {
620 | scanner := bufio.NewScanner(r)
621 | buffer := bytes.NewBuffer(nil)
622 | buffer.Write(prefix)
623 |
624 | for scanner.Scan() {
625 | buffer.Truncate(len(prefix))
626 | buffer.Write(scanner.Bytes())
627 | buffer.WriteByte('\n')
628 | _, _ = w.Write(buffer.Bytes())
629 | }
630 | }
631 |
632 | func pad(s string, width int) string {
633 | if len(s) < width {
634 | return s + strings.Repeat(" ", width-len(s))
635 | }
636 | return s
637 | }
638 |
639 | func randomSessionID() string {
640 | var b [16]byte
641 | _, err := rand.Read(b[:])
642 | if err != nil {
643 | panic(err)
644 | }
645 | var i big.Int
646 | i.SetBytes(b[:])
647 | return i.Text(62) // base62
648 | }
649 |
650 | var (
651 | errConnectionRefused = errors.New("connection refused")
652 | )
653 |
654 | func tidyErr(err error) error {
655 | var errno syscall.Errno
656 | if errors.As(err, &errno) {
657 | switch errno {
658 | case syscall.ECONNREFUSED:
659 | return errConnectionRefused
660 | }
661 | }
662 | return err
663 | }
664 |
--------------------------------------------------------------------------------
/cli/run_darwin.go:
--------------------------------------------------------------------------------
1 | package cli
2 |
3 | import (
4 | "os"
5 | "syscall"
6 | )
7 |
8 | func setSysProcAttr(attr *syscall.SysProcAttr) {
9 | attr.Setpgid = true
10 | }
11 |
12 | func killProcess(process *os.Process, signal os.Signal) {
13 | // Sending the signal to -pid sends it to all processes
14 | // in the process group.
15 | _ = syscall.Kill(-process.Pid, signal.(syscall.Signal))
16 | }
17 |
--------------------------------------------------------------------------------
/cli/run_default.go:
--------------------------------------------------------------------------------
1 | //go:build !linux && !darwin
2 |
3 | package cli
4 |
5 | import (
6 | "os"
7 | "syscall"
8 | )
9 |
10 | func setSysProcAttr(attr *syscall.SysProcAttr) {}
11 |
12 | func killProcess(process *os.Process, _ os.Signal) {
13 | process.Kill()
14 | }
15 |
--------------------------------------------------------------------------------
/cli/run_linux.go:
--------------------------------------------------------------------------------
1 | package cli
2 |
3 | import (
4 | "os"
5 | "syscall"
6 | )
7 |
8 | func setSysProcAttr(attr *syscall.SysProcAttr) {
9 | attr.Setpgid = true
10 | attr.Pdeathsig = syscall.SIGTERM
11 | }
12 |
13 | func killProcess(process *os.Process, signal os.Signal) {
14 | // Sending the signal to -pid sends it to all processes
15 | // in the process group.
16 | _ = syscall.Kill(-process.Pid, signal.(syscall.Signal))
17 | }
18 |
--------------------------------------------------------------------------------
/cli/run_test.go:
--------------------------------------------------------------------------------
1 | package cli
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "fmt"
7 | "os"
8 | "os/exec"
9 | "path/filepath"
10 | "regexp"
11 | "runtime"
12 | "strings"
13 | "testing"
14 | "time"
15 |
16 | "github.com/stretchr/testify/assert"
17 | )
18 |
19 | var dispatchBinary = filepath.Join("../build", runtime.GOOS, runtime.GOARCH, "dispatch")
20 |
21 | func TestRunCommand(t *testing.T) {
22 | t.Run("Run with non-existent env file", func(t *testing.T) {
23 | t.Parallel()
24 |
25 | buff, err := execRunCommand(&[]string{}, "run", "--env-file", "non-existent.env", "--", "echo", "hello")
26 | if err != nil {
27 | t.Fatal(err.Error())
28 | }
29 |
30 | errMsg := "no such file or directory\n"
31 | path := regexp.QuoteMeta(filepath.FromSlash("/dispatch/cli/non-existent.env"))
32 | if runtime.GOOS == "windows" {
33 | errMsg = "The system cannot find the file specified.\n"
34 | }
35 | assert.Regexp(t, "Error: failed to load env file from .+"+path+": open non-existent\\.env: "+errMsg, buff.String())
36 | })
37 |
38 | if runtime.GOOS != "windows" {
39 | t.Run("Run with env file", func(t *testing.T) {
40 | t.Parallel()
41 |
42 | envFile, err := createEnvFile(t.TempDir(), []byte("CHARACTER=rick_sanchez"))
43 | defer os.Remove(envFile)
44 | if err != nil {
45 | t.Fatalf("Failed to write env file: %v", err)
46 | }
47 |
48 | buff, err := execRunCommand(&[]string{}, "run", "--env-file", envFile, "--", "printenv", "CHARACTER")
49 | if err != nil {
50 | t.Fatal(err.Error())
51 | }
52 |
53 | result, found := findEnvVariableInLogs(&buff)
54 | if !found {
55 | t.Fatalf("Expected printenv in the output: %s", buff.String())
56 | }
57 | assert.Equal(t, "rick_sanchez", result, fmt.Sprintf("Expected 'printenv | rick_sanchez' in the output, got 'printenv | %s'", result))
58 | })
59 | }
60 |
61 | // FIXME(@chicoxyzzy): Fix tests to work on Windows
62 | if runtime.GOOS != "windows" {
63 | t.Run("Run with env variable", func(t *testing.T) {
64 | t.Parallel()
65 |
66 | // Set environment variables
67 | envVars := []string{"CHARACTER=morty_smith"}
68 |
69 | buff, err := execRunCommand(&envVars, "run", "--", "printenv", "CHARACTER")
70 | if err != nil {
71 | t.Fatal(err.Error())
72 | }
73 |
74 | result, found := findEnvVariableInLogs(&buff)
75 | if !found {
76 | t.Fatalf("Expected printenv in the output: %s", buff.String())
77 | }
78 | assert.Equal(t, "morty_smith", result, fmt.Sprintf("Expected 'printenv | morty_smith' in the output, got 'printenv | %s'", result))
79 | })
80 |
81 | t.Run("Run with env variable in command line has priority over the one in the env file", func(t *testing.T) {
82 | t.Parallel()
83 |
84 | envFile, err := createEnvFile(t.TempDir(), []byte("CHARACTER=rick_sanchez"))
85 | defer os.Remove(envFile)
86 | if err != nil {
87 | t.Fatalf("Failed to write env file: %v", err)
88 | }
89 |
90 | // Set environment variables
91 | envVars := []string{"CHARACTER=morty_smith"}
92 | buff, err := execRunCommand(&envVars, "run", "--env-file", envFile, "--", "printenv", "CHARACTER")
93 | if err != nil {
94 | t.Fatal(err.Error())
95 | }
96 |
97 | result, found := findEnvVariableInLogs(&buff)
98 | if !found {
99 | t.Fatalf("Expected printenv in the output: %s", buff.String())
100 | }
101 | assert.Equal(t, "morty_smith", result, fmt.Sprintf("Expected 'printenv | morty_smith' in the output, got 'printenv | %s'", result))
102 | })
103 |
104 | t.Run("Run with env variable in local env vars has priority over the one in the env file", func(t *testing.T) {
105 | // Do not use t.Parallel() here as we are manipulating the environment!
106 |
107 | // Set environment variables
108 | os.Setenv("CHARACTER", "morty_smith")
109 | defer os.Unsetenv("CHARACTER")
110 |
111 | envFile, err := createEnvFile(t.TempDir(), []byte("CHARACTER=rick_sanchez"))
112 | defer os.Remove(envFile)
113 |
114 | if err != nil {
115 | t.Fatalf("Failed to write env file: %v", err)
116 | }
117 |
118 | buff, err := execRunCommand(&[]string{}, "run", "--env-file", envFile, "--", "printenv", "CHARACTER")
119 | if err != nil {
120 | t.Fatal(err.Error())
121 | }
122 |
123 | result, found := findEnvVariableInLogs(&buff)
124 | if !found {
125 | t.Fatalf("Expected printenv in the output: %s\n\n", buff.String())
126 | }
127 | assert.Equal(t, "morty_smith", result, fmt.Sprintf("Expected 'printenv | morty_smith' in the output, got 'printenv | %s'", result))
128 | })
129 | }
130 | }
131 |
132 | func execRunCommand(envVars *[]string, arg ...string) (bytes.Buffer, error) {
133 | // Create a context with a timeout to ensure the process doesn't run indefinitely
134 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
135 | defer cancel()
136 |
137 | // add the api key to the arguments so the command can run without `dispatch login` being run first
138 | arg = append(arg[:1], append([]string{"--api-key", "00000000"}, arg[1:]...)...)
139 |
140 | // Set up the command
141 | cmd := exec.CommandContext(ctx, dispatchBinary, arg...)
142 |
143 | if len(*envVars) != 0 {
144 | // Set environment variables
145 | cmd.Env = append(os.Environ(), *envVars...)
146 | }
147 |
148 | // Capture the standard error
149 | var errBuf bytes.Buffer
150 | cmd.Stderr = &errBuf
151 |
152 | // Start the command
153 | if err := cmd.Start(); err != nil {
154 | return errBuf, fmt.Errorf("Failed to start command: %w", err)
155 | }
156 |
157 | // Wait for the command to finish or for the context to timeout
158 | // We use Wait() instead of Run() so that we can capture the error
159 | // For example:
160 | // FOO=bar ./build/darwin/amd64/dispatch run -- printenv FOO
161 | // This will exit with
162 | // Error: command 'printenv FOO' exited unexpectedly
163 | // but also it will print...
164 | // printenv | bar
165 | // to the logs and that is exactly what we want to test
166 | // If context timeout occurs, than something went wrong
167 | // and `dispatch run` is running indefinitely.
168 | if err := cmd.Wait(); err != nil {
169 | // Check if the error is due to context timeout (command running too long)
170 | if ctx.Err() == context.DeadlineExceeded {
171 | return errBuf, fmt.Errorf("Command timed out: %w", err)
172 | }
173 | }
174 |
175 | return errBuf, nil
176 | }
177 |
178 | func createEnvFile(path string, content []byte) (string, error) {
179 | envFile := filepath.Join(path, "test.env")
180 | err := os.WriteFile(envFile, content, 0600)
181 | return envFile, err
182 | }
183 |
184 | func findEnvVariableInLogs(buf *bytes.Buffer) (string, bool) {
185 | var result string
186 | found := false
187 |
188 | // Split the log into lines
189 | lines := strings.Split(buf.String(), "\n")
190 |
191 | // Iterate over each line and check for the condition
192 | for _, line := range lines {
193 | if strings.Contains(line, "printenv | ") {
194 | result = strings.Split(line, "printenv | ")[1]
195 | found = true
196 | break
197 | }
198 | }
199 | return result, found
200 | }
201 |
--------------------------------------------------------------------------------
/cli/style.go:
--------------------------------------------------------------------------------
1 | package cli
2 |
3 | import (
4 | "fmt"
5 | "strings"
6 |
7 | "github.com/charmbracelet/bubbles/spinner"
8 | tea "github.com/charmbracelet/bubbletea"
9 | "github.com/charmbracelet/lipgloss"
10 | "github.com/spf13/cobra"
11 | )
12 |
13 | var (
14 | dialogBoxStyle = lipgloss.NewStyle().
15 | Border(lipgloss.RoundedBorder()).
16 | BorderForeground(lipgloss.Color("#874BFD")).
17 | Margin(1, 2).
18 | Padding(1, 2).
19 | BorderTop(true).
20 | BorderLeft(true).
21 | BorderRight(true).
22 | BorderBottom(true)
23 |
24 | successStyle = lipgloss.NewStyle().Foreground(greenColor)
25 |
26 | failureStyle = lipgloss.NewStyle().Foreground(redColor)
27 | )
28 |
29 | type errMsg struct{ error }
30 |
31 | type resultMsg struct{ string }
32 |
33 | type spinnerModel struct {
34 | spinner spinner.Model
35 | exit bool
36 | err error
37 | hello string
38 | result string
39 | fn func() (tea.Msg, error)
40 | }
41 |
42 | func newSpinnerModel(hello string, fn func() (tea.Msg, error)) spinnerModel {
43 | s := spinner.New()
44 | s.Spinner = spinner.Dot
45 | s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
46 | return spinnerModel{
47 | spinner: s,
48 | hello: hello,
49 | fn: fn,
50 | }
51 | }
52 |
53 | func runSpinner(fn func() (tea.Msg, error)) tea.Cmd {
54 | return func() tea.Msg {
55 | result, err := fn()
56 | if err != nil {
57 | return errMsg{err}
58 | }
59 | if result == nil {
60 | return resultMsg{}
61 | }
62 | return resultMsg{result.(string)}
63 | }
64 | }
65 |
66 | func (m spinnerModel) Init() tea.Cmd {
67 | return tea.Batch(
68 | m.spinner.Tick,
69 | runSpinner(m.fn),
70 | )
71 | }
72 |
73 | func (m spinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
74 | switch msg := msg.(type) {
75 | case tea.KeyMsg:
76 | switch msg.String() {
77 | case "q", "esc", "ctrl+c":
78 | m.exit = true
79 | return m, tea.Quit
80 | default:
81 | return m, nil
82 | }
83 | case errMsg:
84 | m.err = msg
85 | return m, tea.Quit
86 | case resultMsg:
87 | m.result = msg.string
88 | return m, tea.Quit
89 | case spinner.TickMsg:
90 | var cmd tea.Cmd
91 | m.spinner, cmd = m.spinner.Update(msg)
92 | return m, cmd
93 | }
94 | return m, nil
95 | }
96 |
97 | func (m spinnerModel) View() string {
98 | if m.err != nil {
99 | return fmt.Sprintf("Error: %s\n", m.err.Error())
100 | }
101 | if m.result != "" {
102 | return m.result
103 | }
104 | str := fmt.Sprintf("%s %s...press q to quit\n", m.spinner.View(), m.hello)
105 | if m.exit {
106 | return str + "\n"
107 | }
108 | return str
109 | }
110 |
111 | func success(msg string) {
112 | fmt.Println(successStyle.Render(msg))
113 | }
114 |
115 | func failure(cmd *cobra.Command, msgs ...string) {
116 | cmd.Println(failureStyle.Render(strings.Join(msgs, " ")) + "\n")
117 | }
118 |
119 | func simple(cmd *cobra.Command, msgs ...string) {
120 | cmd.Println(strings.Join(msgs, " "))
121 | }
122 |
123 | func dialog(msg string, args ...interface{}) {
124 | fmt.Println(dialogBoxStyle.Render(fmt.Sprintf(msg, args...)))
125 | }
126 |
--------------------------------------------------------------------------------
/cli/switch.go:
--------------------------------------------------------------------------------
1 | package cli
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "os"
7 |
8 | "github.com/spf13/cobra"
9 | )
10 |
11 | var (
12 | SwitchCmdLong = `Switch between Dispatch organizations.
13 |
14 | The switch command is used to select which organization is used
15 | when running a Dispatch application locally.
16 |
17 | To manage your organizations, visit the Dispatch Console: https://console.dispatch.run/`
18 | )
19 |
20 | func switchCommand(configPath string) *cobra.Command {
21 | cmd := &cobra.Command{
22 | Use: "switch [organization]",
23 | Short: "Switch between organizations",
24 | Long: SwitchCmdLong,
25 | GroupID: "management",
26 | RunE: func(cmd *cobra.Command, args []string) error {
27 | cfg, err := LoadConfig(configPath)
28 | if err != nil {
29 | if !errors.Is(err, os.ErrNotExist) {
30 | failure(cmd, fmt.Sprintf("Failed to load Dispatch configuration: %v", err))
31 | }
32 |
33 | // User must login to create a configuration file.
34 | simple(cmd, "Please run `dispatch login` to login to Dispatch.")
35 | return nil
36 | }
37 |
38 | // List organizations if no arguments were provided.
39 | if len(args) == 0 {
40 | simple(cmd, "Available organizations:")
41 | for org := range cfg.Organization {
42 | simple(cmd, "-", org)
43 | }
44 | return nil
45 | }
46 |
47 | // Otherwise, try to switch to the specified organization.
48 | name := args[0]
49 | _, ok := cfg.Organization[name]
50 | if !ok {
51 | failure(cmd, fmt.Sprintf("Organization '%s' not found", name))
52 |
53 | simple(cmd, "Available organizations:")
54 | for org := range cfg.Organization {
55 | simple(cmd, "-", org)
56 | }
57 | return nil
58 | }
59 |
60 | simple(cmd, fmt.Sprintf("Switched to organization: %v", name))
61 | cfg.Active = name
62 | return CreateConfig(configPath, cfg)
63 | },
64 | }
65 | return cmd
66 | }
67 |
--------------------------------------------------------------------------------
/cli/switch_test.go:
--------------------------------------------------------------------------------
1 | package cli
2 |
3 | import (
4 | "bytes"
5 | "os"
6 | "path/filepath"
7 | "testing"
8 |
9 | "github.com/charmbracelet/lipgloss"
10 | "github.com/muesli/termenv"
11 | "github.com/stretchr/testify/assert"
12 | )
13 |
14 | type testCase struct {
15 | name string
16 | args []string
17 | configExists bool
18 | configContent string
19 | }
20 |
21 | type expectedOutput struct {
22 | stdout string
23 | stderr string
24 | }
25 |
26 | func TestSwitchCommand(t *testing.T) {
27 | tcs := []struct {
28 | in testCase
29 | out expectedOutput
30 | }{
31 | {
32 | in: testCase{
33 | name: "Config file doesn't exist",
34 | args: []string{"org1"},
35 | configExists: false,
36 | },
37 | out: expectedOutput{
38 | stdout: "Please run `dispatch login` to login to Dispatch.\n",
39 | },
40 | },
41 | {
42 | in: testCase{
43 | name: "No arguments provided",
44 | args: []string{},
45 | configExists: true,
46 | configContent: `
47 | # Warning = 'THIS FILE IS GENERATED. DO NOT EDIT!'
48 | active = 'x-s-org'
49 |
50 | [Organizations]
51 | [Organizations.x-s-org]
52 | api_key = 'x'
53 | `,
54 | },
55 | out: expectedOutput{
56 | stdout: "Available organizations:\n- x-s-org\n",
57 | },
58 | },
59 | {
60 | in: testCase{
61 | name: "Switch to non-existing organization",
62 | args: []string{"random"},
63 | configExists: true,
64 | configContent: `
65 | # Warning = 'THIS FILE IS GENERATED. DO NOT EDIT!'
66 | active = 'x-s-org'
67 |
68 | [Organizations]
69 | [Organizations.x-s-org]
70 | api_key = 'x'
71 | `,
72 | },
73 | out: expectedOutput{
74 | stdout: "Organization 'random' not found\n\nAvailable organizations:\n- x-s-org\n",
75 | },
76 | },
77 | {
78 | in: testCase{
79 | name: "Switch to existing organization",
80 | args: []string{"x-s-org"},
81 | configExists: true,
82 | configContent: `
83 | # Warning = 'THIS FILE IS GENERATED. DO NOT EDIT!'
84 | active = 'x-s-org'
85 |
86 | [Organizations]
87 | [Organizations.x-s-org]
88 | api_key = 'x'
89 | `,
90 | },
91 | out: expectedOutput{
92 | stdout: "Switched to organization: x-s-org\n",
93 | },
94 | },
95 | }
96 |
97 | for _, tc := range tcs {
98 | tc := tc
99 | t.Run(tc.in.name, func(t *testing.T) {
100 | t.Parallel()
101 |
102 | configPath := setupConfig(t, tc.in)
103 | stdout := &bytes.Buffer{}
104 | stderr := &bytes.Buffer{}
105 | cmd := switchCommand(configPath)
106 | cmd.SetOut(stdout)
107 | cmd.SetErr(stderr)
108 | cmd.SetArgs(tc.in.args)
109 |
110 | if err := cmd.Execute(); err != nil {
111 | t.Fatalf("Received unexpected error: %v", err)
112 | }
113 |
114 | assert.Equal(t, tc.out.stdout, stdout.String())
115 | assert.Equal(t, tc.out.stderr, stderr.String())
116 | })
117 | }
118 | }
119 |
120 | func setupConfig(t *testing.T, tc testCase) string {
121 | lipgloss.SetColorProfile(termenv.Ascii)
122 | tempDir := t.TempDir() // unique temp dir for each test and cleaned up after test finishes
123 | configPath := filepath.Join(tempDir, "config.yaml")
124 | if tc.configExists {
125 | err := os.WriteFile(configPath, []byte(tc.configContent), 0600)
126 | assert.NoError(t, err)
127 | } else {
128 | os.Remove(configPath)
129 | }
130 | return configPath
131 | }
132 |
--------------------------------------------------------------------------------
/cli/text.go:
--------------------------------------------------------------------------------
1 | package cli
2 |
3 | import (
4 | "strings"
5 |
6 | "github.com/muesli/reflow/ansi"
7 | )
8 |
9 | func whitespace(width int) string {
10 | return strings.Repeat(" ", width)
11 | }
12 |
13 | func padding(width int, s string) int {
14 | return width - ansi.PrintableRuneWidth(s)
15 | }
16 |
17 | func truncate(width int, s string) string {
18 | var truncated bool
19 | for len(s) > 0 && ansi.PrintableRuneWidth(s) > width {
20 | s = s[:len(s)-1]
21 | truncated = true
22 | }
23 | if truncated {
24 | s = s + "\033[0m"
25 | }
26 | return s
27 | }
28 |
29 | func right(width int, s string) string {
30 | if ansi.PrintableRuneWidth(s) > width {
31 | return truncate(width-3, s) + "..."
32 | }
33 | return whitespace(padding(width, s)) + s
34 | }
35 |
36 | func left(width int, s string) string {
37 | if ansi.PrintableRuneWidth(s) > width {
38 | return truncate(width-3, s) + "..."
39 | }
40 | return s + whitespace(padding(width, s))
41 | }
42 |
43 | func join(rows ...string) string {
44 | var b strings.Builder
45 | for i, row := range rows {
46 | if i > 0 {
47 | b.WriteByte(' ')
48 | }
49 | b.WriteString(row)
50 | }
51 | return b.String()
52 | }
53 |
54 | func clearANSI(s string) string {
55 | var isANSI bool
56 | var b strings.Builder
57 | for _, c := range s {
58 | if c == ansi.Marker {
59 | isANSI = true
60 | } else if isANSI {
61 | if ansi.IsTerminator(c) {
62 | isANSI = false
63 | }
64 | } else {
65 | b.WriteRune(c)
66 | }
67 | }
68 | return b.String()
69 | }
70 |
--------------------------------------------------------------------------------
/cli/tui.go:
--------------------------------------------------------------------------------
1 | package cli
2 |
3 | import (
4 | "bytes"
5 | "errors"
6 | "fmt"
7 | "math"
8 | "net/http"
9 | "strconv"
10 | "strings"
11 | "sync"
12 | "time"
13 |
14 | sdkv1 "buf.build/gen/go/stealthrocket/dispatch-proto/protocolbuffers/go/dispatch/sdk/v1"
15 | "github.com/charmbracelet/bubbles/help"
16 | "github.com/charmbracelet/bubbles/key"
17 | "github.com/charmbracelet/bubbles/textinput"
18 | "github.com/charmbracelet/bubbles/viewport"
19 | tea "github.com/charmbracelet/bubbletea"
20 | "github.com/charmbracelet/lipgloss"
21 | "github.com/muesli/reflow/ansi"
22 | )
23 |
24 | const (
25 | refreshInterval = time.Second / 10
26 | underscoreBlinkInterval = time.Second / 2
27 | )
28 |
29 | const (
30 | pendingIcon = "•" // U+2022
31 | successIcon = "✔" // U+2714
32 | failureIcon = "✗" // U+2718
33 | )
34 |
35 | var (
36 | // Style for the viewport that contains everything.
37 | viewportStyle = lipgloss.NewStyle().Margin(1, 2)
38 |
39 | // Styles for the dispatch_ ASCII logo.
40 | logoStyle = lipgloss.NewStyle().Foreground(defaultColor)
41 | logoUnderscoreStyle = lipgloss.NewStyle().Foreground(greenColor)
42 |
43 | // Style for the table of function calls.
44 | tableHeaderStyle = lipgloss.NewStyle().Foreground(defaultColor).Bold(true)
45 | selectedStyle = lipgloss.NewStyle().Background(magentaColor)
46 |
47 | // Styles for function names and statuses in the table.
48 | pendingStyle = lipgloss.NewStyle().Foreground(grayColor)
49 | suspendedStyle = lipgloss.NewStyle().Foreground(grayColor)
50 | retryStyle = lipgloss.NewStyle().Foreground(yellowColor)
51 | errorStyle = lipgloss.NewStyle().Foreground(redColor)
52 | okStyle = lipgloss.NewStyle().Foreground(greenColor)
53 |
54 | // Styles for other components inside the table.
55 | treeStyle = lipgloss.NewStyle().Foreground(grayColor)
56 |
57 | // Styles for the function call detail tab.
58 | detailHeaderStyle = lipgloss.NewStyle().Foreground(grayColor)
59 | detailLowPriorityStyle = lipgloss.NewStyle().Foreground(grayColor)
60 | )
61 |
62 | type TUI struct {
63 | ticks uint64
64 |
65 | // Storage for the function call hierarchies.
66 | //
67 | // FIXME: we never clean up items from these maps
68 | roots map[DispatchID]struct{}
69 | orderedRoots []DispatchID
70 | calls map[DispatchID]functionCall
71 |
72 | // Storage for logs.
73 | logs bytes.Buffer
74 |
75 | // TUI models / options / flags, used to display the information
76 | // above.
77 | viewport viewport.Model
78 | selection textinput.Model
79 | help help.Model
80 | ready bool
81 | activeTab tab
82 | selectMode bool
83 | tailMode bool
84 | logoHelp string
85 | logsTabHelp string
86 | functionsTabHelp string
87 | detailTabHelp string
88 | selectHelp string
89 | windowHeight int
90 | selected *DispatchID
91 |
92 | err error
93 |
94 | mu sync.Mutex
95 | }
96 |
97 | type tab int
98 |
99 | const (
100 | functionsTab tab = iota
101 | logsTab
102 | detailTab
103 | )
104 |
105 | const tabCount = 3
106 |
107 | var (
108 | showFunctionsTabKey = key.NewBinding(
109 | key.WithKeys("tab"),
110 | key.WithHelp("tab", "show functions"),
111 | )
112 |
113 | showLogsTabKey = key.NewBinding(
114 | key.WithKeys("tab"),
115 | key.WithHelp("tab", "show logs"),
116 | )
117 |
118 | selectModeKey = key.NewBinding(
119 | key.WithKeys("s"),
120 | key.WithHelp("s", "select function"),
121 | )
122 |
123 | tailKey = key.NewBinding(
124 | key.WithKeys("t"),
125 | key.WithHelp("t", "tail"),
126 | )
127 |
128 | quitKey = key.NewBinding(
129 | key.WithKeys("q", "ctrl+c", "esc"),
130 | key.WithHelp("q", "quit"),
131 | )
132 |
133 | selectKeys = key.NewBinding(
134 | key.WithKeys("enter"),
135 | key.WithHelp("0-9+ enter", "select function"),
136 | )
137 |
138 | exitSelectKey = key.NewBinding(
139 | key.WithKeys("esc"),
140 | key.WithHelp("esc", "show functions"),
141 | )
142 |
143 | scrollKeys = key.NewBinding(
144 | key.WithKeys("up", "down"),
145 | key.WithHelp("↑↓", "scroll"),
146 | )
147 |
148 | logoKeyMap = []key.Binding{showLogsTabKey, quitKey}
149 | functionsTabKeyMap = []key.Binding{showLogsTabKey, selectModeKey, scrollKeys, quitKey}
150 | detailTabKeyMap = []key.Binding{showFunctionsTabKey, scrollKeys, quitKey}
151 | logsTabKeyMap = []key.Binding{showFunctionsTabKey, tailKey, scrollKeys, quitKey}
152 | selectKeyMap = []key.Binding{selectKeys, scrollKeys, exitSelectKey}
153 | )
154 |
155 | type tickMsg struct{}
156 |
157 | func tick() tea.Cmd {
158 | // The TUI isn't in the driver's seat. Instead, we have the layer
159 | // up coordinating the interactions between the Dispatch API and
160 | // the local application. The layer up notifies the TUI of changes
161 | // via the FunctionCallObserver interface.
162 | //
163 | // To keep the TUI up to date, we have a ticker that sends messages
164 | // at a fixed interval.
165 | return tea.Tick(refreshInterval, func(time.Time) tea.Msg {
166 | return tickMsg{}
167 | })
168 | }
169 |
170 | type focusSelectMsg struct{}
171 |
172 | func focusSelect() tea.Msg {
173 | return focusSelectMsg{}
174 | }
175 |
176 | func (t *TUI) Init() tea.Cmd {
177 | // Note that t.viewport is initialized on the first tea.WindowSizeMsg.
178 | t.help = help.New()
179 |
180 | t.selection = textinput.New()
181 | t.selection.Focus() // input is visibile iff t.selectMode == true
182 |
183 | t.selectMode = false
184 | t.tailMode = true
185 |
186 | t.activeTab = functionsTab
187 | t.logoHelp = t.help.ShortHelpView(logoKeyMap)
188 | t.logsTabHelp = t.help.ShortHelpView(logsTabKeyMap)
189 | t.functionsTabHelp = t.help.ShortHelpView(functionsTabKeyMap)
190 | t.detailTabHelp = t.help.ShortHelpView(detailTabKeyMap)
191 | t.selectHelp = t.help.ShortHelpView(selectKeyMap)
192 |
193 | return tick()
194 | }
195 |
196 | func (t *TUI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
197 | // Here we handle "messages" such as key presses, window size changes,
198 | // refresh ticks, etc. Note that the TUI view is updated after messages
199 | // have been processed.
200 | var cmd tea.Cmd
201 | var cmds []tea.Cmd
202 | switch msg := msg.(type) {
203 | case tickMsg:
204 | t.ticks++
205 | cmds = append(cmds, tick())
206 |
207 | case focusSelectMsg:
208 | t.selectMode = true
209 | t.selection.SetValue("")
210 | cmds = append(cmds, textinput.Blink)
211 |
212 | case tea.WindowSizeMsg:
213 | t.windowHeight = msg.Height
214 | height := msg.Height - 1 // reserve space for status bar
215 | width := msg.Width
216 | if !t.ready {
217 | t.viewport = viewport.New(width, height)
218 | t.viewport.Style = viewportStyle
219 | t.ready = true
220 | } else {
221 | t.viewport.Width = width
222 | t.viewport.Height = height
223 | }
224 |
225 | case tea.KeyMsg:
226 | if t.selectMode {
227 | switch msg.String() {
228 | case "esc":
229 | t.selectMode = false
230 | case "tab":
231 | t.selectMode = false
232 | t.activeTab = functionsTab
233 | t.viewport.YOffset = 0 // reset
234 | t.tailMode = true
235 | case "enter":
236 | if t.selected != nil {
237 | t.selectMode = false
238 | t.activeTab = detailTab
239 | t.viewport.YOffset = 0 // reset
240 | }
241 | case "ctrl+c":
242 | return t, tea.Quit
243 | }
244 | } else {
245 | switch msg.String() {
246 | case "esc":
247 | if t.activeTab == detailTab {
248 | t.activeTab = functionsTab
249 | t.viewport.YOffset = 0 // reset
250 | t.tailMode = true
251 | } else {
252 | return t, tea.Quit
253 | }
254 | case "ctrl+c", "q":
255 | return t, tea.Quit
256 | case "s":
257 | // Don't accept s/select until at least one function
258 | // call has been received.
259 | if len(t.calls) > 0 && t.err == nil {
260 | cmds = append(cmds, focusSelect)
261 | }
262 | case "t":
263 | t.tailMode = true
264 | case "v":
265 | Verbose = true
266 | case "tab":
267 | t.selectMode = false
268 | t.activeTab = (t.activeTab + 1) % tabCount
269 | if t.activeTab == detailTab && t.selected == nil {
270 | t.activeTab = functionsTab
271 | }
272 | t.viewport.YOffset = 0 // reset
273 | t.tailMode = true
274 | case "up", "down", "left", "right", "pgup", "pgdown", "ctrl+u", "ctrl+d":
275 | t.tailMode = false
276 | }
277 | }
278 | }
279 |
280 | // Forward messages to the text input in select mode.
281 | if t.selectMode {
282 | t.selection, cmd = t.selection.Update(msg)
283 | if cmd != nil {
284 | cmds = append(cmds, cmd)
285 | }
286 | }
287 |
288 | // Forward messages to the viewport, e.g. for scroll-back support.
289 | t.viewport, cmd = t.viewport.Update(msg)
290 | if cmd != nil {
291 | cmds = append(cmds, cmd)
292 | }
293 |
294 | cmd = nil
295 | switch {
296 | case len(cmds) == 1:
297 | cmd = cmds[0]
298 | case len(cmds) > 1:
299 | cmd = tea.Batch(cmds...)
300 | }
301 |
302 | return t, cmd
303 | }
304 |
305 | func (t *TUI) View() string {
306 | t.mu.Lock()
307 | defer t.mu.Unlock()
308 |
309 | var viewportContent string
310 | var statusBarContent string
311 | var helpContent string
312 | if !t.ready {
313 | viewportContent = t.logoView()
314 | statusBarContent = "Initializing..."
315 | helpContent = t.logoHelp
316 | } else {
317 | switch t.activeTab {
318 | case functionsTab:
319 | if len(t.roots) == 0 {
320 | viewportContent = t.logoView()
321 | statusBarContent = "Waiting for function calls..."
322 | helpContent = t.logoHelp
323 | } else {
324 | viewportContent = t.functionsView(time.Now())
325 | if len(t.calls) == 1 {
326 | statusBarContent = "1 total function call"
327 | } else {
328 | statusBarContent = fmt.Sprintf("%d total function calls", len(t.calls))
329 | }
330 | var inflightCount int
331 | for _, n := range t.calls {
332 | if !n.done {
333 | inflightCount++
334 | }
335 | }
336 | statusBarContent += fmt.Sprintf(", %d in-flight", inflightCount)
337 | helpContent = t.functionsTabHelp
338 | }
339 | if t.selectMode {
340 | statusBarContent = t.selection.View()
341 | helpContent = t.selectHelp
342 | }
343 | case detailTab:
344 | id := *t.selected
345 | viewportContent = t.detailView(id)
346 | helpContent = t.detailTabHelp
347 | case logsTab:
348 | viewportContent = t.logs.String()
349 | helpContent = t.logsTabHelp
350 | }
351 | }
352 |
353 | if t.err != nil {
354 | statusBarContent = errorStyle.Render(t.err.Error())
355 | }
356 |
357 | t.viewport.SetContent(viewportContent)
358 |
359 | // Shrink the viewport so it contains the content and status bar only.
360 | footerHeight := 1
361 | if statusBarContent != "" {
362 | footerHeight = 3
363 | }
364 | maxViewportHeight := max(t.windowHeight-footerHeight, 8)
365 | t.viewport.Height = min(t.viewport.TotalLineCount()+1, maxViewportHeight)
366 |
367 | // Tail the output, unless the user has tried
368 | // to scroll back (e.g. with arrow keys).
369 | if t.tailMode && !t.viewport.AtBottom() {
370 | t.viewport.GotoBottom()
371 | }
372 |
373 | var b strings.Builder
374 | b.WriteString(t.viewport.View())
375 | b.WriteByte('\n')
376 | if statusBarContent != "" {
377 | b.WriteString(" ")
378 | b.WriteString(statusBarContent)
379 | b.WriteString("\n\n")
380 | }
381 | b.WriteString(" ")
382 | b.WriteString(helpContent)
383 | return b.String()
384 | }
385 |
386 | // https://patorjk.com/software/taag/ (Ogre)
387 | var dispatchAscii = []string{
388 | ` _ _ _ _`,
389 | ` __| (_)___ _ __ __ _| |_ ___| |__`,
390 | ` / _' | / __| '_ \ / _' | __/ __| '_ \`,
391 | `| (_| | \__ \ |_) | (_| | || (__| | | |`,
392 | ` \__,_|_|___/ .__/ \__,_|\__\___|_| |_|`,
393 | ` |_|`,
394 | }
395 |
396 | var underscoreAscii = []string{
397 | " _____",
398 | "|_____|",
399 | }
400 |
401 | const underscoreIndex = 3
402 |
403 | func (t *TUI) logoView() string {
404 | showUnderscore := (t.ticks/uint64(underscoreBlinkInterval/refreshInterval))%2 == 0
405 |
406 | var b strings.Builder
407 | for i, line := range dispatchAscii {
408 | b.WriteString(logoStyle.Render(line))
409 | if showUnderscore {
410 | if i >= underscoreIndex && i-underscoreIndex < len(underscoreAscii) {
411 | b.WriteString(logoUnderscoreStyle.Render(underscoreAscii[i-underscoreIndex]))
412 | }
413 | }
414 | b.WriteByte('\n')
415 | }
416 | return b.String()
417 | }
418 |
419 | func (t *TUI) functionsView(now time.Time) string {
420 | t.selected = nil
421 |
422 | // Render function calls in a hybrid table/tree view.
423 | var b strings.Builder
424 | var rows rowBuffer
425 | for i, rootID := range t.orderedRoots {
426 | if i > 0 {
427 | b.WriteByte('\n')
428 | }
429 |
430 | // Buffer rows in memory.
431 | t.buildRows(now, rootID, nil, &rows)
432 |
433 | // Dynamically size the function call tree column.
434 | maxFunctionWidth := 0
435 | for i := range rows.rows {
436 | maxFunctionWidth = max(maxFunctionWidth, ansi.PrintableRuneWidth(rows.rows[i].function))
437 | }
438 | functionColumnWidth := max(9, min(50, maxFunctionWidth))
439 |
440 | // Render the table.
441 | if i == 0 {
442 | b.WriteString(t.tableHeaderView(functionColumnWidth))
443 | }
444 | for i := range rows.rows {
445 | b.WriteString(t.tableRowView(&rows.rows[i], functionColumnWidth))
446 | }
447 |
448 | rows.reset()
449 | }
450 | b.WriteByte('\n')
451 | return b.String()
452 | }
453 |
454 | func (t *TUI) tableHeaderView(functionColumnWidth int) string {
455 | columns := []string{
456 | left(functionColumnWidth, tableHeaderStyle.Render("Function")),
457 | right(8, tableHeaderStyle.Render("Attempt")),
458 | right(10, tableHeaderStyle.Render("Duration")),
459 | left(1, pendingIcon),
460 | left(35, tableHeaderStyle.Render("Status")),
461 | }
462 | if t.selectMode {
463 | idWidth := int(math.Log10(float64(len(t.calls)))) + 1
464 | columns = append([]string{left(idWidth, strings.Repeat("#", idWidth))}, columns...)
465 | }
466 | return join(columns...) + "\n"
467 | }
468 |
469 | func (t *TUI) tableRowView(r *row, functionColumnWidth int) string {
470 | attemptStr := strconv.Itoa(r.attempt)
471 |
472 | var durationStr string
473 | if r.duration > 0 {
474 | durationStr = r.duration.String()
475 | } else {
476 | durationStr = "?"
477 | }
478 |
479 | values := []string{
480 | left(functionColumnWidth, r.function),
481 | right(8, attemptStr),
482 | right(10, durationStr),
483 | left(1, r.icon),
484 | left(35, r.status),
485 | }
486 |
487 | id := strconv.Itoa(r.index)
488 | var selected bool
489 | if t.selectMode {
490 | idWidth := int(math.Log10(float64(len(t.calls)))) + 1
491 | paddedID := left(idWidth, id)
492 | if input := strings.TrimSpace(t.selection.Value()); input != "" && id == input {
493 | selected = true
494 | t.selected = &r.id
495 | }
496 | values = append([]string{paddedID}, values...)
497 | }
498 | result := join(values...)
499 | if selected {
500 | result = selectedStyle.Render(clearANSI(result))
501 | }
502 | return result + "\n"
503 | }
504 |
505 | func (t *TUI) detailView(id DispatchID) string {
506 | now := time.Now()
507 |
508 | n := t.calls[id]
509 |
510 | style, _, status := n.status(now)
511 |
512 | var view strings.Builder
513 |
514 | add := func(name, value string) {
515 | const padding = 16
516 | view.WriteString(right(padding, detailHeaderStyle.Render(name+":")))
517 | view.WriteByte(' ')
518 | view.WriteString(value)
519 | view.WriteByte('\n')
520 | }
521 |
522 | const timestampFormat = "2006-01-02T15:04:05.000"
523 |
524 | add("ID", detailLowPriorityStyle.Render(string(id)))
525 | add("Function", n.function())
526 | add("Status", style.Render(status))
527 | add("Creation time", detailLowPriorityStyle.Render(n.creationTime.Local().Format(timestampFormat)))
528 | if !n.expirationTime.IsZero() && !n.done {
529 | add("Expiration time", detailLowPriorityStyle.Render(n.expirationTime.Local().Format(timestampFormat)))
530 | }
531 | add("Duration", n.duration(now).String())
532 | add("Attempts", strconv.Itoa(n.attempt()))
533 | add("Requests", strconv.Itoa(len(n.timeline)))
534 |
535 | var result strings.Builder
536 | result.WriteString(view.String())
537 |
538 | for _, rt := range n.timeline {
539 | view.Reset()
540 |
541 | result.WriteByte('\n')
542 |
543 | // TODO: show request # and/or attempt #?
544 |
545 | add("Timestamp", detailLowPriorityStyle.Render(rt.request.ts.Local().Format(timestampFormat)))
546 | req := rt.request.proto
547 | switch d := req.Directive.(type) {
548 | case *sdkv1.RunRequest_Input:
549 | if rt.request.input == "" {
550 | rt.request.input = anyString(d.Input)
551 | }
552 | add("Input", rt.request.input)
553 |
554 | case *sdkv1.RunRequest_PollResult:
555 | switch s := d.PollResult.State.(type) {
556 | case *sdkv1.PollResult_CoroutineState:
557 | add("Input", detailLowPriorityStyle.Render(fmt.Sprintf("<%d bytes of opaque state>", len(s.CoroutineState))))
558 | case *sdkv1.PollResult_TypedCoroutineState:
559 | if any := s.TypedCoroutineState; any != nil {
560 | add("Input", detailLowPriorityStyle.Render(fmt.Sprintf("<%d bytes of %s state>", len(any.Value), typeName(any.TypeUrl))))
561 | } else {
562 | add("Input", detailLowPriorityStyle.Render(""))
563 | }
564 | case nil:
565 | add("Input", detailLowPriorityStyle.Render(""))
566 | default:
567 | add("Input", detailLowPriorityStyle.Render(""))
568 | }
569 | // TODO: show call results
570 | // TODO: show poll error
571 | }
572 |
573 | if rt.response.ts.IsZero() {
574 | add("Status", "Running")
575 | } else {
576 | if res := rt.response.proto; res != nil {
577 | switch d := res.Directive.(type) {
578 | case *sdkv1.RunResponse_Exit:
579 | var statusStyle lipgloss.Style
580 | if res.Status == sdkv1.Status_STATUS_OK {
581 | statusStyle = okStyle
582 | } else if terminalStatus(res.Status) {
583 | statusStyle = errorStyle
584 | } else {
585 | statusStyle = retryStyle
586 | }
587 | add("Status", statusStyle.Render(statusString(res.Status)))
588 |
589 | if result := d.Exit.Result; result != nil {
590 | if rt.response.output == "" {
591 | rt.response.output = anyString(result.Output)
592 | }
593 | add("Output", rt.response.output)
594 |
595 | if result.Error != nil {
596 | errorMessage := result.Error.Type
597 | if result.Error.Message != "" {
598 | errorMessage += ": " + result.Error.Message
599 | }
600 | add("Error", statusStyle.Render(errorMessage))
601 | }
602 | }
603 | if tailCall := d.Exit.TailCall; tailCall != nil {
604 | add("Tail call", tailCall.Function)
605 | }
606 |
607 | case *sdkv1.RunResponse_Poll:
608 | add("Status", suspendedStyle.Render("Suspended"))
609 |
610 | switch s := d.Poll.State.(type) {
611 | case *sdkv1.Poll_CoroutineState:
612 | add("Output", detailLowPriorityStyle.Render(fmt.Sprintf("<%d bytes of opaque state>", len(s.CoroutineState))))
613 | case *sdkv1.Poll_TypedCoroutineState:
614 | if any := s.TypedCoroutineState; any != nil {
615 | add("Output", detailLowPriorityStyle.Render(fmt.Sprintf("<%d bytes of %s state>", len(any.Value), typeName(any.TypeUrl))))
616 | } else {
617 | add("Output", detailLowPriorityStyle.Render(""))
618 | }
619 | case nil:
620 | add("Output", detailLowPriorityStyle.Render(""))
621 | default:
622 | add("Output", detailLowPriorityStyle.Render(""))
623 | }
624 |
625 | if len(d.Poll.Calls) > 0 {
626 | var calls strings.Builder
627 | for i, call := range d.Poll.Calls {
628 | if i > 0 {
629 | calls.WriteString(", ")
630 | }
631 | calls.WriteString(call.Function)
632 | }
633 | add("Calls", truncate(50, calls.String()))
634 | }
635 | }
636 | } else if c := rt.response.httpStatus; c != 0 {
637 | style := errorStyle
638 | if !terminalHTTPStatusCode(c) {
639 | style = retryStyle
640 | }
641 | add("Error", style.Render(fmt.Sprintf("%d %s", c, http.StatusText(c))))
642 | } else if rt.response.err != nil {
643 | add("Error", retryStyle.Render(rt.response.err.Error()))
644 | }
645 |
646 | latency := rt.response.ts.Sub(rt.request.ts)
647 | add("Latency", latency.String())
648 | }
649 | result.WriteString(view.String())
650 | }
651 |
652 | return result.String()
653 | }
654 |
655 | type row struct {
656 | id DispatchID
657 | index int
658 | function string
659 | attempt int
660 | duration time.Duration
661 | icon string
662 | status string
663 | }
664 |
665 | type rowBuffer struct {
666 | rows []row
667 | seq int
668 | }
669 |
670 | func (b *rowBuffer) add(r row) {
671 | b.seq++
672 | r.index = b.seq
673 | b.rows = append(b.rows, r)
674 | }
675 |
676 | func (b *rowBuffer) reset() {
677 | b.rows = b.rows[:0]
678 | }
679 |
680 | func (t *TUI) buildRows(now time.Time, id DispatchID, isLast []bool, rows *rowBuffer) {
681 | n := t.calls[id]
682 |
683 | // Render the tree prefix.
684 | var function strings.Builder
685 | for i, last := range isLast {
686 | var s string
687 | if i == len(isLast)-1 {
688 | if last {
689 | s = "└─"
690 | } else {
691 | s = "├─"
692 | }
693 | } else {
694 | if last {
695 | s = " "
696 | } else {
697 | s = "│ "
698 | }
699 | }
700 | function.WriteString(treeStyle.Render(s))
701 | function.WriteByte(' ')
702 | }
703 |
704 | style, icon, status := n.status(now)
705 |
706 | function.WriteString(style.Render(n.function()))
707 |
708 | rows.add(row{
709 | id: id,
710 | function: function.String(),
711 | attempt: n.attempt(),
712 | duration: n.duration(now),
713 | icon: style.Render(icon),
714 | status: style.Render(status),
715 | })
716 |
717 | // Recursively render children.
718 | for i, id := range n.orderedChildren {
719 | last := i == len(n.orderedChildren)-1
720 | t.buildRows(now, id, append(isLast[:len(isLast):len(isLast)], last), rows)
721 | }
722 | }
723 |
724 | type DispatchID string
725 |
726 | type functionCall struct {
727 | lastFunction string
728 | lastStatus sdkv1.Status
729 | lastError error
730 |
731 | failures int
732 | polls int
733 |
734 | running bool
735 | suspended bool
736 | done bool
737 |
738 | creationTime time.Time
739 | expirationTime time.Time
740 | doneTime time.Time
741 |
742 | children map[DispatchID]struct{}
743 | orderedChildren []DispatchID
744 |
745 | timeline []*roundtrip
746 | }
747 |
748 | type roundtrip struct {
749 | request runRequest
750 | response runResponse
751 | }
752 |
753 | type runRequest struct {
754 | ts time.Time
755 | proto *sdkv1.RunRequest
756 | input string
757 | }
758 |
759 | type runResponse struct {
760 | ts time.Time
761 | proto *sdkv1.RunResponse
762 | httpStatus int
763 | err error
764 | output string
765 | }
766 |
767 | func (n *functionCall) function() string {
768 | if n.lastFunction != "" {
769 | return n.lastFunction
770 | }
771 | return "(?)"
772 | }
773 |
774 | func (n *functionCall) status(now time.Time) (style lipgloss.Style, icon, status string) {
775 | icon = pendingIcon
776 | if n.running {
777 | style = pendingStyle
778 | } else if n.suspended {
779 | style = suspendedStyle
780 | } else if n.done {
781 | if n.lastStatus == sdkv1.Status_STATUS_OK {
782 | style = okStyle
783 | icon = successIcon
784 | } else {
785 | style = errorStyle
786 | icon = failureIcon
787 | }
788 | } else if !n.expirationTime.IsZero() && n.expirationTime.Before(now) {
789 | n.lastError = errors.New("Expired")
790 | style = errorStyle
791 | n.done = true
792 | n.doneTime = n.expirationTime
793 | icon = failureIcon
794 | } else if n.failures > 0 {
795 | style = retryStyle
796 | } else {
797 | style = pendingStyle
798 | }
799 |
800 | if n.running {
801 | status = "Running"
802 | } else if n.suspended {
803 | status = "Suspended"
804 | } else if n.lastError != nil {
805 | status = n.lastError.Error()
806 | } else if n.lastStatus != sdkv1.Status_STATUS_UNSPECIFIED {
807 | status = statusString(n.lastStatus)
808 | } else {
809 | status = "Pending"
810 | }
811 |
812 | return
813 | }
814 |
815 | func (n *functionCall) attempt() int {
816 | attempt := len(n.timeline) - n.polls
817 | if n.suspended {
818 | attempt++
819 | }
820 | return attempt
821 | }
822 |
823 | func (n *functionCall) duration(now time.Time) time.Duration {
824 | var duration time.Duration
825 | if !n.creationTime.IsZero() {
826 | var start time.Time
827 | if !n.creationTime.IsZero() && n.creationTime.Before(n.timeline[0].request.ts) {
828 | start = n.creationTime
829 | } else {
830 | start = n.timeline[0].request.ts
831 | }
832 | var end time.Time
833 | if !n.done {
834 | end = now
835 | } else {
836 | end = n.doneTime
837 | }
838 | duration = end.Sub(start).Truncate(time.Millisecond)
839 | }
840 | return max(duration, 0)
841 | }
842 |
843 | func (t *TUI) ObserveRequest(now time.Time, req *sdkv1.RunRequest) {
844 | // ObserveRequest is part of the FunctionCallObserver interface.
845 | // It's called after a request has been received from the Dispatch API,
846 | // and before the request has been sent to the local application.
847 |
848 | t.mu.Lock()
849 | defer t.mu.Unlock()
850 |
851 | if t.roots == nil {
852 | t.roots = map[DispatchID]struct{}{}
853 | }
854 | if t.calls == nil {
855 | t.calls = map[DispatchID]functionCall{}
856 | }
857 |
858 | rootID := DispatchID(req.RootDispatchId)
859 | parentID := DispatchID(req.ParentDispatchId)
860 | id := DispatchID(req.DispatchId)
861 |
862 | // Upsert the root.
863 | if _, ok := t.roots[rootID]; !ok {
864 | t.roots[rootID] = struct{}{}
865 | t.orderedRoots = append(t.orderedRoots, rootID)
866 | }
867 | root, ok := t.calls[rootID]
868 | if !ok {
869 | root = functionCall{}
870 | }
871 | t.calls[rootID] = root
872 |
873 | // Upsert the function call.
874 | n, ok := t.calls[id]
875 | if !ok {
876 | n = functionCall{}
877 | }
878 | n.lastFunction = req.Function
879 | n.running = true
880 | n.suspended = false
881 | if req.CreationTime != nil {
882 | n.creationTime = req.CreationTime.AsTime()
883 | }
884 | if n.creationTime.IsZero() {
885 | n.creationTime = now
886 | }
887 | if req.ExpirationTime != nil {
888 | n.expirationTime = req.ExpirationTime.AsTime()
889 | }
890 | n.timeline = append(n.timeline, &roundtrip{request: runRequest{ts: now, proto: req}})
891 | t.calls[id] = n
892 |
893 | // Upsert the parent and link its child, if applicable.
894 | if parentID != "" {
895 | parent, ok := t.calls[parentID]
896 | if !ok {
897 | parent = functionCall{}
898 | if parentID != rootID {
899 | panic("not implemented")
900 | }
901 | }
902 | if parent.children == nil {
903 | parent.children = map[DispatchID]struct{}{}
904 | }
905 | if _, ok := parent.children[id]; !ok {
906 | parent.children[id] = struct{}{}
907 | parent.orderedChildren = append(parent.orderedChildren, id)
908 | }
909 | t.calls[parentID] = parent
910 | }
911 | }
912 |
913 | func (t *TUI) ObserveResponse(now time.Time, req *sdkv1.RunRequest, err error, httpRes *http.Response, res *sdkv1.RunResponse) {
914 | // ObserveResponse is part of the FunctionCallObserver interface.
915 | // It's called after a response has been received from the local
916 | // application, and before the response has been sent to Dispatch.
917 |
918 | t.mu.Lock()
919 | defer t.mu.Unlock()
920 |
921 | id := DispatchID(req.DispatchId)
922 | n := t.calls[id]
923 |
924 | rt := n.timeline[len(n.timeline)-1]
925 | rt.response.ts = now
926 | rt.response.proto = res
927 | rt.response.err = err
928 | if res == nil && httpRes != nil {
929 | rt.response.httpStatus = httpRes.StatusCode
930 | }
931 |
932 | n.lastError = nil
933 | n.lastStatus = 0
934 | n.running = false
935 |
936 | if res != nil {
937 | switch res.Status {
938 | case sdkv1.Status_STATUS_OK:
939 | // noop
940 | case sdkv1.Status_STATUS_INCOMPATIBLE_STATE:
941 | n = functionCall{lastFunction: n.lastFunction} // reset
942 | default:
943 | n.failures++
944 | }
945 |
946 | switch d := res.Directive.(type) {
947 | case *sdkv1.RunResponse_Exit:
948 | n.lastStatus = res.Status
949 | n.done = terminalStatus(res.Status)
950 | if d.Exit.TailCall != nil {
951 | n = functionCall{lastFunction: d.Exit.TailCall.Function} // reset
952 | } else if res.Status != sdkv1.Status_STATUS_OK && d.Exit.Result != nil {
953 | if e := d.Exit.Result.Error; e != nil && e.Type != "" {
954 | if e.Message == "" {
955 | n.lastError = fmt.Errorf("%s", e.Type)
956 | } else {
957 | n.lastError = fmt.Errorf("%s: %s", e.Type, e.Message)
958 | }
959 | }
960 | }
961 | case *sdkv1.RunResponse_Poll:
962 | n.suspended = true
963 | n.polls++
964 | }
965 | } else if httpRes != nil {
966 | n.failures++
967 | n.lastError = fmt.Errorf("unexpected HTTP status code %d", httpRes.StatusCode)
968 | n.done = terminalHTTPStatusCode(httpRes.StatusCode)
969 | } else if err != nil {
970 | n.failures++
971 | n.lastError = err
972 | }
973 |
974 | if n.done && n.doneTime.IsZero() {
975 | n.doneTime = now
976 | }
977 |
978 | t.calls[id] = n
979 | }
980 |
981 | func (t *TUI) Write(b []byte) (int, error) {
982 | t.mu.Lock()
983 | defer t.mu.Unlock()
984 |
985 | return t.logs.Write(b)
986 | }
987 |
988 | func (t *TUI) Read(b []byte) (int, error) {
989 | t.mu.Lock()
990 | defer t.mu.Unlock()
991 |
992 | return t.logs.Read(b)
993 | }
994 |
995 | func (t *TUI) SetError(err error) {
996 | t.mu.Lock()
997 | defer t.mu.Unlock()
998 |
999 | t.err = err
1000 | }
1001 |
1002 | func statusString(status sdkv1.Status) string {
1003 | switch status {
1004 | case sdkv1.Status_STATUS_OK:
1005 | return "OK"
1006 | case sdkv1.Status_STATUS_TIMEOUT:
1007 | return "Timeout"
1008 | case sdkv1.Status_STATUS_THROTTLED:
1009 | return "Throttled"
1010 | case sdkv1.Status_STATUS_INVALID_ARGUMENT:
1011 | return "Invalid response"
1012 | case sdkv1.Status_STATUS_TEMPORARY_ERROR:
1013 | return "Temporary error"
1014 | case sdkv1.Status_STATUS_PERMANENT_ERROR:
1015 | return "Permanent error"
1016 | case sdkv1.Status_STATUS_INCOMPATIBLE_STATE:
1017 | return "Incompatible state"
1018 | case sdkv1.Status_STATUS_DNS_ERROR:
1019 | return "DNS error"
1020 | case sdkv1.Status_STATUS_TCP_ERROR:
1021 | return "TCP error"
1022 | case sdkv1.Status_STATUS_TLS_ERROR:
1023 | return "TLS error"
1024 | case sdkv1.Status_STATUS_HTTP_ERROR:
1025 | return "HTTP error"
1026 | case sdkv1.Status_STATUS_UNAUTHENTICATED:
1027 | return "Unauthenticated"
1028 | case sdkv1.Status_STATUS_PERMISSION_DENIED:
1029 | return "Permission denied"
1030 | case sdkv1.Status_STATUS_NOT_FOUND:
1031 | return "Not found"
1032 | default:
1033 | return status.String()
1034 | }
1035 | }
1036 |
1037 | func terminalStatus(status sdkv1.Status) bool {
1038 | switch status {
1039 | case sdkv1.Status_STATUS_TIMEOUT,
1040 | sdkv1.Status_STATUS_THROTTLED,
1041 | sdkv1.Status_STATUS_TEMPORARY_ERROR,
1042 | sdkv1.Status_STATUS_INCOMPATIBLE_STATE,
1043 | sdkv1.Status_STATUS_DNS_ERROR,
1044 | sdkv1.Status_STATUS_TCP_ERROR,
1045 | sdkv1.Status_STATUS_TLS_ERROR,
1046 | sdkv1.Status_STATUS_HTTP_ERROR:
1047 | return false
1048 | default:
1049 | return true
1050 | }
1051 | }
1052 |
1053 | func terminalHTTPStatusCode(code int) bool {
1054 | switch code / 100 {
1055 | case 4:
1056 | return code != http.StatusRequestTimeout && code != http.StatusTooManyRequests
1057 | case 5:
1058 | return code == http.StatusNotImplemented
1059 | default:
1060 | return true
1061 | }
1062 | }
1063 |
1064 | func typeName(typeUrl string) string {
1065 | i := strings.LastIndexByte(typeUrl, '/')
1066 | if i < 0 {
1067 | return typeUrl
1068 | }
1069 | return typeUrl[i+1:]
1070 | }
1071 |
--------------------------------------------------------------------------------
/cli/verification.go:
--------------------------------------------------------------------------------
1 | package cli
2 |
3 | import (
4 | "fmt"
5 | "net/http"
6 |
7 | tea "github.com/charmbracelet/bubbletea"
8 | "github.com/spf13/cobra"
9 | )
10 |
11 | func verificationCommand() *cobra.Command {
12 | cmd := &cobra.Command{
13 | Use: "verification",
14 | Short: "Manage verification keys",
15 | Long: `Manage Dispatch verification keys.
16 |
17 | Verification keys are used by your Dispatch applications to verify that
18 | function calls were sent by Dispatch.
19 |
20 | See the documentation for more information:
21 | https://docs.dispatch.run/dispatch/getting-started/production-deployment
22 | `,
23 | GroupID: "management",
24 | RunE: func(cmd *cobra.Command, args []string) error {
25 | return cmd.Help()
26 | },
27 | }
28 | cmd.AddCommand(&cobra.Command{
29 | Use: "rollout",
30 | Short: "Rollout a new verification key",
31 | SilenceUsage: true,
32 | PreRunE: func(cmd *cobra.Command, args []string) error {
33 | return runConfigFlow()
34 | },
35 | RunE: rolloutKey,
36 | })
37 | cmd.AddCommand(&cobra.Command{
38 | Use: "get",
39 | Short: "Get the active verification key",
40 | SilenceUsage: true,
41 | PreRunE: func(cmd *cobra.Command, args []string) error {
42 | return runConfigFlow()
43 | },
44 | RunE: getKey,
45 | })
46 | return cmd
47 | }
48 |
49 | type ListSigningKeys struct {
50 | Keys []Key `json:"keys"`
51 | }
52 |
53 | type SigningKey struct {
54 | Key Key `json:"key"`
55 | }
56 |
57 | type Key struct {
58 | SigningKeyID string `json:"signingKeyId"`
59 | AsymmetricKey struct {
60 | PublicKey string `json:"publicKey"`
61 | } `json:"asymmetricKey"`
62 | }
63 |
64 | // TODO: create better output for created signing key
65 | func rolloutKey(cmd *cobra.Command, args []string) error {
66 | // TODO: instantiate the api in main?
67 | api := &dispatchApi{client: http.DefaultClient, apiKey: DispatchApiKey}
68 |
69 | fn := func() (tea.Msg, error) {
70 | skey, err := api.CreateSigningKey()
71 | if err != nil {
72 | return "", fmt.Errorf("failed to create key: %w", err)
73 | }
74 | return fmt.Sprintf("New key:\n\n%s", skey.Key.AsymmetricKey.PublicKey), nil
75 | }
76 |
77 | p := tea.NewProgram(newSpinnerModel("Creating a new verification key", fn))
78 | _, err := p.Run()
79 | return err
80 | }
81 |
82 | // TODO: build table from keys
83 | func getKey(cmd *cobra.Command, args []string) error {
84 | // TODO: instantiate the api in main?
85 | api := &dispatchApi{client: http.DefaultClient, apiKey: DispatchApiKey}
86 |
87 | fn := func() (tea.Msg, error) {
88 | skeys, err := api.ListSigningKeys()
89 | if err != nil {
90 | return "", fmt.Errorf("failed to list keys: %w", err)
91 | }
92 | if len(skeys.Keys) == 0 {
93 | return "", fmt.Errorf("Key not found. Use `dispatch verification rollout` to create the first key.")
94 | }
95 | return skeys.Keys[0].AsymmetricKey.PublicKey, nil
96 | }
97 |
98 | p := tea.NewProgram(newSpinnerModel("Fetching active verification key", fn))
99 | _, err := p.Run()
100 | return err
101 | }
102 |
--------------------------------------------------------------------------------
/cli/version.go:
--------------------------------------------------------------------------------
1 | package cli
2 |
3 | import (
4 | "runtime/debug"
5 |
6 | "github.com/spf13/cobra"
7 | )
8 |
9 | func versionCommand() *cobra.Command {
10 | return &cobra.Command{
11 | Use: "version",
12 | Short: "Print the version",
13 | RunE: func(cmd *cobra.Command, args []string) error {
14 | // Match dispatch -v,--version output:
15 | cmd.Println("dispatch version " + version())
16 | return nil
17 | },
18 | }
19 | }
20 |
21 | func version() string {
22 | version := "devel"
23 | if info, ok := debug.ReadBuildInfo(); ok {
24 | switch info.Main.Version {
25 | case "":
26 | case "(devel)":
27 | default:
28 | version = info.Main.Version
29 | }
30 | for _, setting := range info.Settings {
31 | if setting.Key == "vcs.revision" {
32 | version += " " + setting.Value
33 | }
34 | }
35 | }
36 | return version
37 | }
38 |
--------------------------------------------------------------------------------
/cli/version_test.go:
--------------------------------------------------------------------------------
1 | package cli
2 |
3 | import (
4 | "bytes"
5 | "os/exec"
6 | "testing"
7 |
8 | "github.com/stretchr/testify/assert"
9 | )
10 |
11 | var versionText = "dispatch version devel"
12 |
13 | func TestVersionCommand(t *testing.T) {
14 | t.Run("Print the version (test runtime)", func(t *testing.T) {
15 | t.Parallel()
16 |
17 | cmd := versionCommand()
18 | stdout := &bytes.Buffer{}
19 | cmd.SetOut(stdout)
20 |
21 | if err := cmd.Execute(); err != nil {
22 | t.Fatalf("Received unexpected error: %v", err)
23 | }
24 |
25 | assert.Equal(t, versionText+"\n", stdout.String())
26 | })
27 |
28 | t.Run("Print the version (binary)", func(t *testing.T) {
29 | t.Parallel()
30 |
31 | cmd := exec.Command(dispatchBinary, "version")
32 | stderr := &bytes.Buffer{}
33 | cmd.Stderr = stderr
34 |
35 | if err := cmd.Run(); err != nil {
36 | t.Fatalf("Received unexpected error: %v", err)
37 | }
38 |
39 | // get git commit hash
40 | cmdGitHash := exec.Command("git", "rev-parse", "HEAD")
41 | stdout := &bytes.Buffer{}
42 | cmdGitHash.Stdout = stdout
43 |
44 | if err := cmdGitHash.Run(); err != nil {
45 | t.Fatalf("Received unexpected error: %v", err)
46 | }
47 |
48 | version := stdout.String()
49 |
50 | assert.Equal(t, versionText+" "+version, stderr.String())
51 | })
52 | }
53 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/dispatchrun/dispatch
2 |
3 | go 1.22.0
4 |
5 | require (
6 | buf.build/gen/go/stealthrocket/dispatch-proto/protocolbuffers/go v1.34.2-20240612225639-f8a6c0a10402.2
7 | github.com/charmbracelet/bubbles v0.18.0
8 | github.com/charmbracelet/bubbletea v0.25.0
9 | github.com/charmbracelet/lipgloss v0.9.1
10 | github.com/joho/godotenv v1.5.1
11 | github.com/muesli/reflow v0.3.0
12 | github.com/muesli/termenv v0.15.2
13 | github.com/nlpodyssey/gopickle v0.3.0
14 | github.com/pelletier/go-toml/v2 v2.2.0
15 | github.com/spf13/cobra v1.8.0
16 | github.com/stretchr/testify v1.9.0
17 | golang.org/x/term v0.19.0
18 | google.golang.org/protobuf v1.34.2
19 | )
20 |
21 | require (
22 | buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.34.2-20231115204500-e097f827e652.2 // indirect
23 | github.com/atotto/clipboard v0.1.4 // indirect
24 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
25 | github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect
26 | github.com/cpuguy83/go-md2man/v2 v2.0.3 // indirect
27 | github.com/davecgh/go-spew v1.1.1 // indirect
28 | github.com/inconshreveable/mousetrap v1.1.0 // indirect
29 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
30 | github.com/mattn/go-isatty v0.0.18 // indirect
31 | github.com/mattn/go-localereader v0.0.1 // indirect
32 | github.com/mattn/go-runewidth v0.0.15 // indirect
33 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect
34 | github.com/muesli/cancelreader v0.2.2 // indirect
35 | github.com/pmezard/go-difflib v1.0.0 // indirect
36 | github.com/rivo/uniseg v0.4.6 // indirect
37 | github.com/russross/blackfriday/v2 v2.1.0 // indirect
38 | github.com/spf13/pflag v1.0.5 // indirect
39 | golang.org/x/sync v0.1.0 // indirect
40 | golang.org/x/sys v0.19.0 // indirect
41 | golang.org/x/text v0.14.0 // indirect
42 | gopkg.in/yaml.v3 v3.0.1 // indirect
43 | )
44 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.34.2-20231115204500-e097f827e652.2 h1:ilkjxsfdhrdeLL582R4XH+IQLrV2Y31lHB8jb3AQSJA=
2 | buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.34.2-20231115204500-e097f827e652.2/go.mod h1:ylS4c28ACSI59oJrOdW4pHS4n0Hw4TgSPHn8rpHl4Yw=
3 | buf.build/gen/go/stealthrocket/dispatch-proto/protocolbuffers/go v1.34.2-20240612225639-f8a6c0a10402.2 h1:GfZ4I61ZZiXk6zXLZaMNz1rSW0MxlBRRc5n5GOUbIg4=
4 | buf.build/gen/go/stealthrocket/dispatch-proto/protocolbuffers/go v1.34.2-20240612225639-f8a6c0a10402.2/go.mod h1:Z1Y+G5dzXnHxfdt7l3S9kLBWV6iO2Zw3z2DvY9yKpaw=
5 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
6 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
7 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
8 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
9 | github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0=
10 | github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw=
11 | github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM=
12 | github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg=
13 | github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg=
14 | github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I=
15 | github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY=
16 | github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
17 | github.com/cpuguy83/go-md2man/v2 v2.0.3 h1:qMCsGGgs+MAzDFyp9LpAe1Lqy/fY/qCovCm0qnXZOBM=
18 | github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
19 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
20 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
21 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
22 | github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
23 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
24 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
25 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
26 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
27 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
28 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
29 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
30 | github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98=
31 | github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
32 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
33 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
34 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
35 | github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
36 | github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
37 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34=
38 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
39 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
40 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
41 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
42 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
43 | github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
44 | github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
45 | github.com/nlpodyssey/gopickle v0.3.0 h1:BLUE5gxFLyyNOPzlXxt6GoHEMMxD0qhsE4p0CIQyoLw=
46 | github.com/nlpodyssey/gopickle v0.3.0/go.mod h1:f070HJ/yR+eLi5WmM1OXJEGaTpuJEUiib19olXgYha0=
47 | github.com/pelletier/go-toml/v2 v2.2.0 h1:QLgLl2yMN7N+ruc31VynXs1vhMZa7CeHHejIeBAsoHo=
48 | github.com/pelletier/go-toml/v2 v2.2.0/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
49 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
50 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
51 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
52 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
53 | github.com/rivo/uniseg v0.4.6 h1:Sovz9sDSwbOz9tgUy8JpT+KgCkPYJEN/oYzlJiYTNLg=
54 | github.com/rivo/uniseg v0.4.6/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
55 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
56 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
57 | github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
58 | github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
59 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
60 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
61 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
62 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
63 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
64 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
65 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
66 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
67 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
68 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
69 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
70 | golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
71 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
72 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
73 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
74 | golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
75 | golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
76 | golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q=
77 | golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
78 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
79 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
80 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
81 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
82 | google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
83 | google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
84 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
85 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
86 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
87 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
88 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
89 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "os"
5 |
6 | "github.com/dispatchrun/dispatch/cli"
7 | )
8 |
9 | func main() {
10 | if err := cli.Main(); err != nil {
11 | // The error is logged by the CLI library.
12 | // No need to log here too.
13 | os.Exit(1)
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/proto/buf.lock:
--------------------------------------------------------------------------------
1 | # Generated by buf. DO NOT EDIT.
2 | version: v1
3 | deps:
4 | - remote: buf.build
5 | owner: bufbuild
6 | repository: protovalidate
7 | commit: e097f827e65240ac9fd4b1158849a8fc
8 | digest: shake256:f19252436fd9ded945631e2ffaaed28247a92c9015ccf55ae99db9fb3d9600c4fdb00fd2d3bd7701026ec2fd4715c5129e6ae517c25a59ba690020cfe80bf8ad
9 | - remote: buf.build
10 | owner: stealthrocket
11 | repository: dispatch-proto
12 | commit: 639d52c5db754187a9461d96d783c093
13 | digest: shake256:0fc989737c9db14c41feab7f2dc847b9cf7949cdb1852e3a5337dbb99d909a38acd9211b60501274cac55924ea18c2b18889a7087183ab5d850e79e3b4c3eaed
14 |
--------------------------------------------------------------------------------
/proto/buf.yaml:
--------------------------------------------------------------------------------
1 | version: v1
2 | deps:
3 | - buf.build/stealthrocket/dispatch-proto
4 | breaking:
5 | use:
6 | - FILE
7 | lint:
8 | use:
9 | - DEFAULT
10 |
--------------------------------------------------------------------------------