├── .github └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .gon-amd64.hcl ├── .gon-arm64.hcl ├── .goreleaser.yaml ├── .vscode ├── launch.json └── settings.json ├── LICENSE ├── Makefile ├── README.md ├── cmd ├── api.go ├── builder.go ├── dev.go ├── dev_proxy.go ├── dev_serve.go ├── dev_trigger.go ├── dev_up.go ├── exec.go ├── link.go ├── login.go ├── new.go ├── open.go ├── print-access-token.go ├── push.go ├── release.go ├── root.go ├── trigger.go ├── utils │ ├── checks.go │ ├── keys.go │ ├── messages.go │ └── shared.go ├── validate.go ├── version.go ├── version_ugrade.go ├── version_upgrade_exec.go └── version_upgrade_exec_windows.go ├── go.mod ├── go.sum ├── internal ├── .DS_Store ├── api │ ├── api.go │ ├── client.go │ └── github.go ├── auth │ ├── auth.go │ └── auth_test.go ├── discovery │ ├── discovery.go │ ├── screenshots.go │ ├── screenshots_test.go │ └── testdata │ │ └── images │ │ ├── 1.png │ │ ├── 2.jpeg │ │ ├── 3.png │ │ ├── 4.jpeg │ │ ├── 5.png │ │ ├── 6.webp │ │ ├── dir1 │ │ └── not_an_image │ │ ├── dir2 │ │ ├── 1.png │ │ └── 2.jpeg │ │ └── dir3 │ │ ├── 3.png │ │ └── not_an_image ├── proxy │ └── proxy.go ├── runtime │ ├── .spaceignore │ ├── ignore_test.go │ ├── manager.go │ ├── meta.go │ └── zip.go └── spacefile │ ├── .DS_Store │ ├── icon.go │ ├── icon_test.go │ ├── schemas │ └── spacefile.schema.json │ ├── spacefile.go │ ├── spacefile_test.go │ └── testdata │ ├── icons │ └── size-128.png │ └── spacefile │ ├── duplicated_micros │ └── Spacefile │ ├── implicit_primary │ └── Spacefile │ ├── multiple_micros │ └── Spacefile │ ├── multiple_primary │ └── Spacefile │ ├── no_primary │ └── Spacefile │ └── single_micro │ └── Spacefile ├── main.go ├── pkg ├── components │ ├── choose │ │ └── choose.go │ ├── components.go │ ├── confirm │ │ └── confirm.go │ ├── emoji │ │ ├── codes.go │ │ └── emoji.go │ ├── styles │ │ └── styles.go │ └── text │ │ └── text.go ├── scanner │ ├── frameworks.go │ ├── runtimes.go │ ├── scan.go │ ├── scan_test.go │ ├── testdata │ │ ├── empty │ │ │ └── readme │ │ └── micros │ │ │ ├── go │ │ │ └── go.mod │ │ │ ├── invalid │ │ │ └── invalid │ │ │ ├── next │ │ │ └── package.json │ │ │ ├── node │ │ │ ├── package-lock.json │ │ │ └── package.json │ │ │ ├── nuxt │ │ │ └── package.json │ │ │ ├── python │ │ │ └── requirements.txt │ │ │ ├── react │ │ │ └── package.json │ │ │ ├── static │ │ │ ├── index.html │ │ │ ├── index.js │ │ │ └── styles.css │ │ │ ├── svelte-kit │ │ │ └── package.json │ │ │ ├── svelte │ │ │ └── package.json │ │ │ └── vue │ │ │ └── package.json │ └── types.go ├── util │ └── fs │ │ └── fs.go └── writer │ └── prefixer.go ├── scripts ├── generate-docs.go ├── install-unix.sh └── install-windows.ps1 └── shared └── types.go /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | push: 5 | # run only against tags 6 | tags: 7 | - "*" 8 | 9 | permissions: 10 | contents: write 11 | # packages: write 12 | # issues: write 13 | 14 | jobs: 15 | goreleaser: 16 | runs-on: macos-latest 17 | steps: 18 | - uses: actions/checkout@v3 19 | with: 20 | fetch-depth: 0 21 | - run: git fetch --force --tags 22 | - uses: actions/setup-go@v3 23 | with: 24 | go-version: ">=1.20.2" 25 | cache: true 26 | - name: Install Gon 27 | run: brew install mitchellh/gon/gon 28 | - name: Setup Apple Certificates 29 | uses: apple-actions/import-codesign-certs@v2 30 | with: 31 | p12-file-base64: ${{ secrets.APPLE_APP_SIGN_CERTIFICATES_P12 }} 32 | p12-password: ${{ secrets.APPLE_APP_SIGN_CERTIFICATES_P12_PASSWORD }} 33 | - uses: goreleaser/goreleaser-action@v4 34 | with: 35 | distribution: goreleaser 36 | version: latest 37 | args: release --clean 38 | env: 39 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 40 | APPLE_APP_SIGN_USERNAME: ${{ secrets.APPLE_APP_SIGN_USERNAME }} 41 | APPLE_APP_SIGN_PASSWORD: ${{ secrets.APPLE_APP_SIGN_PASSWORD }} -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Go package 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | - name: Set up Go 11 | uses: actions/setup-go@v4 12 | with: 13 | go-version: ">=1.20.2" 14 | cache: true 15 | - name: Build 16 | run: go build -v ./... 17 | - name: Test 18 | run: go test -v ./... 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | space 3 | out 4 | 5 | # goreleaser 6 | dist 7 | starters 8 | 9 | .env 10 | .env.* 11 | 12 | tags 13 | -------------------------------------------------------------------------------- /.gon-amd64.hcl: -------------------------------------------------------------------------------- 1 | source = ["dist/space-macos_darwin_amd64_v1/space"] 2 | bundle_id = "sh.deta.cli" 3 | 4 | apple_id { 5 | username = "@env:APPLE_APP_SIGN_USERNAME" 6 | password = "@env:APPLE_APP_SIGN_PASSWORD" 7 | } 8 | 9 | sign { 10 | application_identity = "7033D02EC11F23C6C666B6D26DAC7CA9D439FF7F" 11 | } -------------------------------------------------------------------------------- /.gon-arm64.hcl: -------------------------------------------------------------------------------- 1 | source = ["dist/space-macos-arm_darwin_arm64/space"] 2 | bundle_id = "sh.deta.cli" 3 | 4 | apple_id { 5 | username = "@env:APPLE_APP_SIGN_USERNAME" 6 | password = "@env:APPLE_APP_SIGN_PASSWORD" 7 | } 8 | 9 | sign { 10 | application_identity = "7033D02EC11F23C6C666B6D26DAC7CA9D439FF7F" 11 | } -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # This is an example .goreleaser.yml file with some sensible defaults. 2 | # Make sure to check the documentation at https://goreleaser.com 3 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 4 | before: 5 | hooks: 6 | # You may remove this if you don't use go modules. 7 | - go mod tidy 8 | # you may remove this if you don't need go generate 9 | - go generate ./... 10 | builds: 11 | - binary: space 12 | env: 13 | - CGO_ENABLED=0 14 | ldflags: 15 | - -X github.com/deta/space/cmd/utils.SpaceVersion={{ .Version }} 16 | - -X github.com/deta/space/cmd/utils.Platform={{ .Env.GOARCH }}-{{ .Env.GOOS }} 17 | goos: 18 | - linux 19 | - windows 20 | goarch: 21 | - amd64 22 | - arm64 23 | - binary: space 24 | id: space-macos 25 | env: 26 | - CGO_ENABLED=0 27 | ldflags: 28 | - -X github.com/deta/space/cmd/utils.SpaceVersion={{ .Version }} 29 | - -X github.com/deta/space/cmd/utils.Platform={{ .Env.GOARCH }}-{{ .Env.GOOS }} 30 | goos: 31 | - darwin 32 | goarch: 33 | - amd64 34 | hooks: 35 | post: 36 | - gon -log-level=info -log-json .gon-amd64.hcl 37 | - binary: space 38 | id: space-macos-arm 39 | env: 40 | - CGO_ENABLED=0 41 | ldflags: 42 | - -X github.com/deta/space/cmd/utils.SpaceVersion={{ .Version }} 43 | - -X github.com/deta/space/cmd/utils.Platform={{ .Env.GOARCH }}-{{ .Env.GOOS }} 44 | goos: 45 | - darwin 46 | goarch: 47 | - arm64 48 | hooks: 49 | post: 50 | - gon -log-level=info -log-json .gon-arm64.hcl 51 | 52 | archives: 53 | - format: zip 54 | # this name template makes the OS and Arch compatible with the results of uname. 55 | name_template: >- 56 | space-{{ .Os }}- 57 | {{- if eq .Arch "amd64" }}x86_64 58 | {{- else if eq .Arch "386" }}i386 59 | {{- else }}{{ .Arch }}{{ end }} 60 | {{- if .Arm }}v{{ .Arm }}{{ end }} 61 | 62 | checksum: 63 | name_template: "checksums.txt" 64 | snapshot: 65 | name_template: "{{ incpatch .Version }}-next" 66 | release: 67 | # will mark pre-release tags as pre-releases on GitHub 68 | prerelease: auto 69 | changelog: 70 | sort: asc 71 | filters: 72 | exclude: 73 | - "^docs:" 74 | - "^test:" 75 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "build space docs", 9 | "type": "go", 10 | "request": "launch", 11 | "mode": "auto", 12 | "program": "${workspaceFolder}/scripts/generate-docs.go" 13 | }, 14 | { 15 | "name": "space new", 16 | "type": "go", 17 | "request": "launch", 18 | "mode": "auto", 19 | "program": "${workspaceFolder}", 20 | "args": [ 21 | "new" 22 | ] 23 | }, 24 | { 25 | "name": "space login", 26 | "type": "go", 27 | "request": "launch", 28 | "console": "integratedTerminal", 29 | "mode": "auto", 30 | "program": "${workspaceFolder}", 31 | "args": [ 32 | "login" 33 | ] 34 | }, 35 | { 36 | "name": "space link", 37 | "type": "go", 38 | "request": "launch", 39 | "mode": "auto", 40 | "program": "${workspaceFolder}", 41 | "args": [ 42 | "link", 43 | "--id=a0wrQNbiw9h2" 44 | ] 45 | }, 46 | { 47 | "name": "space validate", 48 | "type": "go", 49 | "request": "launch", 50 | "mode": "auto", 51 | "program": "${workspaceFolder}", 52 | "args": [ 53 | "validate", 54 | "-d=${input:appFolder}" 55 | ] 56 | }, 57 | { 58 | "name": "space push", 59 | "type": "go", 60 | "request": "launch", 61 | "mode": "auto", 62 | "program": "${workspaceFolder}", 63 | "args": [ 64 | "push", 65 | "-d=${input:appFolder}" 66 | ] 67 | }, 68 | { 69 | "name": "space release", 70 | "type": "go", 71 | "request": "launch", 72 | "mode": "auto", 73 | "program": "${workspaceFolder}", 74 | "console": "integratedTerminal", 75 | "args": [ 76 | "release", 77 | "-d=${env:HOME}/dev/tests/svelte-kit-local2" 78 | ] 79 | }, 80 | { 81 | "name": "space dev", 82 | "type": "go", 83 | "request": "launch", 84 | "mode": "auto", 85 | "program": "${workspaceFolder}", 86 | "args": [ 87 | "dev", 88 | "--dir=${workspaceFolder}/${input:appFolder}" 89 | ] 90 | }, 91 | { 92 | "name": "space dev trigger", 93 | "type": "go", 94 | "request": "launch", 95 | "mode": "auto", 96 | "program": "${workspaceFolder}", 97 | "args": [ 98 | "dev", 99 | "--dir=${workspaceFolder}/example", 100 | "trigger", 101 | "action" 102 | ] 103 | }, 104 | { 105 | "name": "space dev up", 106 | "type": "go", 107 | "request": "launch", 108 | "mode": "auto", 109 | "program": "${workspaceFolder}", 110 | "args": [ 111 | "dev", 112 | "up", 113 | "--dir=${workspaceFolder}/starters", 114 | "python-app" 115 | ] 116 | }, 117 | { 118 | "name": "space dev run", 119 | "type": "go", 120 | "request": "launch", 121 | "mode": "auto", 122 | "program": "${workspaceFolder}", 123 | "args": [ 124 | "dev", 125 | "run", 126 | "--dir=${workspaceFolder}/example", 127 | "ls" 128 | ] 129 | }, 130 | { 131 | "name": "space dev proxy", 132 | "type": "go", 133 | "request": "launch", 134 | "mode": "auto", 135 | "program": "${workspaceFolder}", 136 | "args": [ 137 | "dev", 138 | "proxy", 139 | "--dir=${workspaceFolder}/example" 140 | ] 141 | }, 142 | { 143 | "name": "space api", 144 | "type": "go", 145 | "request": "launch", 146 | "console": "integratedTerminal", 147 | "mode": "auto", 148 | "envFile": "${workspaceFolder}/.env.staging", 149 | "program": "${workspaceFolder}", 150 | "args": [ 151 | "api", 152 | "/v0/apps" 153 | ] 154 | }, 155 | { 156 | "name": "space complete", 157 | "type": "go", 158 | "request": "launch", 159 | "console": "integratedTerminal", 160 | "mode": "auto", 161 | "envFile": "${workspaceFolder}/.env.staging", 162 | "program": "${workspaceFolder}", 163 | "args": [ 164 | "__complete", 165 | "tty", 166 | "d" 167 | ] 168 | }, 169 | { 170 | "name": "space tty", 171 | "type": "go", 172 | "request": "launch", 173 | "console": "integratedTerminal", 174 | "mode": "auto", 175 | "envFile": "${workspaceFolder}/.env.staging", 176 | "program": "${workspaceFolder}", 177 | "args": [ 178 | "tty", 179 | "ttydemoapp-1", 180 | "greet", 181 | "--input", 182 | "name=achille" 183 | ] 184 | } 185 | ], 186 | "inputs": [ 187 | { 188 | "id": "appFolder", 189 | "type": "pickString", 190 | "description": "Select the app folder", 191 | "options": [ 192 | "starters", 193 | "starters/deno-app", 194 | "starters/go-app", 195 | "starters/next-app", 196 | "starters/node-app", 197 | "starters/nuxt-app", 198 | "starters/python-app", 199 | "starters/sveltekit-app" 200 | ] 201 | } 202 | ] 203 | } 204 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.associations": { 3 | "Spacefile": "yaml" 4 | }, 5 | "yaml.schemas": { 6 | "internal/spacefile/schemas/spacefile.json": [ 7 | "Spacefile" 8 | ] 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Deta 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SPACE_VERSION = DEV 2 | LINUX_PLATFORM = x86_64-linux 3 | LINUX_ARM_PLATFORM = arm64-linux 4 | MAC_PLATFORM = x86_64-darwin 5 | MAC_ARM_PLATFORM = arm64-darwin 6 | WINDOWS_PLATFORM = x86_64-windows 7 | 8 | LDFLAGS := -X github.com/deta/space/cmd/shared.SpaceVersion=$(SPACE_VERSION) $(LDFLAGS) 9 | 10 | .PHONY: build clean 11 | 12 | build-linux: 13 | GOOS=linux GOARCH=amd64 go build -ldflags="$(LDFLAGS) -X github.com/deta/space/cmd/shared.Platform=$(LINUX_PLATFORM)" -o build/space 14 | cd build && zip -FSr space-$(LINUX_PLATFORM).zip space 15 | 16 | build-linux-arm: 17 | GOOS=linux GOARCH=arm64 go build -ldflags="$(LDFLAGS) -X github.com/deta/space/cmd/shared.Platform=$(LINUX_ARM_PLATFORM)" -o build/space 18 | cd build && zip -FSr space-$(LINUX_ARM_PLATFORM).zip space 19 | 20 | build-win: 21 | GOOS=windows GOARCH=amd64 go build -ldflags="$(LDFLAGS) -X github.com/deta/space/cmd/shared.Platform=$(WINDOWS_PLATFORM)" -o build/space.exe 22 | cd build && zip -FSr space-$(WINDOWS_PLATFORM).zip space.exe 23 | 24 | build-mac: 25 | GOOS=darwin GOARCH=amd64 go build -ldflags="$(LDFLAGS) -X github.com/deta/space/cmd/shared.Platform=$(MAC_PLATFORM)" -o build/space 26 | cd build && zip -FSr space-$(MAC_PLATFORM).zip space 27 | 28 | build-mac-arm: 29 | CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 go build -ldflags="$(LDFLAGS) -X github.com/deta/space/cmd/shared.Platform=$(MAC_ARM_PLATFORM)" -o build/space 30 | cd build && zip -FSr space-$(MAC_ARM_PLATFORM).zip space 31 | 32 | build: build-linux build-win build-mac build-mac-arm build-linux-arm 33 | 34 | notarize-mac: build-mac 35 | gon ./.x86_64.hcl 36 | 37 | notarize-mac-arm: build-mac-arm 38 | gon ./.arm64.hcl 39 | 40 | clean: 41 | rm -rf out build/space build/space.exe build/*.zip 42 | 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Space CLI 2 | 3 | ## Running the CLI 4 | 5 | ```bash 6 | # Run the CLI 7 | go run main.go [command] 8 | 9 | # Build the space binary, then run it 10 | go build && ./space [command] 11 | 12 | # Install the space binary to your $GOPATH/bin 13 | go install 14 | ``` 15 | 16 | If you want to test the CLI against a variety of projects, you can use the deta/starters repo: 17 | 18 | ```bash 19 | git clone https://github.com/deta/starters 20 | go run main.go -d ./starters/python-app [command] 21 | ``` 22 | 23 | ## Customizing the api endpoint 24 | 25 | You can customize the root endpoint by setting the `SPACE_ROOT` environment variable: 26 | 27 | ```bash 28 | SPACE_ROOT= space push 29 | ``` 30 | 31 | You can also set the `SPACE_ROOT` environment variable in a `.env` file in the root of your project, and load it with a tool like [direnv](https://direnv.net/). 32 | 33 | Other configuration options can be set in the .env file as well: 34 | 35 | - SPACE_ACCESS_TOKEN 36 | 37 | ## Running unit tests 38 | 39 | ```bash 40 | go test ./... 41 | ``` 42 | -------------------------------------------------------------------------------- /cmd/api.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "log" 9 | "os" 10 | "strings" 11 | 12 | "github.com/deta/space/cmd/utils" 13 | "github.com/itchyny/gojq" 14 | "github.com/mattn/go-isatty" 15 | "github.com/spf13/cobra" 16 | ) 17 | 18 | func newCmdAPI() *cobra.Command { 19 | cmd := &cobra.Command{ 20 | Use: "api", 21 | Args: cobra.ExactArgs(1), 22 | Hidden: true, 23 | Short: "Makes an authenticated HTTP request to the Space API and prints the response.", 24 | RunE: func(cmd *cobra.Command, args []string) error { 25 | var body []byte 26 | if !isatty.IsTerminal(os.Stdin.Fd()) { 27 | b, err := io.ReadAll(os.Stdin) 28 | if err != nil { 29 | return err 30 | } 31 | body = b 32 | } 33 | 34 | var method string 35 | if cmd.Flags().Changed("method") { 36 | method, _ = cmd.Flags().GetString("method") 37 | if strings.ToUpper(method) == "GET" && body != nil { 38 | return errors.New("cannot send body with GET request") 39 | } 40 | } else if body != nil { 41 | method = "POST" 42 | } else { 43 | method = "GET" 44 | } 45 | 46 | path := args[0] 47 | var res []byte 48 | switch strings.ToUpper(method) { 49 | case "GET": 50 | r, err := utils.Client.Get(path) 51 | if err != nil { 52 | return err 53 | } 54 | 55 | res = r 56 | case "POST": 57 | r, err := utils.Client.Post(path, body) 58 | if err != nil { 59 | return err 60 | } 61 | 62 | res = r 63 | case "DELETE": 64 | r, err := utils.Client.Delete(path, body) 65 | if err != nil { 66 | return err 67 | } 68 | 69 | res = r 70 | case "PATCH": 71 | r, err := utils.Client.Patch(path, body) 72 | if err != nil { 73 | return err 74 | } 75 | 76 | res = r 77 | default: 78 | return errors.New("invalid method") 79 | } 80 | 81 | if !cmd.Flags().Changed("jq") { 82 | os.Stdout.Write(res) 83 | return nil 84 | } 85 | 86 | jq, _ := cmd.Flags().GetString("jq") 87 | query, err := gojq.Parse(jq) 88 | if err != nil { 89 | return fmt.Errorf("invalid jq query: %s", err) 90 | } 91 | 92 | var v any 93 | if err := json.Unmarshal(res, &v); err != nil { 94 | return err 95 | } 96 | 97 | encoder := json.NewEncoder(os.Stdout) 98 | if isatty.IsTerminal(os.Stdout.Fd()) { 99 | encoder.SetIndent("", " ") 100 | } 101 | 102 | iter := query.Run(v) 103 | for { 104 | v, ok := iter.Next() 105 | if !ok { 106 | break 107 | } 108 | if err, ok := v.(error); ok { 109 | log.Fatalln(err) 110 | } 111 | if err := encoder.Encode(v); err != nil { 112 | return err 113 | } 114 | } 115 | 116 | return nil 117 | }, 118 | } 119 | 120 | cmd.Flags().StringP("method", "X", "", "HTTP method") 121 | cmd.Flags().String("jq", "", "jq filter") 122 | return cmd 123 | } 124 | -------------------------------------------------------------------------------- /cmd/builder.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/deta/space/cmd/utils" 8 | "github.com/deta/space/internal/api" 9 | "github.com/deta/space/internal/runtime" 10 | "github.com/deta/space/pkg/components/emoji" 11 | "github.com/joho/godotenv" 12 | "github.com/spf13/cobra" 13 | ) 14 | 15 | func newCmdBuilder() *cobra.Command { 16 | cmd := &cobra.Command{ 17 | Use: "builder", 18 | Short: "Interact with the builder", 19 | PostRunE: utils.CheckLatestVersion, 20 | Run: func(cmd *cobra.Command, args []string) { 21 | cmd.Usage() 22 | }, 23 | } 24 | 25 | cmd.AddCommand(newCmdEnv()) 26 | return cmd 27 | } 28 | 29 | func newCmdEnv() *cobra.Command { 30 | cmd := &cobra.Command{ 31 | Use: "env", 32 | Short: "Interact with the env variables in the dev instance of your project", 33 | PostRunE: utils.CheckLatestVersion, 34 | RunE: func(cmd *cobra.Command, args []string) error { 35 | projectDir, _ := cmd.Flags().GetString("dir") 36 | projectID, _ := cmd.Flags().GetString("id") 37 | if !cmd.Flags().Changed("id") { 38 | var err error 39 | projectID, err = runtime.GetProjectID(projectDir) 40 | if err != nil { 41 | return fmt.Errorf("failed to get project id: %w", err) 42 | } 43 | } 44 | 45 | set, _ := cmd.Flags().GetString("set") 46 | get, _ := cmd.Flags().GetString("get") 47 | micro, _ := cmd.Flags().GetString("micro") 48 | if cmd.Flags().Changed("set") && cmd.Flags().Changed("get") { 49 | return fmt.Errorf("Both `set` and `get` are used at the same time") 50 | } 51 | 52 | if cmd.Flags().Changed("get") { 53 | return cmdEnvGetFn(micro, get, projectID) 54 | } else if cmd.Flags().Changed("set") { 55 | return cmdEnvSetFn(micro, set, projectID) 56 | } else { 57 | return cmd.Usage() 58 | } 59 | }, 60 | } 61 | 62 | cmd.Flags().StringP("get", "g", "", "file name to write the env variables") 63 | cmd.Flags().StringP("set", "s", ".env", "file name to read env variables from") 64 | cmd.Flags().StringP("micro", "m", "", "micro name to operate on") 65 | cmd.Flags().StringP("id", "i", "", "`project_id` of project") 66 | cmd.Flags().StringP("dir", "d", "./", "src of project") 67 | 68 | cmd.MarkFlagDirname("dir") 69 | 70 | return cmd 71 | } 72 | 73 | func cmdEnvGetFn(microName string, file string, projectID string) error { 74 | devInstance, err := utils.Client.GetDevAppInstance(projectID) 75 | if err != nil { 76 | return err 77 | } 78 | 79 | microPtr, err := cmdEnvGetMicro(microName, devInstance) 80 | if err != nil { 81 | return err 82 | } 83 | 84 | envMap := make(map[string]string) 85 | for _, env := range microPtr.Presets.Environment { 86 | envMap[env.Name] = env.Value 87 | } 88 | 89 | err = godotenv.Write(envMap, file) 90 | if err != nil { 91 | return fmt.Errorf("failed to write to `%s` env file: %w", file, err) 92 | } 93 | 94 | utils.Logger.Printf("%s Wrote %d environment variables from the micro `%s` to the file `%s`", 95 | emoji.Check.Emoji, len(envMap), microPtr.Name, file) 96 | 97 | return nil 98 | } 99 | 100 | func cmdEnvSetFn(microName string, file string, projectID string) error { 101 | devInstance, err := utils.Client.GetDevAppInstance(projectID) 102 | if err != nil { 103 | return err 104 | } 105 | 106 | microPtr, err := cmdEnvGetMicro(microName, devInstance) 107 | if err != nil { 108 | return err 109 | } 110 | 111 | data, err := os.ReadFile(file) 112 | if err != nil { 113 | return fmt.Errorf("failed to read `%s` env file: %w", file, err) 114 | } 115 | 116 | envMap, err := godotenv.UnmarshalBytes(data) 117 | if err != nil { 118 | return fmt.Errorf("failed to parse `%s` env file: %w", file, err) 119 | } 120 | 121 | // update the values in-place 122 | counter := 0 123 | for _, env := range microPtr.Presets.Environment { 124 | if value, ok := envMap[env.Name]; ok && value != env.Value { 125 | env.Value = value 126 | counter++ 127 | } 128 | } 129 | 130 | // no need to update any env variable 131 | if counter == 0 { 132 | utils.Logger.Printf("%s Found 0 environment variables that needs to be updated on the `%s` micro ", 133 | emoji.Check.Emoji, microPtr.Name) 134 | 135 | return nil 136 | } 137 | 138 | err = utils.Client.PatchDevAppInstancePresets(devInstance.ID, microPtr) 139 | if err != nil { 140 | return fmt.Errorf("Failed to patch the dev instance env presets: %s", err) 141 | } 142 | 143 | utils.Logger.Printf("%s Updated %d environment variables on the `%s` micro ", 144 | emoji.Check.Emoji, counter, microPtr.Name) 145 | 146 | return nil 147 | } 148 | 149 | func cmdEnvGetMicro(microName string, devInstance *api.AppInstance) (*api.AppInstanceMicro, error) { 150 | if len(devInstance.Micros) == 1 && (microName == "" || devInstance.Micros[0].Name == microName) { 151 | return devInstance.Micros[0], nil 152 | } 153 | 154 | var microPtr *api.AppInstanceMicro 155 | for _, micro := range devInstance.Micros { 156 | if micro.Name == microName { 157 | microPtr = micro 158 | break 159 | } 160 | } 161 | 162 | if microPtr != nil { 163 | return microPtr, nil 164 | } 165 | 166 | if microName == "" { 167 | return nil, fmt.Errorf("please provide a valid micro name with the `--micro` flag") 168 | } else { 169 | return nil, fmt.Errorf("micro '%s' not found in this project", microName) 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /cmd/dev.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "net/http" 8 | "os" 9 | "os/exec" 10 | "os/signal" 11 | "path/filepath" 12 | "strconv" 13 | "sync" 14 | "syscall" 15 | "time" 16 | 17 | "github.com/alessio/shellescape" 18 | "github.com/deta/space/cmd/utils" 19 | "github.com/deta/space/internal/proxy" 20 | "github.com/deta/space/internal/runtime" 21 | "github.com/deta/space/internal/spacefile" 22 | "github.com/deta/space/pkg/components/emoji" 23 | "github.com/deta/space/pkg/components/styles" 24 | "github.com/deta/space/pkg/writer" 25 | types "github.com/deta/space/shared" 26 | "github.com/pkg/browser" 27 | "github.com/spf13/cobra" 28 | "mvdan.cc/sh/v3/shell" 29 | ) 30 | 31 | const ( 32 | actionEndpoint = "__space/v0/actions" 33 | spaceDevDocsURL = "https://deta.space/docs/en/build/fundamentals/development/local-development" 34 | ) 35 | 36 | var ( 37 | EngineToDevCommand = map[string]string{ 38 | types.React: "npm run start -- --port $PORT", 39 | types.Vue: "npm run dev -- --port $PORT", 40 | types.Svelte: "npm run dev -- --port $PORT", 41 | types.Next: "npm run dev -- --port $PORT", 42 | types.Nuxt: "npm run dev -- --port $PORT", 43 | types.SvelteKit: "npm run dev -- --port $PORT", 44 | } 45 | errNoDevCommand = errors.New("no dev command found for micro") 46 | ) 47 | 48 | func NewCmdDev() *cobra.Command { 49 | cmd := &cobra.Command{ 50 | Use: "dev", 51 | Short: "Spin up a local development environment for your Space project", 52 | Long: `Spin up a local development environment for your Space project. 53 | 54 | The cli will start one process for each of your micros, then expose a single enpoint for your Space app.`, 55 | 56 | PreRunE: utils.CheckAll(utils.CheckProjectInitialized("dir"), utils.CheckNotEmpty("id")), 57 | PostRunE: utils.CheckLatestVersion, 58 | RunE: func(cmd *cobra.Command, args []string) error { 59 | var err error 60 | 61 | projectDir, _ := cmd.Flags().GetString("dir") 62 | projectID, _ := cmd.Flags().GetString("id") 63 | host, _ := cmd.Flags().GetString("host") 64 | port, _ := cmd.Flags().GetInt("port") 65 | open, _ := cmd.Flags().GetBool("open") 66 | 67 | if !cmd.Flags().Changed("id") { 68 | projectID, err = runtime.GetProjectID(projectDir) 69 | if err != nil { 70 | return fmt.Errorf("failed to get proejct id: %w", err) 71 | } 72 | } 73 | 74 | if !cmd.Flags().Changed("port") { 75 | port, err = GetFreePort(utils.DevPort) 76 | if err != nil { 77 | return err 78 | } 79 | } 80 | 81 | if err := dev(projectDir, projectID, host, port, open); err != nil { 82 | return err 83 | } 84 | 85 | return nil 86 | }, 87 | } 88 | 89 | cmd.AddCommand(newCmdDevUp()) 90 | cmd.AddCommand(newCmdDevProxy()) 91 | cmd.AddCommand(newCmdDevTrigger()) 92 | cmd.AddCommand(newCmdServe()) 93 | 94 | cmd.Flags().StringP("dir", "d", ".", "directory of the project") 95 | cmd.Flags().StringP("id", "i", "", "project id") 96 | cmd.Flags().IntP("port", "p", 0, "port to run the proxy on") 97 | cmd.Flags().StringP("host", "H", "localhost", "host to run the proxy on") 98 | cmd.Flags().Bool("open", false, "open the app in the browser") 99 | 100 | return cmd 101 | } 102 | 103 | func GetFreePort(start int) (int, error) { 104 | if start < 0 || start > 65535 { 105 | return 0, errors.New("invalid port range") 106 | } 107 | 108 | for portNumber := start; portNumber < start+100; portNumber++ { 109 | if utils.IsPortActive(portNumber) { 110 | continue 111 | } 112 | 113 | return portNumber, nil 114 | } 115 | 116 | return 0, errors.New("no free port found") 117 | } 118 | 119 | func dev(projectDir string, projectID string, host string, port int, open bool) error { 120 | meta, err := runtime.GetProjectMeta(projectDir) 121 | if err != nil { 122 | return err 123 | } 124 | 125 | routeDir := filepath.Join(projectDir, ".space", "micros") 126 | spacefile, err := spacefile.LoadSpacefile(projectDir) 127 | if err != nil { 128 | return fmt.Errorf("failed to parse Spacefile: %w", err) 129 | } 130 | 131 | projectKey, err := utils.GenerateDataKeyIfNotExists(projectID) 132 | if err != nil { 133 | return fmt.Errorf("failed to generate project key: %w", err) 134 | } 135 | addr := fmt.Sprintf("%s:%d", host, port) 136 | 137 | utils.Logger.Printf("\n%s Checking for running micros...", emoji.Eyes) 138 | var stoppedMicros []*types.Micro 139 | for _, micro := range spacefile.Micros { 140 | _, err := getMicroPort(micro, routeDir) 141 | if err != nil { 142 | stoppedMicros = append(stoppedMicros, micro) 143 | continue 144 | } 145 | 146 | utils.Logger.Printf("\nMicro %s found", styles.Green(micro.Name)) 147 | utils.Logger.Printf("L url: %s", styles.Blue(fmt.Sprintf("http://%s%s", addr, micro.Path))) 148 | } 149 | 150 | startPort := port + 1 151 | 152 | wg := sync.WaitGroup{} 153 | ctx, cancelFunc := context.WithCancel(context.Background()) 154 | defer cancelFunc() 155 | 156 | utils.Logger.Printf("\n%s Starting %d micro servers...\n\n", emoji.Laptop, len(stoppedMicros)) 157 | for _, micro := range stoppedMicros { 158 | freePort, err := GetFreePort(startPort) 159 | if err != nil { 160 | return err 161 | } 162 | 163 | command, err := MicroCommand(micro, projectDir, projectKey, freePort, ctx) 164 | if err != nil { 165 | if errors.Is(err, errNoDevCommand) { 166 | utils.Logger.Printf("%s micro %s has no dev command\n", emoji.X, micro.Name) 167 | utils.Logger.Printf("See %s to get started\n", styles.Blue(spaceDevDocsURL)) 168 | continue 169 | } 170 | } 171 | 172 | portFile := filepath.Join(routeDir, fmt.Sprintf("%s.port", micro.Name)) 173 | if err := writePortFile(portFile, freePort); err != nil { 174 | return err 175 | } 176 | defer os.Remove(portFile) 177 | 178 | startPort = freePort + 1 179 | 180 | if micro.Primary { 181 | utils.Logger.Printf("Micro %s (primary)", styles.Green(micro.Name)) 182 | } else { 183 | utils.Logger.Printf("Micro %s", styles.Green(micro.Name)) 184 | } 185 | spaceUrl := fmt.Sprintf("http://%s%s", addr, micro.Path) 186 | utils.Logger.Printf("L url: %s\n\n", styles.Blue(spaceUrl)) 187 | 188 | wg.Add(1) 189 | go func(command *exec.Cmd) { 190 | defer wg.Done() 191 | err := command.Run() 192 | if err != nil { 193 | if errors.Is(err, exec.ErrNotFound) { 194 | utils.Logger.Printf("%s Command not found: %s", emoji.ErrorExclamation, command.Args[0]) 195 | return 196 | } 197 | utils.Logger.Printf("Command `%s` exited.", command.String()) 198 | cancelFunc() 199 | } 200 | }(command) 201 | } 202 | 203 | time.Sleep(3 * time.Second) 204 | proxy := proxy.NewReverseProxy(projectKey, meta.ID, meta.Name, meta.Alias) 205 | if err := loadMicrosFromDir(proxy, spacefile.Micros, routeDir); err != nil { 206 | return err 207 | } 208 | 209 | server := http.Server{ 210 | Addr: addr, 211 | Handler: proxy, 212 | } 213 | 214 | wg.Add(1) 215 | go func() { 216 | defer wg.Done() 217 | err := server.ListenAndServe() 218 | if err != nil && err != http.ErrServerClosed { 219 | utils.StdErrLogger.Println("proxy error", err) 220 | } 221 | }() 222 | 223 | go func() { 224 | sigs := make(chan os.Signal, 1) 225 | signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) 226 | select { 227 | case <-sigs: 228 | utils.Logger.Println("Interrupted!") 229 | cancelFunc() 230 | case <-ctx.Done(): 231 | } 232 | 233 | utils.Logger.Printf("\n\nShutting down...\n\n") 234 | server.Shutdown(context.Background()) 235 | }() 236 | 237 | if open { 238 | // Wait a bit for the server to start 239 | time.Sleep(1 * time.Second) 240 | browser.OpenURL(fmt.Sprintf("http://%s", addr)) 241 | } 242 | 243 | wg.Wait() 244 | 245 | // Wait a bit for all logs to be printed 246 | time.Sleep(1 * time.Second) 247 | 248 | return nil 249 | } 250 | 251 | func writePortFile(portfile string, port int) error { 252 | portDir := filepath.Dir(portfile) 253 | if _, err := os.Stat(portDir); os.IsNotExist(err) { 254 | if err := os.MkdirAll(portDir, 0755); err != nil { 255 | return err 256 | } 257 | } 258 | 259 | return os.WriteFile(portfile, []byte(fmt.Sprintf("%d", port)), 0644) 260 | } 261 | 262 | func loadMicrosFromDir(proxy *proxy.ReverseProxy, micros []*types.Micro, routeDir string) error { 263 | for _, micro := range micros { 264 | portFile := filepath.Join(routeDir, fmt.Sprintf("%s.port", micro.Name)) 265 | if _, err := os.Stat(portFile); err != nil { 266 | continue 267 | } 268 | 269 | microPort, err := parsePort(portFile) 270 | if err != nil { 271 | continue 272 | } 273 | 274 | n, err := proxy.AddMicro(micro, microPort) 275 | if err != nil { 276 | return err 277 | } 278 | 279 | if n != 0 { 280 | utils.Logger.Printf("\nExtracted %d actions from %s.", n, micro.Name) 281 | utils.Logger.Printf("L Preview URL: %s\n\n", "https://deta.space?devServer=http://localhost:4200") 282 | } 283 | } 284 | 285 | return nil 286 | } 287 | 288 | func getMicroPort(micro *types.Micro, routeDir string) (int, error) { 289 | portFile := filepath.Join(routeDir, fmt.Sprintf("%s.port", micro.Name)) 290 | if _, err := os.Stat(portFile); err != nil { 291 | return 0, err 292 | } 293 | 294 | port, err := parsePort(portFile) 295 | if err != nil { 296 | return 0, err 297 | } 298 | 299 | if !utils.IsPortActive(port) { 300 | return 0, fmt.Errorf("port %d is not active", port) 301 | } 302 | 303 | return port, nil 304 | } 305 | 306 | func parsePort(portFile string) (int, error) { 307 | // check if the port is already in use 308 | portStr, err := os.ReadFile(portFile) 309 | if err != nil { 310 | return 0, err 311 | } 312 | 313 | return strconv.Atoi(string(portStr)) 314 | } 315 | 316 | func MicroCommand(micro *types.Micro, directory, projectKey string, port int, ctx context.Context) (*exec.Cmd, error) { 317 | var devCommand string 318 | 319 | if micro.Dev != "" { 320 | devCommand = micro.Dev 321 | } else if micro.Engine == "static" { 322 | root := micro.Serve 323 | if root == "" { 324 | root = micro.Src 325 | } 326 | devCommand = fmt.Sprintf("%s dev serve %s --port %d", shellescape.Quote(os.Args[0]), shellescape.Quote(root), port) 327 | } else if EngineToDevCommand[micro.Engine] != "" { 328 | devCommand = EngineToDevCommand[micro.Engine] 329 | } else { 330 | return nil, errNoDevCommand 331 | } 332 | 333 | commandDir := filepath.Join(directory, micro.Src) 334 | 335 | environ := map[string]string{ 336 | "PORT": fmt.Sprintf("%d", port), 337 | "DETA_PROJECT_KEY": projectKey, 338 | "DETA_SPACE_APP_HOSTNAME": fmt.Sprintf("localhost:%d", port), 339 | "DETA_SPACE_APP_MICRO_NAME": micro.Name, 340 | "DETA_SPACE_APP_MICRO_TYPE": micro.Type(), 341 | } 342 | 343 | if types.IsPythonEngine(micro.Engine) { 344 | environ["UVICORN_PORT"] = fmt.Sprintf("%d", port) 345 | } 346 | 347 | if micro.Presets != nil { 348 | for _, env := range micro.Presets.Env { 349 | // If the env is already set by the user, don't override it 350 | if os.Getenv(env.Name) != "" { 351 | continue 352 | } 353 | 354 | if env.Default == "" { 355 | continue 356 | } 357 | 358 | environ[env.Name] = env.Default 359 | } 360 | } 361 | 362 | fields, err := shell.Fields(devCommand, func(s string) string { 363 | if env, ok := environ[s]; ok { 364 | return env 365 | } 366 | 367 | return os.Getenv(s) 368 | }) 369 | if err != nil { 370 | return nil, err 371 | } 372 | 373 | if len(fields) == 0 { 374 | return nil, fmt.Errorf("no command found for micro %s", micro.Name) 375 | } 376 | commandName := fields[0] 377 | var commandArgs []string 378 | if len(fields) > 0 { 379 | commandArgs = fields[1:] 380 | } 381 | 382 | cmd := exec.Command(commandName, commandArgs...) 383 | cmd.Env = os.Environ() 384 | for key, value := range environ { 385 | cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", key, value)) 386 | } 387 | cmd.Dir = commandDir 388 | cmd.Stdout = writer.NewPrefixer(micro.Name, os.Stdout) 389 | cmd.Stderr = writer.NewPrefixer(micro.Name, os.Stderr) 390 | 391 | return cmd, nil 392 | } 393 | -------------------------------------------------------------------------------- /cmd/dev_proxy.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "os" 8 | "os/signal" 9 | "path/filepath" 10 | "sync" 11 | "syscall" 12 | 13 | "github.com/deta/space/cmd/utils" 14 | "github.com/deta/space/internal/proxy" 15 | "github.com/deta/space/internal/runtime" 16 | "github.com/deta/space/internal/spacefile" 17 | "github.com/deta/space/pkg/components/emoji" 18 | "github.com/deta/space/pkg/components/styles" 19 | "github.com/pkg/browser" 20 | "github.com/spf13/cobra" 21 | ) 22 | 23 | func newCmdDevProxy() *cobra.Command { 24 | cmd := &cobra.Command{ 25 | Use: "proxy", 26 | Short: "Start a reverse proxy for your micros", 27 | Long: `Start a reverse proxy for your micros 28 | 29 | The micros will be automatically discovered and proxied to.`, 30 | PreRunE: utils.CheckProjectInitialized("dir"), 31 | PostRunE: utils.CheckLatestVersion, 32 | RunE: func(cmd *cobra.Command, args []string) error { 33 | var err error 34 | 35 | directory, _ := cmd.Flags().GetString("dir") 36 | host, _ := cmd.Flags().GetString("host") 37 | port, _ := cmd.Flags().GetInt("port") 38 | open, _ := cmd.Flags().GetBool("open") 39 | 40 | if !cmd.Flags().Changed("port") { 41 | port, err = GetFreePort(utils.DevPort) 42 | if err != nil { 43 | return fmt.Errorf("failed to get free port: %w", err) 44 | } 45 | } 46 | 47 | if err := devProxy(directory, host, port, open); err != nil { 48 | return err 49 | } 50 | 51 | return nil 52 | }, 53 | } 54 | 55 | cmd.Flags().StringP("dir", "d", ".", "directory of the project") 56 | cmd.Flags().IntP("port", "p", 0, "port to run the proxy on") 57 | cmd.Flags().StringP("host", "H", "localhost", "host to run the proxy on") 58 | cmd.Flags().Bool("open", false, "open the app in the browser") 59 | 60 | return cmd 61 | } 62 | 63 | func devProxy(projectDir string, host string, port int, open bool) error { 64 | meta, err := runtime.GetProjectMeta(projectDir) 65 | if err != nil { 66 | return err 67 | } 68 | 69 | addr := fmt.Sprintf("%s:%d", host, port) 70 | 71 | microDir := filepath.Join(projectDir, ".space", "micros") 72 | spacefile, _ := spacefile.LoadSpacefile(projectDir) 73 | 74 | if entries, err := os.ReadDir(microDir); err != nil || len(entries) == 0 { 75 | utils.Logger.Printf("%s No running micros detected.", emoji.X) 76 | utils.Logger.Printf("L Use %s to manually start a micro", styles.Blue("space dev up ")) 77 | return err 78 | } 79 | 80 | projectKey, err := utils.GenerateDataKeyIfNotExists(meta.ID) 81 | if err != nil { 82 | return fmt.Errorf("failed to generate project key: %w", err) 83 | } 84 | 85 | reverseProxy := proxy.NewReverseProxy(projectKey, meta.ID, meta.Name, meta.Alias) 86 | if err := loadMicrosFromDir(reverseProxy, spacefile.Micros, microDir); err != nil { 87 | return err 88 | } 89 | if err != nil { 90 | return err 91 | } 92 | server := &http.Server{ 93 | Addr: addr, 94 | Handler: reverseProxy, 95 | } 96 | 97 | wg := sync.WaitGroup{} 98 | wg.Add(1) 99 | go func() { 100 | defer wg.Done() 101 | utils.Logger.Printf("%s proxy listening on http://%s", emoji.Laptop, addr) 102 | server.ListenAndServe() 103 | }() 104 | 105 | go func() { 106 | sigs := make(chan os.Signal, 1) 107 | signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) 108 | <-sigs 109 | utils.Logger.Printf("\n\nShutting down...\n\n") 110 | server.Shutdown(context.Background()) 111 | }() 112 | 113 | if open { 114 | browser.OpenURL(fmt.Sprintf("http://localhost:%d", port)) 115 | } 116 | 117 | wg.Wait() 118 | return nil 119 | } 120 | -------------------------------------------------------------------------------- /cmd/dev_serve.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/deta/space/cmd/utils" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | func newCmdServe() *cobra.Command { 12 | cmd := &cobra.Command{ 13 | Use: "serve", 14 | Hidden: true, 15 | Args: cobra.ExactArgs(1), 16 | Short: "Serve static files", 17 | Run: func(cmd *cobra.Command, args []string) { 18 | fs := http.FileServer(http.Dir(args[0])) 19 | http.Handle("/", fs) 20 | 21 | host, _ := cmd.Flags().GetString("host") 22 | port, _ := cmd.Flags().GetInt("port") 23 | 24 | address := fmt.Sprintf("%s:%d", host, port) 25 | utils.Logger.Printf("Serving %s on %s", args[0], address) 26 | http.ListenAndServe(address, nil) 27 | }, 28 | } 29 | 30 | cmd.Flags().IntP("port", "p", 8080, "port to serve on") 31 | cmd.Flags().StringP("host", "H", "localhost", "host to serve on") 32 | 33 | return cmd 34 | } 35 | -------------------------------------------------------------------------------- /cmd/dev_trigger.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "os" 11 | "path/filepath" 12 | 13 | "github.com/AlecAivazis/survey/v2" 14 | "github.com/deta/space/cmd/utils" 15 | "github.com/deta/space/internal/spacefile" 16 | "github.com/deta/space/pkg/components/emoji" 17 | "github.com/deta/space/pkg/components/styles" 18 | "github.com/deta/space/shared" 19 | "github.com/spf13/cobra" 20 | ) 21 | 22 | func newCmdDevTrigger() *cobra.Command { 23 | cmd := &cobra.Command{ 24 | Use: "trigger ", 25 | Short: "Trigger a micro action", 26 | Long: `Manually trigger an action. 27 | Make sure that the corresponding micro is running before triggering the action.`, 28 | Aliases: []string{"t"}, 29 | Args: cobra.MaximumNArgs(1), 30 | PostRunE: utils.CheckLatestVersion, 31 | RunE: func(cmd *cobra.Command, args []string) error { 32 | experimental, _ := cmd.Flags().GetBool("experimental") 33 | if !experimental { 34 | projectDir, _ := cmd.Flags().GetString("dir") 35 | 36 | if len(args) == 0 { 37 | return errors.New("action name is required") 38 | } 39 | 40 | if err := triggerScheduledAction(projectDir, args[0]); err != nil { 41 | return err 42 | } 43 | 44 | return nil 45 | } 46 | 47 | var action Action 48 | if len(args) > 0 { 49 | actionRes, err := http.Get(fmt.Sprintf("http://localhost:%d/__space/actions/%s", utils.DevPort, args[0])) 50 | if err != nil { 51 | return err 52 | } 53 | defer actionRes.Body.Close() 54 | 55 | if err := json.NewDecoder(actionRes.Body).Decode(&action); err != nil { 56 | return err 57 | } 58 | 59 | } else { 60 | if !utils.IsPortActive(utils.DevPort) { 61 | utils.Logger.Printf("%s No action specified and no micro is running", emoji.X) 62 | } 63 | 64 | res, err := http.Get(fmt.Sprintf("http://localhost:%d/__space/actions", utils.DevPort)) 65 | if err != nil { 66 | return err 67 | } 68 | defer res.Body.Close() 69 | 70 | var actions []Action 71 | if err := json.NewDecoder(res.Body).Decode(&actions); err != nil { 72 | return err 73 | } 74 | 75 | options := make([]string, len(actions)) 76 | for i, action := range actions { 77 | options[i] = action.Name 78 | } 79 | 80 | var response string 81 | prompt := &survey.Select{ 82 | Message: "Select an action to trigger", 83 | Options: options, 84 | Description: func(value string, index int) string { 85 | return actions[index].Title 86 | }, 87 | } 88 | if err := survey.AskOne(prompt, &response); err != nil { 89 | return err 90 | } 91 | 92 | for _, a := range actions { 93 | if a.Name == response { 94 | action = a 95 | break 96 | } 97 | } 98 | 99 | if action.Name == "" { 100 | return fmt.Errorf("action %s not found", response) 101 | } 102 | } 103 | 104 | params, err := extractInput(cmd, action) 105 | if err != nil { 106 | return err 107 | } 108 | 109 | payload, err := json.Marshal(params) 110 | if err != nil { 111 | return err 112 | } 113 | 114 | actionResponse, err := http.Post(fmt.Sprintf("http://localhost:%d/__space/actions/%s", utils.DevPort, action.Name), "application/json", bytes.NewReader(payload)) 115 | if err != nil { 116 | return err 117 | } 118 | defer actionResponse.Body.Close() 119 | 120 | var actionOuput ActionOutput 121 | if err := json.NewDecoder(actionResponse.Body).Decode(&actionOuput); err != nil { 122 | return err 123 | } 124 | 125 | encoder := json.NewEncoder(os.Stdout) 126 | encoder.SetIndent("", " ") 127 | if err := encoder.Encode(actionOuput.Data); err != nil { 128 | return err 129 | } 130 | 131 | return nil 132 | }, 133 | } 134 | 135 | cmd.Flags().StringArrayP("input", "i", []string{}, "action input") 136 | cmd.Flags().BoolP("experimental", "x", false, "enable experimental features") 137 | cmd.Flags().MarkHidden("experimental") 138 | cmd.Flags().String("id", "", "project id") 139 | 140 | return cmd 141 | } 142 | 143 | func triggerScheduledAction(projectDir string, actionID string) (err error) { 144 | spacefile, err := spacefile.LoadSpacefile(projectDir) 145 | if err != nil { 146 | return fmt.Errorf("failed to parse Spacefile: %w", err) 147 | } 148 | routeDir := filepath.Join(projectDir, ".space", "micros") 149 | 150 | for _, micro := range spacefile.Micros { 151 | for _, action := range micro.Actions { 152 | if action.ID != actionID { 153 | continue 154 | } 155 | 156 | utils.Logger.Printf("\n%s Checking if micro %s is running...\n", emoji.Eyes, styles.Green(micro.Name)) 157 | port, err := getMicroPort(micro, routeDir) 158 | if err != nil { 159 | upCommand := fmt.Sprintf("space dev up %s", micro.Name) 160 | utils.Logger.Printf("%s Micro %s is not running, to start it run:", emoji.X, styles.Green(micro.Name)) 161 | utils.Logger.Printf("L %s", styles.Blue(upCommand)) 162 | return err 163 | } 164 | 165 | utils.Logger.Printf("%s Micro %s is running", styles.Green("✔️"), styles.Green(micro.Name)) 166 | 167 | body, err := json.Marshal(shared.ActionRequest{ 168 | Event: shared.ActionEvent{ 169 | ID: actionID, 170 | Trigger: "schedule", 171 | }, 172 | }) 173 | if err != nil { 174 | return err 175 | } 176 | 177 | actionEndpoint := fmt.Sprintf("http://localhost:%d/%s", port, actionEndpoint) 178 | utils.Logger.Printf("\nTriggering action %s", styles.Green(actionID)) 179 | utils.Logger.Printf("L POST %s", styles.Blue(actionEndpoint)) 180 | 181 | res, err := http.Post(actionEndpoint, "application/json", bytes.NewReader(body)) 182 | if err != nil { 183 | return fmt.Errorf("failed to trigger action: %w", err) 184 | } 185 | defer res.Body.Close() 186 | 187 | utils.Logger.Println("\n┌ Action Response:") 188 | 189 | utils.Logger.Printf("\n%s", res.Status) 190 | 191 | utils.Logger.Println() 192 | io.Copy(os.Stdout, res.Body) 193 | 194 | if res.StatusCode >= 400 { 195 | return fmt.Errorf("\n\nL failed to trigger action") 196 | } 197 | utils.Logger.Printf("\n\nL Action triggered successfully!") 198 | return nil 199 | } 200 | } 201 | return fmt.Errorf("\n%s action `%s` not found", emoji.X, actionID) 202 | } 203 | -------------------------------------------------------------------------------- /cmd/dev_up.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "os" 8 | "os/signal" 9 | "path/filepath" 10 | "syscall" 11 | 12 | "github.com/deta/space/cmd/utils" 13 | "github.com/deta/space/internal/runtime" 14 | "github.com/deta/space/internal/spacefile" 15 | "github.com/deta/space/pkg/components/emoji" 16 | "github.com/deta/space/pkg/components/styles" 17 | "github.com/pkg/browser" 18 | "github.com/spf13/cobra" 19 | ) 20 | 21 | func newCmdDevUp() *cobra.Command { 22 | devUpCmd := &cobra.Command{ 23 | Short: "Start a single micro for local development", 24 | Use: "up ", 25 | PreRunE: utils.CheckAll(utils.CheckProjectInitialized("dir"), utils.CheckNotEmpty("id")), 26 | PostRunE: utils.CheckLatestVersion, 27 | RunE: func(cmd *cobra.Command, args []string) error { 28 | var err error 29 | 30 | projectDir, _ := cmd.Flags().GetString("dir") 31 | projectID, _ := cmd.Flags().GetString("id") 32 | port, _ := cmd.Flags().GetInt("port") 33 | open, _ := cmd.Flags().GetBool("open") 34 | 35 | if !cmd.Flags().Changed("id") { 36 | projectID, err = runtime.GetProjectID(projectDir) 37 | if err != nil { 38 | return err 39 | } 40 | } 41 | 42 | if !cmd.Flags().Changed("port") { 43 | port, err = GetFreePort(utils.DevPort + 1) 44 | if err != nil { 45 | return fmt.Errorf("failed to get free port: %w", err) 46 | } 47 | } 48 | 49 | if err := devUp(projectDir, projectID, port, args[0], open); err != nil { 50 | return err 51 | } 52 | 53 | return nil 54 | }, 55 | } 56 | 57 | devUpCmd.Flags().StringP("dir", "d", ".", "directory of the project") 58 | devUpCmd.Flags().StringP("id", "i", "", "project id") 59 | devUpCmd.Flags().IntP("port", "p", 0, "port to run the micro on") 60 | devUpCmd.Flags().Bool("open", false, "open the app in the browser") 61 | 62 | return devUpCmd 63 | } 64 | 65 | func devUp(projectDir string, projectId string, port int, microName string, open bool) (err error) { 66 | 67 | spacefile, err := spacefile.LoadSpacefile(projectDir) 68 | if err != nil { 69 | return fmt.Errorf("failed to parse Spacefile: %w", err) 70 | } 71 | 72 | projectKey, err := utils.GenerateDataKeyIfNotExists(projectId) 73 | if err != nil { 74 | return fmt.Errorf("failed to generate project key: %w", err) 75 | } 76 | 77 | for _, micro := range spacefile.Micros { 78 | if micro.Name != microName { 79 | continue 80 | } 81 | 82 | portFile := filepath.Join(projectDir, ".space", "micros", fmt.Sprintf("%s.port", microName)) 83 | if _, err := os.Stat(portFile); err == nil { 84 | microPort, _ := parsePort(portFile) 85 | if utils.IsPortActive(microPort) { 86 | utils.Logger.Printf("%s %s is already running on port %d", emoji.X, styles.Green(microName), microPort) 87 | } 88 | } 89 | 90 | writePortFile(portFile, port) 91 | 92 | command, err := MicroCommand(micro, projectDir, projectKey, port, context.Background()) 93 | if err != nil { 94 | if errors.Is(err, errNoDevCommand) { 95 | utils.Logger.Printf("%s micro %s has no dev command\n", emoji.X, micro.Name) 96 | utils.Logger.Printf("See %s to get started\n", styles.Blue(spaceDevDocsURL)) 97 | return err 98 | } 99 | return err 100 | } 101 | defer os.Remove(portFile) 102 | 103 | if err := command.Start(); err != nil { 104 | return fmt.Errorf("failed to start %s: %s", styles.Green(microName), err.Error()) 105 | } 106 | 107 | // If we receive a SIGINT or SIGTERM, we want to send a SIGTERM to the child process 108 | go func() { 109 | sigs := make(chan os.Signal, 1) 110 | signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) 111 | <-sigs 112 | utils.Logger.Printf("\n\nShutting down...\n\n") 113 | 114 | command.Process.Signal(syscall.SIGTERM) 115 | }() 116 | 117 | if open { 118 | browser.OpenURL(fmt.Sprintf("http://localhost:%d", port)) 119 | } 120 | 121 | microUrl := fmt.Sprintf("http://localhost:%d", port) 122 | utils.Logger.Printf("\n%s Micro %s running on %s", styles.Green("✔️"), styles.Green(microName), styles.Blue(microUrl)) 123 | utils.Logger.Printf("\n%s Use %s to emulate the routing of your Space app\n\n", emoji.LightBulb, styles.Blue("space dev proxy")) 124 | 125 | command.Wait() 126 | return nil 127 | } 128 | return fmt.Errorf("micro %s not found", microName) 129 | } 130 | -------------------------------------------------------------------------------- /cmd/exec.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | 8 | "github.com/deta/space/cmd/utils" 9 | "github.com/deta/space/internal/runtime" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | func newCmdExec() *cobra.Command { 14 | cmd := &cobra.Command{ 15 | Use: "exec", 16 | Short: "Run a command in the context of your project", 17 | Long: `Run a command in the context of your project. 18 | 19 | The data key will be automatically injected into the command's environment.`, 20 | Args: cobra.MinimumNArgs(1), 21 | PostRunE: utils.CheckLatestVersion, 22 | RunE: func(cmd *cobra.Command, args []string) error { 23 | var err error 24 | projectID, _ := cmd.Flags().GetString("project") 25 | if !cmd.Flags().Changed("project") { 26 | cwd, _ := os.Getwd() 27 | projectID, err = runtime.GetProjectID(cwd) 28 | if err != nil { 29 | return fmt.Errorf("project id not provided and could not be inferred from current working directory") 30 | } 31 | } 32 | 33 | if err := execRun(projectID, args); err != nil { 34 | return err 35 | } 36 | 37 | return nil 38 | }, 39 | } 40 | 41 | cmd.Flags().String("project", "", "id of project to exec the command in") 42 | 43 | return cmd 44 | } 45 | 46 | func execRun(projectID string, args []string) error { 47 | var err error 48 | 49 | projectKey, err := utils.GenerateDataKeyIfNotExists(projectID) 50 | if err != nil { 51 | return fmt.Errorf("failed to generate data key: %w", err) 52 | } 53 | 54 | name := args[0] 55 | var extraArgs []string 56 | if len(args) > 1 { 57 | extraArgs = args[1:] 58 | } 59 | 60 | command := exec.Command(name, extraArgs...) 61 | command.Env = os.Environ() 62 | command.Env = append(command.Env, "DETA_PROJECT_KEY="+projectKey) 63 | command.Stdout = os.Stdout 64 | command.Stderr = os.Stderr 65 | command.Stdin = os.Stdin 66 | 67 | return command.Run() 68 | } 69 | -------------------------------------------------------------------------------- /cmd/link.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/deta/space/cmd/utils" 8 | "github.com/deta/space/internal/api" 9 | "github.com/deta/space/internal/auth" 10 | "github.com/deta/space/internal/runtime" 11 | "github.com/deta/space/pkg/components/emoji" 12 | "github.com/deta/space/pkg/components/styles" 13 | "github.com/deta/space/pkg/components/text" 14 | "github.com/spf13/cobra" 15 | ) 16 | 17 | func newCmdLink() *cobra.Command { 18 | cmd := &cobra.Command{ 19 | Use: "link [flags]", 20 | Short: "Link a local directory with an existing project", 21 | PostRunE: utils.CheckLatestVersion, 22 | RunE: func(cmd *cobra.Command, args []string) error { 23 | var err error 24 | projectDir, _ := cmd.Flags().GetString("dir") 25 | projectID, _ := cmd.Flags().GetString("id") 26 | 27 | if !cmd.Flags().Changed("id") { 28 | utils.Logger.Printf("Grab the %s of the project you want to link to using Teletype.\n\n", styles.Code("Project ID")) 29 | 30 | if projectID, err = selectLinkProjectID(); err != nil { 31 | return err 32 | } 33 | } 34 | 35 | if err := link(projectDir, projectID); err != nil { 36 | return err 37 | } 38 | 39 | return nil 40 | }, 41 | PreRunE: utils.CheckAll( 42 | utils.CheckExists("dir"), 43 | utils.CheckNotEmpty("id"), 44 | ), 45 | } 46 | 47 | cmd.Flags().StringP("id", "i", "", "project id of project to link") 48 | cmd.Flags().StringP("dir", "d", "./", "src of project to link") 49 | 50 | return cmd 51 | } 52 | 53 | func selectLinkProjectID() (string, error) { 54 | promptInput := text.Input{ 55 | Prompt: "Project ID", 56 | Placeholder: "", 57 | Validator: func(value string) error { 58 | if value == "" { 59 | return fmt.Errorf("please provide a valid id, empty project id is not valid") 60 | } 61 | return nil 62 | }, 63 | } 64 | 65 | return text.Run(&promptInput) 66 | } 67 | 68 | func link(projectDir string, projectID string) error { 69 | projectRes, err := utils.Client.GetProject(&api.GetProjectRequest{ID: projectID}) 70 | if err != nil { 71 | if errors.Is(auth.ErrNoAccessTokenFound, err) { 72 | utils.Logger.Println(utils.LoginInfo()) 73 | return err 74 | } 75 | if errors.Is(err, api.ErrProjectNotFound) { 76 | return fmt.Errorf("no project found, please provide a valid project id") 77 | } 78 | 79 | return fmt.Errorf("failed to get project details, %w", err) 80 | } 81 | 82 | err = runtime.StoreProjectMeta(projectDir, &runtime.ProjectMeta{ID: projectRes.ID, Name: projectRes.Name, Alias: projectRes.Alias}) 83 | if err != nil { 84 | return fmt.Errorf("failed to store project metadata locally, %w", err) 85 | } 86 | 87 | utils.Logger.Println(styles.Greenf("%s Project", emoji.Link), styles.Pink(projectRes.Name), styles.Green("was linked!")) 88 | utils.Logger.Println(utils.ProjectNotes(projectRes.Name, projectRes.ID)) 89 | return nil 90 | } 91 | -------------------------------------------------------------------------------- /cmd/login.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "os" 8 | "strings" 9 | 10 | "github.com/deta/space/cmd/utils" 11 | "github.com/deta/space/internal/api" 12 | "github.com/deta/space/internal/auth" 13 | "github.com/deta/space/pkg/components/styles" 14 | "github.com/deta/space/pkg/components/text" 15 | "github.com/spf13/cobra" 16 | ) 17 | 18 | func newCmdLogin() *cobra.Command { 19 | cmd := &cobra.Command{ 20 | Use: "login", 21 | Short: "Login to space", 22 | PostRunE: utils.CheckLatestVersion, 23 | Args: cobra.NoArgs, 24 | RunE: func(cmd *cobra.Command, args []string) error { 25 | var err error 26 | withToken, _ := cmd.Flags().GetBool("with-token") 27 | 28 | var accessToken string 29 | if withToken { 30 | input, err := io.ReadAll(os.Stdin) 31 | if err != nil { 32 | return fmt.Errorf("failed to read access token from standard input, %w", err) 33 | } 34 | 35 | accessToken = strings.TrimSpace(string(input)) 36 | } else { 37 | utils.Logger.Printf("To authenticate the Space CLI with your Space account, generate a new %s in your Space settings and paste it below:\n\n", styles.Code("access token")) 38 | accessToken, err = inputAccessToken() 39 | if err != nil { 40 | return err 41 | } 42 | } 43 | 44 | if err := login(accessToken); err != nil { 45 | return fmt.Errorf("failed to login, %w", err) 46 | } 47 | 48 | return nil 49 | }, 50 | } 51 | 52 | cmd.Flags().BoolP("with-token", "t", false, "Read token from standard input") 53 | if !utils.IsOutputInteractive() { 54 | cmd.MarkFlagRequired("with-token") 55 | } 56 | 57 | return cmd 58 | } 59 | 60 | func inputAccessToken() (string, error) { 61 | promptInput := text.Input{ 62 | Prompt: "Enter access token", 63 | Placeholder: "", 64 | Validator: func(value string) error { 65 | if value == "" { 66 | return fmt.Errorf("cannot be empty") 67 | } 68 | return nil 69 | }, 70 | PasswordMode: true, 71 | } 72 | 73 | return text.Run(&promptInput) 74 | } 75 | 76 | func login(accessToken string) (err error) { 77 | // Check if the access token is valid 78 | _, err = utils.Client.GetSpace(&api.GetSpaceRequest{ 79 | AccessToken: accessToken, 80 | }) 81 | 82 | if err != nil { 83 | if errors.Is(err, auth.ErrInvalidAccessToken) { 84 | return fmt.Errorf("invalid access token, please generate a valid token from your Space settings") 85 | } 86 | return fmt.Errorf("failed to validate access token, %w", err) 87 | } 88 | 89 | err = auth.StoreAccessToken(accessToken) 90 | if err != nil { 91 | return fmt.Errorf("failed to store access token, %w", err) 92 | } 93 | 94 | utils.Logger.Println(styles.Green("👍 Login Successful!")) 95 | return nil 96 | } 97 | -------------------------------------------------------------------------------- /cmd/new.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/deta/space/cmd/utils" 10 | "github.com/deta/space/internal/api" 11 | "github.com/deta/space/internal/auth" 12 | "github.com/deta/space/internal/runtime" 13 | "github.com/deta/space/internal/spacefile" 14 | "github.com/deta/space/pkg/components/confirm" 15 | "github.com/deta/space/pkg/components/styles" 16 | "github.com/deta/space/pkg/components/text" 17 | "github.com/deta/space/pkg/scanner" 18 | "github.com/spf13/cobra" 19 | ) 20 | 21 | func newCmdNew() *cobra.Command { 22 | cmd := &cobra.Command{ 23 | Use: "new [flags]", 24 | Short: "Create new project", 25 | PostRunE: utils.CheckLatestVersion, 26 | RunE: func(cmd *cobra.Command, args []string) error { 27 | projectDir, _ := cmd.Flags().GetString("dir") 28 | blankProject, _ := cmd.Flags().GetBool("blank") 29 | projectName, _ := cmd.Flags().GetString("name") 30 | 31 | if !cmd.Flags().Changed("name") { 32 | abs, err := filepath.Abs(projectDir) 33 | if err != nil { 34 | return fmt.Errorf("failed to get absolute path of project directory: %w", err) 35 | } 36 | 37 | name := filepath.Base(abs) 38 | projectName, err = selectProjectName(name) 39 | if err != nil { 40 | return err 41 | } 42 | } 43 | 44 | if err := newProject(projectDir, projectName, blankProject); err != nil { 45 | return err 46 | } 47 | 48 | return nil 49 | }, 50 | PreRunE: utils.CheckAll( 51 | utils.CheckExists("dir"), 52 | func(cmd *cobra.Command, args []string) error { 53 | if cmd.Flags().Changed("name") { 54 | name, _ := cmd.Flags().GetString("name") 55 | return validateProjectName(name) 56 | } 57 | 58 | return nil 59 | }), 60 | } 61 | 62 | cmd.Flags().StringP("name", "n", "", "project name") 63 | cmd.Flags().StringP("dir", "d", "./", "src of project to release") 64 | cmd.MarkFlagDirname("dir") 65 | cmd.Flags().BoolP("blank", "b", false, "create blank project") 66 | 67 | if !utils.IsOutputInteractive() { 68 | cmd.MarkFlagRequired("name") 69 | } 70 | 71 | return cmd 72 | } 73 | 74 | func validateProjectName(projectName string) error { 75 | if len(projectName) < 4 { 76 | return fmt.Errorf("project name must be at least 4 characters long") 77 | } 78 | 79 | if len(projectName) > 16 { 80 | return fmt.Errorf("project name must be at most 16 characters long") 81 | } 82 | 83 | return nil 84 | } 85 | 86 | func selectProjectName(placeholder string) (string, error) { 87 | promptInput := text.Input{ 88 | Prompt: "What is your project's name?", 89 | Placeholder: placeholder, 90 | Validator: validateProjectName, 91 | } 92 | 93 | return text.Run(&promptInput) 94 | } 95 | 96 | func createProject(name string) (*runtime.ProjectMeta, error) { 97 | res, err := utils.Client.CreateProject(&api.CreateProjectRequest{ 98 | Name: name, 99 | }) 100 | if err != nil { 101 | return nil, err 102 | } 103 | 104 | return &runtime.ProjectMeta{ID: res.ID, Name: res.Name, Alias: res.Alias}, nil 105 | } 106 | 107 | func createSpacefile(projectDir string, projectName string, blankProject bool) error { 108 | if blankProject { 109 | _, err := spacefile.CreateBlankSpacefile(projectDir) 110 | return err 111 | } 112 | 113 | autoDetectedMicros, err := scanner.Scan(projectDir) 114 | if err != nil { 115 | return fmt.Errorf("problem while trying to auto detect runtimes/frameworks for project %s: %s", projectName, err) 116 | } 117 | 118 | if len(autoDetectedMicros) == 0 { 119 | _, err := spacefile.CreateBlankSpacefile(projectDir) 120 | return err 121 | } 122 | 123 | for _, micro := range autoDetectedMicros { 124 | utils.Logger.Printf("\nMicro found in \"%s\"", styles.Code(micro.Src)) 125 | utils.Logger.Printf("L engine: %s\n", styles.Blue(micro.Engine)) 126 | } 127 | 128 | if !utils.IsOutputInteractive() { 129 | _, err = spacefile.CreateSpacefileWithMicros(projectDir, autoDetectedMicros) 130 | return err 131 | } 132 | 133 | utils.Logger.Println() 134 | if ok, err := confirm.Run(fmt.Sprintf("Do you want to setup \"%s\" with this configuration?", projectName)); err != nil { 135 | return err 136 | } else if !ok { 137 | _, err := spacefile.CreateBlankSpacefile(projectDir) 138 | return err 139 | } 140 | 141 | _, err = spacefile.CreateSpacefileWithMicros(projectDir, autoDetectedMicros) 142 | return err 143 | } 144 | 145 | func newProject(projectDir, projectName string, blankProject bool) error { 146 | // Create spacefile if it doesn't exist 147 | spaceFilePath := filepath.Join(projectDir, "Spacefile") 148 | if _, err := os.Stat(spaceFilePath); errors.Is(err, os.ErrNotExist) { 149 | err := createSpacefile(projectDir, projectName, blankProject) 150 | if err != nil { 151 | return fmt.Errorf("failed to create Spacefile, %w", err) 152 | } 153 | } 154 | 155 | // Create project 156 | meta, err := createProject(projectName) 157 | if err != nil { 158 | if errors.Is(auth.ErrNoAccessTokenFound, err) { 159 | utils.Logger.Println(utils.LoginInfo()) 160 | return err 161 | } 162 | return fmt.Errorf("failed to create a project, %w", err) 163 | } 164 | 165 | if err := runtime.StoreProjectMeta(projectDir, meta); err != nil { 166 | return fmt.Errorf("failed to save project metadata locally, %w", err) 167 | } 168 | 169 | utils.Logger.Println(styles.Greenf("\nProject %s created successfully!", projectName)) 170 | utils.Logger.Println(utils.ProjectNotes(projectName, meta.ID)) 171 | 172 | return nil 173 | } 174 | -------------------------------------------------------------------------------- /cmd/open.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/deta/space/cmd/utils" 7 | "github.com/deta/space/internal/runtime" 8 | "github.com/pkg/browser" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | func newCmdOpen() *cobra.Command { 13 | cmd := &cobra.Command{ 14 | Use: "open", 15 | Short: "Open your local project in the Builder UI", 16 | PreRunE: utils.CheckAll(utils.CheckExists("dir"), utils.CheckNotEmpty("id")), 17 | PostRunE: utils.CheckLatestVersion, 18 | 19 | RunE: open, 20 | } 21 | 22 | cmd.Flags().StringP("id", "i", "", "project id of project to open") 23 | cmd.Flags().StringP("dir", "d", "./", "src of project to open") 24 | 25 | return cmd 26 | } 27 | 28 | func open(cmd *cobra.Command, args []string) error { 29 | 30 | projectDir, _ := cmd.Flags().GetString("dir") 31 | projectID, _ := cmd.Flags().GetString("id") 32 | 33 | if !cmd.Flags().Changed("id") { 34 | var err error 35 | projectID, err = runtime.GetProjectID(projectDir) 36 | if err != nil { 37 | return fmt.Errorf("failed to get the project id, %w", err) 38 | } 39 | } 40 | 41 | utils.Logger.Printf("Opening project in default browser...\n") 42 | if err := browser.OpenURL(fmt.Sprintf("%s/%s", utils.BuilderUrl, projectID)); err != nil { 43 | return fmt.Errorf("failed to open a browser window, %w", err) 44 | } 45 | 46 | return nil 47 | } 48 | -------------------------------------------------------------------------------- /cmd/print-access-token.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/deta/space/internal/auth" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | func newCmdPrintAccessToken() *cobra.Command { 11 | return &cobra.Command{ 12 | Use: "print-access-token", 13 | Args: cobra.NoArgs, 14 | Hidden: true, 15 | Short: "Prints the access token used by the CLI.", 16 | RunE: func(cmd *cobra.Command, args []string) error { 17 | token, err := auth.GetAccessToken() 18 | if err != nil { 19 | return err 20 | } 21 | 22 | if _, err := os.Stdout.WriteString(token); err != nil { 23 | return err 24 | } 25 | 26 | return nil 27 | }, 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /cmd/push.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bufio" 5 | "errors" 6 | "fmt" 7 | "log" 8 | "os" 9 | "path/filepath" 10 | "strings" 11 | 12 | "github.com/deta/space/cmd/utils" 13 | "github.com/deta/space/internal/api" 14 | "github.com/deta/space/internal/auth" 15 | "github.com/deta/space/internal/runtime" 16 | "github.com/deta/space/internal/spacefile" 17 | "github.com/deta/space/pkg/components/emoji" 18 | "github.com/deta/space/pkg/components/styles" 19 | "github.com/pkg/browser" 20 | "github.com/spf13/cobra" 21 | ) 22 | 23 | const fetchPromotionsRetryCount = 5 24 | 25 | func newCmdPush() *cobra.Command { 26 | cmd := &cobra.Command{ 27 | Use: "push [flags]", 28 | Short: "Push your changes to Space and create a new revision.", 29 | Long: `Push your changes to Space and create a new revision. 30 | 31 | Space will automatically update your Builder instance with the new revision. 32 | 33 | If you don't want to follow the logs of the build and update, pass the --skip-logs argument which will exit the process as soon as the build is started instead of waiting for it to finish. 34 | 35 | Tip: Use the .spaceignore file to exclude certain files and directories from being uploaded during push. 36 | `, 37 | Args: cobra.NoArgs, 38 | PreRunE: utils.CheckAll(utils.CheckProjectInitialized("dir"), utils.CheckNotEmpty("id", "tag")), 39 | PostRunE: utils.CheckLatestVersion, 40 | RunE: func(cmd *cobra.Command, args []string) error { 41 | projectDir, _ := cmd.Flags().GetString("dir") 42 | projectID, _ := cmd.Flags().GetString("id") 43 | if !cmd.Flags().Changed("id") { 44 | var err error 45 | projectID, err = runtime.GetProjectID(projectDir) 46 | if err != nil { 47 | return fmt.Errorf("failed to get the project id, %w", err) 48 | } 49 | } 50 | 51 | pushTag, _ := cmd.Flags().GetString("tag") 52 | openInBrowser, _ := cmd.Flags().GetBool("open") 53 | skipLogs, _ := cmd.Flags().GetBool("skip-logs") 54 | experimental, _ := cmd.Flags().GetBool("experimental") 55 | 56 | return push(projectID, projectDir, pushTag, openInBrowser, skipLogs, experimental) 57 | }, 58 | } 59 | 60 | cmd.Flags().StringP("id", "i", "", "project id of project to push") 61 | cmd.Flags().StringP("dir", "d", "./", "src of project to push") 62 | cmd.MarkFlagDirname("dir") 63 | cmd.Flags().StringP("tag", "t", "", "tag to identify this push") 64 | cmd.Flags().Bool("open", false, "open builder instance/project in browser after push") 65 | cmd.Flags().BoolP("skip-logs", "", false, "skip following logs after push") 66 | cmd.Flags().BoolP("experimental", "", false, "use experimental builds") 67 | cmd.Flags().MarkHidden("experimental") 68 | 69 | return cmd 70 | } 71 | 72 | func push(projectID, projectDir, pushTag string, openInBrowser, skipLogs, experimental bool) error { 73 | utils.Logger.Printf("Validating your Spacefile...") 74 | 75 | s, err := spacefile.LoadSpacefile(projectDir) 76 | if err != nil { 77 | return fmt.Errorf("failed to parse your Spacefile, %w", err) 78 | } 79 | 80 | utils.Logger.Printf(styles.Green("\nYour Spacefile looks good, proceeding with your push!")) 81 | 82 | // push code & run build steps 83 | zippedCode, nbFiles, err := runtime.ZipDir(projectDir) 84 | if err != nil { 85 | return fmt.Errorf("failed to zip your project, %w", err) 86 | } 87 | 88 | build, err := utils.Client.CreateBuild(&api.CreateBuildRequest{AppID: projectID, Tag: pushTag, Experimental: experimental, AutoPWA: *s.AutoPWA}) 89 | if err != nil { 90 | return fmt.Errorf("failed to start a build, %w", err) 91 | } 92 | utils.Logger.Printf("\n%s Successfully started your build!", emoji.Check) 93 | 94 | // push spacefile 95 | raw, err := os.ReadFile(filepath.Join(projectDir, "Spacefile")) 96 | if err != nil { 97 | return fmt.Errorf("failed to read Spacefile, %w", err) 98 | } 99 | 100 | _, err = utils.Client.PushSpacefile(&api.PushSpacefileRequest{ 101 | Manifest: raw, 102 | BuildID: build.ID, 103 | }) 104 | if err != nil { 105 | return fmt.Errorf("failed to push Spacefile, %w", err) 106 | } 107 | utils.Logger.Printf("%s Successfully pushed your Spacefile!", emoji.Check) 108 | 109 | // // push spacefile icon 110 | if icon, err := s.GetIcon(); err == nil { 111 | if _, err := utils.Client.PushIcon(&api.PushIconRequest{ 112 | Icon: icon.Raw, 113 | ContentType: icon.IconMeta.ContentType, 114 | BuildID: build.ID, 115 | }); err != nil { 116 | return fmt.Errorf("failed to push the icon, %w", err) 117 | } 118 | } 119 | 120 | if _, err = utils.Client.PushCode(&api.PushCodeRequest{ 121 | BuildID: build.ID, ZippedCode: zippedCode, 122 | }); err != nil { 123 | if errors.Is(auth.ErrNoAccessTokenFound, err) { 124 | utils.Logger.Println(utils.LoginInfo()) 125 | return err 126 | } 127 | return fmt.Errorf("failed to push your code, %w", err) 128 | } 129 | 130 | utils.Logger.Printf("\n%s Pushing your code (%d files) & running build process...\n\n", emoji.Package, nbFiles) 131 | 132 | if skipLogs { 133 | b, err := utils.Client.GetBuild(&api.GetBuildRequest{BuildID: build.ID}) 134 | if err != nil { 135 | return fmt.Errorf("failed to check if the build was started, please check %s for the build status", styles.Codef("%s/%s/develop", utils.BuilderUrl, projectID)) 136 | } 137 | 138 | var url = fmt.Sprintf("%s/%s?event=bld-%s", utils.BuilderUrl, projectID, b.Tag) 139 | 140 | utils.Logger.Println(styles.Greenf("\n%s Successfully pushed your code!", emoji.PartyPopper)) 141 | utils.Logger.Println("\nSkipped following build process, please check build status manually:") 142 | utils.Logger.Println(styles.Codef(url)) 143 | if openInBrowser { 144 | err = browser.OpenURL(url) 145 | 146 | if err != nil { 147 | return fmt.Errorf("failed to open a browser window, %w", err) 148 | } 149 | } 150 | 151 | return nil 152 | } 153 | 154 | // get build logs 155 | readCloser, err := utils.Client.GetBuildLogs(&api.GetBuildLogsRequest{ 156 | BuildID: build.ID, 157 | }) 158 | if err != nil { 159 | return err 160 | } 161 | defer readCloser.Close() 162 | // stream build logs 163 | scanner := bufio.NewScanner(readCloser) 164 | buildLogger := log.New(os.Stderr, "", 0) 165 | buildLogger.SetFlags(log.Ldate | log.Ltime) 166 | for scanner.Scan() { 167 | line := scanner.Text() 168 | buildLogger.Println(line) 169 | } 170 | if err := scanner.Err(); err != nil { 171 | return err 172 | } 173 | 174 | // check build status 175 | b, err := utils.Client.GetBuild(&api.GetBuildRequest{BuildID: build.ID}) 176 | if err != nil { 177 | return fmt.Errorf("failed to check if push succeded, please check %s if a new revision was created successfully", styles.Codef("%s/%s/develop", utils.BuilderUrl, projectID)) 178 | } 179 | if b.Status != api.Complete { 180 | return fmt.Errorf("failed to push code and create a revision, please try again") 181 | } 182 | 183 | // get promotion via build id (build id == revision id) 184 | // loop until either p is not nil, err is not nil, or i is equal to `fetchPromotionRetryCount` 185 | var p *api.GetReleasePromotionResponse 186 | for i := 0; i < fetchPromotionsRetryCount; i++ { 187 | p, err = utils.Client.GetPromotionByRevision(&api.GetPromotionRequest{RevisionID: build.ID}) 188 | 189 | if p != nil { 190 | break 191 | } 192 | 193 | if err != nil { 194 | return fmt.Errorf("failed to check if a new revision was created, please check %s manually", styles.Codef("%s/%s/develop", utils.BuilderUrl, projectID)) 195 | } 196 | } 197 | 198 | utils.Logger.Printf("\n%s Updating your Builder instance with the new revision...\n\n", emoji.Tools) 199 | 200 | readCloserPromotion, err := utils.Client.GetReleaseLogs(&api.GetReleaseLogsRequest{ 201 | ID: p.ID, 202 | }) 203 | if err != nil { 204 | return err 205 | } 206 | 207 | defer readCloserPromotion.Close() 208 | scannerPromotion := bufio.NewScanner(readCloserPromotion) 209 | for scannerPromotion.Scan() { 210 | // we don't want to print the logs to the terminal 211 | } 212 | if err := scannerPromotion.Err(); err != nil { 213 | return err 214 | } 215 | 216 | // check promotion status 217 | p, err = utils.Client.GetReleasePromotion(&api.GetReleasePromotionRequest{PromotionID: p.ID}) 218 | if err != nil { 219 | return fmt.Errorf("failed to check if your Builder instance was updated, please check %s manually", styles.Codef("%s/%s/develop", utils.BuilderUrl, projectID)) 220 | } 221 | if p.Status != api.Complete { 222 | return fmt.Errorf("failed to update your Builder instance, please try again") 223 | } 224 | 225 | // get installation via promotion id (promotion id == release id) 226 | i, err := utils.Client.GetInstallationByRelease(&api.GetInstallationByReleaseRequest{ReleaseID: p.ID}) 227 | if err != nil { 228 | return fmt.Errorf("failed to check if your Builder instance is being updated, please check %s manually", styles.Codef("%s/%s/develop", utils.BuilderUrl, projectID)) 229 | } 230 | 231 | readCloserInstallation, err := utils.Client.GetInstallationLogs(&api.GetInstallationLogsRequest{ 232 | ID: i.ID, 233 | }) 234 | if err != nil { 235 | return err 236 | } 237 | 238 | var instanceUrl string 239 | 240 | defer readCloserInstallation.Close() 241 | scannerInstallation := bufio.NewScanner(readCloserInstallation) 242 | 243 | installationLogger := log.New(os.Stderr, "", 0) 244 | installationLogger.SetFlags(log.Ldate | log.Ltime) 245 | for scannerInstallation.Scan() { 246 | line := scannerInstallation.Text() 247 | if strings.Contains(line, "http") { 248 | instanceUrl = line 249 | } else { 250 | installationLogger.Println(line) 251 | } 252 | } 253 | if err := scannerInstallation.Err(); err != nil { 254 | return err 255 | } 256 | 257 | // check installation status 258 | i, err = utils.Client.GetInstallation(&api.GetInstallationRequest{ID: i.ID}) 259 | if err != nil { 260 | return fmt.Errorf("failed to check if your Builder instance was updated, please check %s manually", styles.Codef("%s/%s/develop", utils.BuilderUrl, projectID)) 261 | } 262 | if i.Status != api.Complete { 263 | return fmt.Errorf("failed to update your Builder instance, please try again") 264 | } 265 | 266 | utils.Logger.Println(styles.Greenf("\n%s Successfully pushed your code and updated your Builder instance!", emoji.PartyPopper)) 267 | 268 | if instanceUrl != "" { 269 | utils.Logger.Printf("Builder instance: %s", styles.Code(instanceUrl)) 270 | 271 | if openInBrowser { 272 | err = browser.OpenURL(instanceUrl) 273 | 274 | if err != nil { 275 | return fmt.Errorf("failed to open a browser window, %w", err) 276 | } 277 | } 278 | } 279 | 280 | return nil 281 | 282 | } 283 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/deta/space/cmd/utils" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | func NewSpaceCmd() *cobra.Command { 11 | cmd := &cobra.Command{ 12 | Use: "space", 13 | Short: "Deta Space CLI", 14 | Long: fmt.Sprintf(`Deta Space command line interface for managing Deta Space projects. 15 | 16 | Complete documentation available at %s`, utils.DocsUrl), 17 | Run: func(cmd *cobra.Command, args []string) { 18 | cmd.Usage() 19 | }, 20 | DisableAutoGenTag: true, 21 | // This will prevent the usage from being displayed when an error occurs 22 | // while calling the Execute function in the main.go file. 23 | SilenceUsage: true, 24 | // This will prevent the error message from being displayed when an error 25 | // We will handle printing the error message ourselves. 26 | // Each subcommand must use RunE instead of Run. 27 | SilenceErrors: true, 28 | Version: utils.SpaceVersion, 29 | } 30 | 31 | cmd.AddCommand(newCmdLogin()) 32 | cmd.AddCommand(newCmdLink()) 33 | cmd.AddCommand(newCmdPush()) 34 | cmd.AddCommand(newCmdExec()) 35 | cmd.AddCommand(NewCmdDev()) 36 | cmd.AddCommand(newCmdNew()) 37 | cmd.AddCommand(NewCmdVersion(utils.SpaceVersion, utils.Platform)) 38 | cmd.AddCommand(newCmdOpen()) 39 | cmd.AddCommand(newCmdValidate()) 40 | cmd.AddCommand(newCmdRelease()) 41 | cmd.AddCommand(newCmdAPI()) 42 | cmd.AddCommand(newCmdPrintAccessToken()) 43 | cmd.AddCommand(newCmdTrigger()) 44 | cmd.AddCommand(newCmdBuilder()) 45 | 46 | return cmd 47 | } 48 | -------------------------------------------------------------------------------- /cmd/trigger.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "os" 8 | "strconv" 9 | "strings" 10 | 11 | "github.com/AlecAivazis/survey/v2" 12 | "github.com/deta/space/cmd/utils" 13 | "github.com/mattn/go-isatty" 14 | "github.com/spf13/cobra" 15 | ) 16 | 17 | type ActionResponse struct { 18 | Actions []Action `json:"actions"` 19 | } 20 | 21 | type Action struct { 22 | InstanceID string `json:"instance_id"` 23 | InstanceAlias string `json:"instance_alias"` 24 | AppName string `json:"app_name"` 25 | Name string `json:"name"` 26 | Title string `json:"title"` 27 | Input ActionInput `json:"input"` 28 | } 29 | 30 | type ActionInput []struct { 31 | Name string `json:"name"` 32 | Type InputType `json:"type"` 33 | Optional bool `json:"optional"` 34 | } 35 | 36 | type InputType string 37 | 38 | type ActionOutput struct { 39 | Type string `json:"type"` 40 | Data any `json:"data"` 41 | } 42 | 43 | func newCmdTrigger() *cobra.Command { 44 | cmd := &cobra.Command{ 45 | Use: "trigger ", 46 | Short: "Trigger a app action", 47 | Long: `Trigger a app action.If the action requires input, it will be prompted for. You can also pipe the input to the command, or pass it as a flag.`, 48 | Hidden: true, 49 | ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { 50 | body, err := utils.Client.Get("/v0/actions") 51 | if err != nil { 52 | return nil, cobra.ShellCompDirectiveError 53 | } 54 | 55 | var actionResponse ActionResponse 56 | if err = json.Unmarshal(body, &actionResponse); err != nil { 57 | return nil, cobra.ShellCompDirectiveError 58 | } 59 | 60 | actions := actionResponse.Actions 61 | instance2actions := make(map[string][]Action) 62 | for _, action := range actions { 63 | instance2actions[action.InstanceAlias] = append(instance2actions[action.InstanceAlias], action) 64 | } 65 | 66 | if len(args) == 0 { 67 | args := make([]string, 0) 68 | for instanceAlias, actions := range instance2actions { 69 | if strings.HasPrefix(instanceAlias, toComplete) { 70 | appName := actions[0].AppName 71 | args = append(args, fmt.Sprintf("%s\t%s", instanceAlias, appName)) 72 | } 73 | } 74 | 75 | return args, cobra.ShellCompDirectiveNoFileComp 76 | } else if len(args) == 1 || len(args) == 2 { 77 | instanceAlias := args[0] 78 | actions, ok := instance2actions[instanceAlias] 79 | if !ok { 80 | return nil, cobra.ShellCompDirectiveError 81 | } 82 | 83 | args := make([]string, 0) 84 | for _, action := range actions { 85 | if strings.HasPrefix(action.Name, toComplete) { 86 | args = append(args, fmt.Sprintf("%s\t%s", action.Name, action.Title)) 87 | } 88 | } 89 | 90 | return args, cobra.ShellCompDirectiveNoFileComp 91 | } else { 92 | return nil, cobra.ShellCompDirectiveNoFileComp 93 | } 94 | }, 95 | RunE: func(cmd *cobra.Command, args []string) error { 96 | res, err := utils.Client.Get("/v0/actions?per_page=1000") 97 | if err != nil { 98 | return err 99 | } 100 | 101 | var actionResponse ActionResponse 102 | if err = json.Unmarshal(res, &actionResponse); err != nil { 103 | return err 104 | } 105 | 106 | alias2actions := make(map[string][]Action) 107 | for _, action := range actionResponse.Actions { 108 | if len(args) > 0 && !strings.HasPrefix(action.InstanceAlias, args[0]) { 109 | continue 110 | } 111 | alias2actions[action.InstanceAlias] = append(alias2actions[action.InstanceAlias], action) 112 | } 113 | 114 | var actions []Action 115 | if len(alias2actions) == 0 { 116 | return fmt.Errorf("no instances found") 117 | } else if len(alias2actions) == 1 && len(args) > 0 { 118 | for _, items := range alias2actions { 119 | actions = append(actions, items...) 120 | } 121 | } else { 122 | instanceAliases := make([]string, 0) 123 | for alias := range alias2actions { 124 | instanceAliases = append(instanceAliases, alias) 125 | } 126 | 127 | var response string 128 | survey.AskOne(&survey.Select{ 129 | Message: "Select an instance:", 130 | Options: instanceAliases, 131 | Description: func(value string, index int) string { 132 | actions := alias2actions[value] 133 | return actions[0].AppName 134 | }, 135 | }, &response) 136 | 137 | actions = alias2actions[response] 138 | } 139 | 140 | var action *Action 141 | if len(args) > 1 { 142 | for _, a := range actions { 143 | if a.Name == args[1] { 144 | action = &a 145 | break 146 | } 147 | } 148 | 149 | if action == nil { 150 | return fmt.Errorf("action %s not found", args[1]) 151 | } 152 | } else { 153 | options := make([]string, 0) 154 | for _, a := range actions { 155 | options = append(options, a.Name) 156 | } 157 | 158 | var response string 159 | if err := survey.AskOne( 160 | &survey.Select{ 161 | Message: "Select an action:", 162 | Options: options, 163 | Description: func(value string, index int) string { 164 | return actions[index].Title 165 | }, 166 | }, 167 | &response, 168 | ); err != nil { 169 | return err 170 | } 171 | 172 | for _, a := range actions { 173 | if a.Name == response { 174 | action = &a 175 | break 176 | } 177 | } 178 | 179 | if action == nil { 180 | return fmt.Errorf("action %s not found", response) 181 | } 182 | } 183 | 184 | payload, err := extractInput(cmd, *action) 185 | if err != nil { 186 | return err 187 | } 188 | 189 | body, err := json.Marshal(payload) 190 | if err != nil { 191 | return err 192 | } 193 | 194 | path := fmt.Sprintf("/v0/actions/%s/%s", action.InstanceID, action.Name) 195 | actionRes, err := utils.Client.Post(path, body) 196 | if err != nil { 197 | return err 198 | } 199 | 200 | var actionOutput ActionOutput 201 | if err = json.Unmarshal(actionRes, &actionOutput); err != nil { 202 | return err 203 | } 204 | 205 | encoder := json.NewEncoder(os.Stdout) 206 | encoder.SetIndent("", " ") 207 | if err := encoder.Encode(actionOutput.Data); err != nil { 208 | return err 209 | } 210 | 211 | return nil 212 | }, 213 | } 214 | 215 | cmd.Flags().StringArrayP("input", "i", nil, "Input parameters") 216 | 217 | return cmd 218 | } 219 | 220 | func extractInput(cmd *cobra.Command, action Action) (map[string]any, error) { 221 | params := make(map[string]any) 222 | if !isatty.IsTerminal(os.Stdin.Fd()) { 223 | var stdinParams map[string]any 224 | bs, err := io.ReadAll(os.Stdin) 225 | if err != nil { 226 | return nil, err 227 | } 228 | 229 | if err := json.Unmarshal(bs, &stdinParams); err != nil { 230 | return nil, err 231 | } 232 | 233 | for k, v := range stdinParams { 234 | params[k] = v 235 | } 236 | } 237 | 238 | if cmd.Flags().Changed("input") { 239 | inputFlag, _ := cmd.Flags().GetStringArray("input") 240 | for _, input := range inputFlag { 241 | parts := strings.Split(input, "=") 242 | if len(parts) != 2 { 243 | return nil, fmt.Errorf("invalid input flag: %s", input) 244 | } 245 | 246 | params[parts[0]] = parts[1] 247 | } 248 | } 249 | 250 | payload := make(map[string]any) 251 | for _, input := range action.Input { 252 | if param, ok := params[input.Name]; ok { 253 | payload[input.Name] = param 254 | continue 255 | } 256 | 257 | if input.Optional { 258 | continue 259 | } 260 | 261 | switch input.Type { 262 | case "string": 263 | var res string 264 | prompt := &survey.Input{Message: fmt.Sprintf("Input %s:", input.Name)} 265 | if err := survey.AskOne(prompt, &res, nil); err != nil { 266 | return nil, err 267 | } 268 | 269 | payload[input.Name] = res 270 | case "number": 271 | var res int 272 | prompt := &survey.Input{Message: fmt.Sprintf("Input %s:", input.Name)} 273 | validator := func(ans interface{}) error { 274 | if _, err := strconv.Atoi(ans.(string)); err != nil { 275 | return fmt.Errorf("invalid number") 276 | } 277 | return nil 278 | } 279 | if err := survey.AskOne(prompt, &res, survey.WithValidator(validator)); err != nil { 280 | return nil, err 281 | } 282 | 283 | payload[input.Name] = res 284 | case "boolean": 285 | var res bool 286 | prompt := &survey.Confirm{Message: fmt.Sprintf("Input %s:", input.Name)} 287 | if err := survey.AskOne(prompt, &res); err != nil { 288 | return nil, err 289 | } 290 | 291 | payload[input.Name] = res 292 | default: 293 | return nil, fmt.Errorf("unknown input type: %s", input.Type) 294 | } 295 | } 296 | 297 | return payload, nil 298 | } 299 | -------------------------------------------------------------------------------- /cmd/utils/checks.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | "time" 10 | 11 | "github.com/deta/space/internal/api" 12 | "github.com/deta/space/internal/runtime" 13 | "github.com/deta/space/pkg/components/styles" 14 | "github.com/spf13/cobra" 15 | ) 16 | 17 | type PreRunFunc func(cmd *cobra.Command, args []string) error 18 | 19 | func CheckAll(funcs ...PreRunFunc) PreRunFunc { 20 | return func(cmd *cobra.Command, args []string) error { 21 | for _, f := range funcs { 22 | if f == nil { 23 | continue 24 | } 25 | if err := f(cmd, args); err != nil { 26 | return err 27 | } 28 | } 29 | return nil 30 | } 31 | } 32 | 33 | func CheckExists(flagName ...string) PreRunFunc { 34 | return func(cmd *cobra.Command, args []string) error { 35 | for _, flagName := range flagName { 36 | dir, _ := cmd.Flags().GetString(flagName) 37 | if _, err := os.Stat(dir); os.IsNotExist(err) { 38 | return fmt.Errorf("directory %s does not exist", dir) 39 | } 40 | } 41 | return nil 42 | } 43 | } 44 | 45 | func CheckProjectInitialized(dirFlag string) PreRunFunc { 46 | return CheckAll(CheckExists(dirFlag), func(cmd *cobra.Command, args []string) error { 47 | dir, _ := cmd.Flags().GetString(dirFlag) 48 | 49 | if _, err := os.Stat(filepath.Join(dir, ".space", "meta")); os.IsNotExist(err) { 50 | return errors.New("project is not initialized. run `space new` to initialize a new project or `space link` to associate an existing project.") 51 | } 52 | 53 | return nil 54 | }) 55 | } 56 | 57 | func CheckNotEmpty(flagNames ...string) PreRunFunc { 58 | return func(cmd *cobra.Command, args []string) error { 59 | for _, flagName := range flagNames { 60 | if cmd.Flags().Changed(flagName) { 61 | value, _ := cmd.Flags().GetString(flagName) 62 | if strings.Trim(value, " ") == "" { 63 | return fmt.Errorf("%s cannot be empty", flagName) 64 | } 65 | } 66 | } 67 | return nil 68 | } 69 | } 70 | 71 | func isPrerelease(version string) bool { 72 | return len(strings.Split(version, "-")) > 1 73 | } 74 | 75 | func CheckLatestVersion(cmd *cobra.Command, args []string) error { 76 | if isPrerelease(SpaceVersion) { 77 | return nil 78 | } 79 | 80 | latestVersion, lastCheck, err := runtime.GetLatestCachedVersion() 81 | if err != nil || time.Since(lastCheck) > 69*time.Minute { 82 | Logger.Println("\nChecking for new Space CLI version...") 83 | version, err := api.GetLatestCliVersion() 84 | if err != nil { 85 | Logger.Println("Failed to check for new Space CLI version") 86 | return nil 87 | } 88 | 89 | runtime.CacheLatestVersion(version) 90 | latestVersion = version 91 | } 92 | 93 | if SpaceVersion != latestVersion { 94 | Logger.Println(styles.Boldf("\n%s New Space CLI version available, upgrade with %s", styles.Info, styles.Code("space version upgrade"))) 95 | } 96 | 97 | return nil 98 | } 99 | -------------------------------------------------------------------------------- /cmd/utils/keys.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/deta/space/internal/api" 7 | "github.com/deta/space/internal/auth" 8 | ) 9 | 10 | func GenerateDataKeyIfNotExists(projectID string) (string, error) { 11 | // check if we have already stored the project key based on the project's id 12 | projectKey, err := auth.GetProjectKey(projectID) 13 | if err == nil { 14 | return projectKey, nil 15 | } 16 | 17 | listRes, err := Client.ListProjectKeys(projectID) 18 | if err != nil { 19 | return "", err 20 | } 21 | 22 | keyName := findAvailableKey(listRes.Keys, "space cli") 23 | 24 | // create a new project key using the api 25 | r, err := Client.CreateProjectKey(projectID, &api.CreateProjectKeyRequest{ 26 | Name: keyName, 27 | }) 28 | if err != nil { 29 | return "", err 30 | } 31 | 32 | // store the project key locally 33 | err = auth.StoreProjectKey(projectID, r.Value) 34 | if err != nil { 35 | return "", err 36 | } 37 | 38 | return r.Value, nil 39 | } 40 | 41 | func findAvailableKey(keys []api.ProjectKey, name string) string { 42 | keyMap := make(map[string]struct{}) 43 | for _, key := range keys { 44 | keyMap[key.Name] = struct{}{} 45 | } 46 | 47 | if _, ok := keyMap[name]; !ok { 48 | return name 49 | } 50 | 51 | for i := 1; ; i++ { 52 | newName := fmt.Sprintf("%s (%d)", name, i) 53 | if _, ok := keyMap[newName]; !ok { 54 | return newName 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /cmd/utils/messages.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/deta/space/pkg/components/emoji" 8 | "github.com/deta/space/pkg/components/styles" 9 | "github.com/mattn/go-isatty" 10 | ) 11 | 12 | func ProjectNotes(projectName string, projectId string) string { 13 | return fmt.Sprintf(` 14 | %s 15 | 16 | %s Find your project in Builder: %s 17 | %s Use the %s to configure your app: %s 18 | %s Push your code to Space with %s`, styles.Bold("Next steps:"), emoji.Eyes, 19 | styles.Bold(fmt.Sprintf("%s/%s", BuilderUrl, projectId)), 20 | emoji.File, 21 | styles.Code("Spacefile"), styles.Bold(SpacefileDocsUrl), 22 | emoji.Swirl, 23 | styles.Code("space push")) 24 | } 25 | 26 | func LoginInfo() string { 27 | return styles.Boldf("No auth token found. Run %s or provide access token to login.", styles.Code("space login")) 28 | } 29 | 30 | func IsOutputInteractive() bool { 31 | return isatty.IsTerminal(os.Stdout.Fd()) 32 | } 33 | -------------------------------------------------------------------------------- /cmd/utils/shared.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net" 7 | "os" 8 | 9 | "github.com/deta/space/internal/api" 10 | ) 11 | 12 | const ( 13 | DocsUrl = "https://deta.space/docs" 14 | SpacefileDocsUrl = "https://go.deta.dev/docs/spacefile/v0" 15 | BuilderUrl = "https://deta.space/builder" 16 | ) 17 | 18 | var ( 19 | Platform string 20 | SpaceVersion = "dev" 21 | DevPort = 4200 22 | Client = api.NewDetaClient(SpaceVersion, Platform) 23 | Logger = log.New(os.Stdout, "", 0) 24 | StdErrLogger = log.New(os.Stderr, "", 0) 25 | ) 26 | 27 | func IsPortActive(port int) bool { 28 | conn, err := net.Dial("tcp", fmt.Sprintf(":%d", port)) 29 | if err != nil { 30 | return false 31 | } 32 | 33 | conn.Close() 34 | return true 35 | } 36 | -------------------------------------------------------------------------------- /cmd/validate.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/deta/space/cmd/utils" 8 | "github.com/deta/space/internal/spacefile" 9 | "github.com/deta/space/pkg/components/emoji" 10 | "github.com/deta/space/pkg/components/styles" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | func newCmdValidate() *cobra.Command { 15 | cmd := &cobra.Command{ 16 | Use: "validate [flags]", 17 | Short: "Validate your Spacefile and check for errors", 18 | PostRunE: utils.CheckLatestVersion, 19 | RunE: func(cmd *cobra.Command, args []string) error { 20 | projectDir, _ := cmd.Flags().GetString("dir") 21 | if err := validate(projectDir); err != nil { 22 | return err 23 | } 24 | 25 | return nil 26 | }, 27 | PreRunE: utils.CheckExists("dir"), 28 | } 29 | cmd.Flags().StringP("dir", "d", "./", "src of project to validate") 30 | 31 | return cmd 32 | } 33 | 34 | func validate(projectDir string) error { 35 | utils.Logger.Printf("\n%s Validating your Spacefile...\n", emoji.Package) 36 | 37 | s, err := spacefile.LoadSpacefile(projectDir) 38 | if err != nil { 39 | return fmt.Errorf("failed to parse Spacefile, %w", err) 40 | } 41 | 42 | if s.Icon == "" { 43 | utils.Logger.Printf("\n%s No app icon specified.", styles.Blue("i")) 44 | } else { 45 | if err := spacefile.ValidateIcon(s.Icon); err != nil { 46 | switch { 47 | case errors.Is(spacefile.ErrInvalidIconType, err): 48 | return fmt.Errorf("invalid icon type, please use a 512x512 sized PNG or WebP icon") 49 | case errors.Is(spacefile.ErrInvalidIconSize, err): 50 | return fmt.Errorf("icon size is not valid, please use a 512x512 sized PNG or WebP icon") 51 | case errors.Is(spacefile.ErrInvalidIconPath, err): 52 | return fmt.Errorf("cannot find the icon in provided path, please provide a valid icon path or leave it empty to auto-generate one") 53 | default: 54 | return err 55 | } 56 | } 57 | } 58 | utils.Logger.Println(styles.Greenf("\n%s Spacefile looks good!", emoji.Sparkles)) 59 | return nil 60 | } 61 | -------------------------------------------------------------------------------- /cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/deta/space/cmd/utils" 5 | "github.com/deta/space/pkg/components/emoji" 6 | "github.com/deta/space/pkg/components/styles" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | func NewCmdVersion(version string, platform string) *cobra.Command { 11 | cmd := &cobra.Command{ 12 | Use: "version", 13 | Short: "Space CLI version", 14 | PostRunE: utils.CheckLatestVersion, 15 | Run: func(cmd *cobra.Command, args []string) { 16 | utils.Logger.Printf("%s%s %s\n", emoji.Pistol, styles.Code(version), platform) 17 | }, 18 | } 19 | 20 | cmd.AddCommand(newCmdVersionUpgrade(version)) 21 | return cmd 22 | } 23 | -------------------------------------------------------------------------------- /cmd/version_ugrade.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os/exec" 6 | "runtime" 7 | "strings" 8 | 9 | "github.com/deta/space/cmd/utils" 10 | "github.com/deta/space/internal/api" 11 | detaruntime "github.com/deta/space/internal/runtime" 12 | "github.com/deta/space/pkg/components/styles" 13 | "github.com/spf13/cobra" 14 | ) 15 | 16 | func newCmdVersionUpgrade(currentVersion string) *cobra.Command { 17 | cmd := &cobra.Command{ 18 | Use: "upgrade", 19 | Short: "Upgrade Space CLI version", 20 | Example: versionUpgradeExamples(), 21 | RunE: func(cmd *cobra.Command, args []string) error { 22 | targetVersion, _ := cmd.Flags().GetString("version") 23 | if !cmd.Flags().Changed("version") { 24 | latestVersion, err := api.GetLatestCliVersion() 25 | if err != nil { 26 | return fmt.Errorf("failed to get the latest version, %w, please try again", err) 27 | } 28 | targetVersion = latestVersion 29 | } 30 | 31 | if currentVersion == targetVersion { 32 | utils.Logger.Println(styles.Boldf("Space CLI version already %s, no upgrade required", styles.Code(targetVersion))) 33 | return nil 34 | } 35 | 36 | switch runtime.GOOS { 37 | case "linux", "darwin": 38 | err := upgradeUnix(targetVersion) 39 | if err != nil { 40 | return fmt.Errorf("failed to upgrade, %w, please try again", err) 41 | } 42 | case "windows": 43 | err := upgradeWin(targetVersion) 44 | if err != nil { 45 | if err != nil { 46 | return fmt.Errorf("failed to upgrade, %w, please try again", err) 47 | } 48 | } 49 | default: 50 | return fmt.Errorf("unsupported OS, %s", runtime.GOOS) 51 | } 52 | 53 | detaruntime.CacheLatestVersion(targetVersion) 54 | 55 | return nil 56 | }, 57 | Args: cobra.NoArgs, 58 | } 59 | cmd.Flags().StringP("version", "v", "", "version number") 60 | return cmd 61 | } 62 | 63 | func upgradeUnix(version string) error { 64 | curlCmd := exec.Command("curl", "-fsSL", "https://deta.space/assets/space-cli.sh") 65 | msg := "Upgrading Space CLI" 66 | curlOutput, err := curlCmd.CombinedOutput() 67 | if err != nil { 68 | utils.Logger.Println(string(curlOutput)) 69 | return err 70 | } 71 | 72 | co := string(curlOutput) 73 | shCmd := exec.Command("sh", "-c", co) 74 | if version != "" { 75 | if !strings.HasPrefix(version, "v") { 76 | version = fmt.Sprintf("v%s", version) 77 | } 78 | msg = fmt.Sprintf("%s to version %s", msg, styles.Code(version)) 79 | shCmd = exec.Command("sh", "-c", co, "upgrade", version) 80 | } 81 | utils.Logger.Printf("%s...\n", msg) 82 | 83 | shOutput, err := shCmd.CombinedOutput() 84 | utils.Logger.Println(string(shOutput)) 85 | if err != nil { 86 | return err 87 | } 88 | return nil 89 | } 90 | 91 | func versionUpgradeExamples() string { 92 | return ` 93 | 1. space version upgrade 94 | Upgrade Space CLI to latest version. 95 | 2. space version upgrade --version v0.0.2 96 | Upgrade Space CLI to version 'v0.0.2'.` 97 | } 98 | -------------------------------------------------------------------------------- /cmd/version_upgrade_exec.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | // +build !windows 3 | 4 | package cmd 5 | 6 | func upgradeWin(version string) error { 7 | return nil 8 | } 9 | -------------------------------------------------------------------------------- /cmd/version_upgrade_exec_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | // +build windows 3 | 4 | package cmd 5 | 6 | import ( 7 | "fmt" 8 | "github.com/deta/space/cmd/utils" 9 | "github.com/deta/space/pkg/components/styles" 10 | "os/exec" 11 | ) 12 | 13 | func upgradeWin(version string) error { 14 | msg := "Upgrading Space CLI" 15 | cmd := "iwr https://deta.space/assets/space-cli.ps1 -useb | iex" 16 | 17 | if version != "" { 18 | msg = fmt.Sprintf("%s to version %s", msg, styles.Code(version)) 19 | cmd = fmt.Sprintf(`$v="%s"; %s`, version, cmd) 20 | } 21 | utils.Logger.Printf("%s...\n", msg) 22 | 23 | pshellCmd := exec.Command("powershell", cmd) 24 | 25 | stdout, err := pshellCmd.CombinedOutput() 26 | fmt.Println(string(stdout)) 27 | if err != nil { 28 | return err 29 | } 30 | 31 | return nil 32 | } 33 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/deta/space 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/AlecAivazis/survey/v2 v2.3.6 7 | github.com/adrg/frontmatter v0.2.0 8 | github.com/alessio/shellescape v1.4.1 9 | github.com/charmbracelet/bubbles v0.15.0 10 | github.com/charmbracelet/bubbletea v0.23.2 11 | github.com/charmbracelet/lipgloss v0.7.1 12 | github.com/google/go-github/v51 v51.0.0 13 | github.com/itchyny/gojq v0.12.12 14 | github.com/joho/godotenv v1.5.1 15 | github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 16 | github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 17 | github.com/santhosh-tekuri/jsonschema/v5 v5.3.0 18 | github.com/spf13/cobra v1.7.0 19 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 20 | gopkg.in/yaml.v2 v2.4.0 21 | gopkg.in/yaml.v3 v3.0.1 22 | gotest.tools/v3 v3.3.0 23 | mvdan.cc/sh/v3 v3.6.0 24 | ) 25 | 26 | require ( 27 | github.com/BurntSushi/toml v1.2.1 // indirect 28 | github.com/ProtonMail/go-crypto v0.0.0-20230417170513-8ee5748c52b5 // indirect 29 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 30 | github.com/cloudflare/circl v1.3.3 // indirect 31 | github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect 32 | github.com/golang/protobuf v1.5.3 // indirect 33 | github.com/google/go-querystring v1.1.0 // indirect 34 | github.com/itchyny/timefmt-go v0.1.5 // indirect 35 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect 36 | github.com/mattn/go-colorable v0.1.2 // indirect 37 | github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect 38 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 39 | golang.org/x/crypto v0.14.0 // indirect 40 | golang.org/x/net v0.17.0 // indirect 41 | golang.org/x/oauth2 v0.7.0 // indirect 42 | golang.org/x/sync v0.1.0 // indirect 43 | google.golang.org/appengine v1.6.7 // indirect 44 | google.golang.org/protobuf v1.30.0 // indirect 45 | ) 46 | 47 | require ( 48 | github.com/atotto/clipboard v0.1.4 // indirect 49 | github.com/containerd/console v1.0.3 // indirect 50 | github.com/google/go-cmp v0.5.9 // indirect 51 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 52 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 53 | github.com/mattn/go-isatty v0.0.18 54 | github.com/mattn/go-localereader v0.0.1 // indirect 55 | github.com/mattn/go-runewidth v0.0.14 // indirect 56 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 57 | github.com/muesli/cancelreader v0.2.2 // indirect 58 | github.com/muesli/reflow v0.3.0 // indirect 59 | github.com/muesli/termenv v0.15.1 // indirect 60 | github.com/rivo/uniseg v0.4.4 // indirect 61 | github.com/spf13/pflag v1.0.5 // indirect 62 | golang.org/x/sys v0.13.0 // indirect 63 | golang.org/x/term v0.13.0 64 | golang.org/x/text v0.13.0 // indirect 65 | ) 66 | -------------------------------------------------------------------------------- /internal/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deta/space-cli/a7f5d050d8577b55c699a96228ba691038c6df27/internal/.DS_Store -------------------------------------------------------------------------------- /internal/api/client.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "io/ioutil" 9 | "net/http" 10 | "strconv" 11 | "time" 12 | 13 | "github.com/deta/space/internal/auth" 14 | ) 15 | 16 | const ( 17 | SpaceClientHeader = "X-Space-Client" 18 | ) 19 | 20 | type DetaClient struct { 21 | Client *http.Client 22 | Version string 23 | Platform string 24 | TimestampShift int64 25 | } 26 | 27 | func NewDetaClient(version string, platform string) *DetaClient { 28 | return &DetaClient{ 29 | Client: &http.Client{}, 30 | Version: version, 31 | Platform: platform, 32 | } 33 | } 34 | 35 | type errorResp struct { 36 | Errors []string `json:"errors,omitempty"` 37 | Detail string `json:"detail,omitempty"` 38 | } 39 | 40 | // requestInput input to Request function 41 | type requestInput struct { 42 | Root string 43 | Path string 44 | Method string 45 | Headers map[string]string 46 | QueryParams map[string]string 47 | Body interface{} 48 | NeedsAuth bool 49 | ContentType string 50 | ReturnReadCloser bool 51 | AccessToken string 52 | } 53 | 54 | // requestOutput ouput of Request function 55 | type requestOutput struct { 56 | Status int 57 | Body []byte 58 | BodyReadCloser io.ReadCloser 59 | Header http.Header 60 | Error *errorResp 61 | } 62 | 63 | func fetchServerTimestamp() (int64, error) { 64 | timestampUrl := fmt.Sprintf("%s/v0/time", spaceRoot) 65 | res, err := http.Get(timestampUrl) 66 | if err != nil { 67 | return 0, fmt.Errorf("failed to fetch timestamp: %w", err) 68 | } 69 | defer res.Body.Close() 70 | 71 | if res.StatusCode != 200 { 72 | return 0, fmt.Errorf("failed to fetch timestamp, status code: %v", res.StatusCode) 73 | } 74 | 75 | b, err := ioutil.ReadAll(res.Body) 76 | if err != nil { 77 | return 0, fmt.Errorf("failed to read timestamp response: %w", err) 78 | } 79 | 80 | serverTimestamp, err := strconv.ParseInt(string(b), 10, 64) 81 | if err != nil { 82 | return 0, fmt.Errorf("failed to parse timestamp: %w", err) 83 | } 84 | 85 | return serverTimestamp, nil 86 | } 87 | 88 | func (c *DetaClient) Get(path string) ([]byte, error) { 89 | output, err := c.request(&requestInput{ 90 | Method: "GET", 91 | Root: spaceRoot, 92 | Path: path, 93 | NeedsAuth: true, 94 | }) 95 | 96 | if err != nil { 97 | return nil, err 98 | } 99 | 100 | if output.Status != 200 { 101 | return nil, fmt.Errorf("request failed, status code: %v", output.Status) 102 | } 103 | 104 | return output.Body, nil 105 | } 106 | 107 | func (c *DetaClient) Post(path string, body []byte) ([]byte, error) { 108 | output, err := c.request(&requestInput{ 109 | Method: "POST", 110 | Path: path, 111 | Root: spaceRoot, 112 | ContentType: "application/json", 113 | Body: body, 114 | NeedsAuth: true, 115 | }) 116 | 117 | if err != nil { 118 | return nil, err 119 | } 120 | 121 | if output.Status != 200 { 122 | return nil, fmt.Errorf("request failed, status code: %v, ", output.Status) 123 | } 124 | 125 | return output.Body, nil 126 | } 127 | 128 | func (c *DetaClient) Delete(path string, body []byte) ([]byte, error) { 129 | output, err := c.request(&requestInput{ 130 | Method: "DELETE", 131 | Path: path, 132 | Root: spaceRoot, 133 | ContentType: "application/json", 134 | Body: body, 135 | NeedsAuth: true, 136 | }) 137 | if err != nil { 138 | return nil, err 139 | } 140 | 141 | if output.Status != 200 { 142 | return nil, fmt.Errorf("request failed, status code: %v", output.Status) 143 | } 144 | 145 | return output.Body, err 146 | } 147 | 148 | func (c *DetaClient) Patch(path string, body []byte) ([]byte, error) { 149 | output, err := c.request(&requestInput{ 150 | Method: "PATCH", 151 | Path: path, 152 | Root: spaceRoot, 153 | ContentType: "application/json", 154 | Body: body, 155 | NeedsAuth: true, 156 | }) 157 | if err != nil { 158 | return nil, err 159 | } 160 | 161 | if output.Status != 200 { 162 | return nil, fmt.Errorf("request failed, status code: %v", output.Status) 163 | } 164 | 165 | return output.Body, err 166 | } 167 | 168 | // Request send an http request to the deta api 169 | func (c *DetaClient) request(i *requestInput) (*requestOutput, error) { 170 | marshalled, _ := i.Body.([]byte) 171 | if i.Body != nil && i.ContentType == "" { 172 | // default set content-type to application/json 173 | i.ContentType = "application/json" 174 | var err error 175 | marshalled, err = json.Marshal(&i.Body) 176 | if err != nil { 177 | return nil, err 178 | } 179 | } 180 | 181 | req, err := http.NewRequest(i.Method, fmt.Sprintf("%s%s", i.Root, i.Path), bytes.NewBuffer(marshalled)) 182 | if err != nil { 183 | return nil, err 184 | } 185 | 186 | // headers 187 | if i.ContentType != "" { 188 | req.Header.Set("Content-type", i.ContentType) 189 | } 190 | for k, v := range i.Headers { 191 | req.Header.Set(k, v) 192 | } 193 | 194 | clientHeader := fmt.Sprintf("cli/%s %s", c.Version, c.Platform) 195 | req.Header.Set(SpaceClientHeader, clientHeader) 196 | 197 | // query params 198 | q := req.URL.Query() 199 | for k, v := range i.QueryParams { 200 | q.Add(k, v) 201 | } 202 | req.URL.RawQuery = q.Encode() 203 | 204 | if i.NeedsAuth { 205 | if i.AccessToken == "" { 206 | i.AccessToken, err = auth.GetAccessToken() 207 | if err != nil { 208 | return nil, fmt.Errorf("failed to get access token: %w", err) 209 | } 210 | } 211 | 212 | now := time.Now().UTC().Unix() 213 | 214 | // client timestamps can be off by a lot, so we compute the shift from the server 215 | if c.TimestampShift == 0 { 216 | serverTimestamp, err := fetchServerTimestamp() 217 | if err != nil { 218 | return nil, fmt.Errorf("failed to compute timestamp shift: %w", err) 219 | } 220 | 221 | c.TimestampShift = serverTimestamp - now 222 | } 223 | 224 | timestamp := strconv.FormatInt(now+c.TimestampShift, 10) 225 | 226 | // compute signature 227 | signature, err := auth.CalcSignature(&auth.CalcSignatureInput{ 228 | AccessToken: i.AccessToken, 229 | HTTPMethod: i.Method, 230 | URI: req.URL.RequestURI(), 231 | Timestamp: timestamp, 232 | ContentType: i.ContentType, 233 | RawBody: marshalled, 234 | }) 235 | if err != nil { 236 | return nil, fmt.Errorf("failed to calculate auth signature: %w", err) 237 | } 238 | // set needed access key auth headers 239 | req.Header.Set("X-Deta-Timestamp", timestamp) 240 | req.Header.Set("X-Deta-Signature", signature) 241 | } 242 | 243 | res, err := c.Client.Do(req) 244 | if err != nil { 245 | return nil, err 246 | } 247 | 248 | o := &requestOutput{ 249 | Status: res.StatusCode, 250 | Header: res.Header, 251 | } 252 | 253 | if i.ReturnReadCloser && res.StatusCode >= 200 && res.StatusCode <= 299 { 254 | o.BodyReadCloser = res.Body 255 | return o, nil 256 | } 257 | 258 | defer res.Body.Close() 259 | b, err := ioutil.ReadAll(res.Body) 260 | if err != nil { 261 | return nil, err 262 | } 263 | if res.StatusCode >= 200 && res.StatusCode <= 299 { 264 | if res.StatusCode != 204 { 265 | o.Body = b 266 | } 267 | return o, nil 268 | } 269 | 270 | var er errorResp 271 | if res.StatusCode == 413 { 272 | er.Detail = "Request entity too large" 273 | o.Error = &er 274 | return o, nil 275 | } 276 | if res.StatusCode == 502 { 277 | er.Detail = "Internal server error" 278 | o.Error = &er 279 | return o, nil 280 | } 281 | err = json.Unmarshal(b, &er) 282 | if err != nil { 283 | return nil, fmt.Errorf("failed to unmarshall error msg, request status code: %v", res.StatusCode) 284 | } 285 | o.Error = &er 286 | return o, nil 287 | } 288 | -------------------------------------------------------------------------------- /internal/api/github.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/google/go-github/v51/github" 9 | ) 10 | 11 | func GetLatestCliVersion() (string, error) { 12 | client := github.NewClient(nil) 13 | 14 | release, resp, err := client.Repositories.GetLatestRelease(context.Background(), "deta", "space-cli") 15 | 16 | if err != nil { 17 | return "", fmt.Errorf("error while fetching latest release: %v", err) 18 | } 19 | 20 | if resp.StatusCode != 200 { 21 | return "", fmt.Errorf("error while fetching latest release: %v", resp.Status) 22 | } 23 | 24 | return strings.TrimPrefix(release.GetTagName(), "v"), nil 25 | } 26 | -------------------------------------------------------------------------------- /internal/auth/auth.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io/ioutil" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | 11 | "crypto/hmac" 12 | "crypto/sha256" 13 | "encoding/hex" 14 | "encoding/json" 15 | ) 16 | 17 | const ( 18 | spaceAccessTokenEnv = "SPACE_ACCESS_TOKEN" 19 | spaceTokensFile = "space_tokens" 20 | spaceSignVersion = "v0" 21 | spaceDir = ".detaspace" 22 | oldSpaceDir = ".deta" 23 | dirModePermReadWriteExecute = 0760 24 | fileModePermReadWrite = 0660 25 | spaceProjectKeysPath = ".detaspace/space_project_keys" 26 | ) 27 | 28 | var ( 29 | spaceAuthTokenPath = filepath.Join(spaceDir, spaceTokensFile) 30 | oldSpaceAuthTokenPath = filepath.Join(oldSpaceDir, spaceTokensFile) 31 | 32 | // ErrNoProjectKeyFound no access token found 33 | ErrNoProjectKeyFound = errors.New("no project key was found or was empty") 34 | // ErrNoAccessTokenFound no access token found 35 | ErrNoAccessTokenFound = errors.New("no access token was found or was empty") 36 | // ErrInvalidAccessToken invalid access token 37 | ErrInvalidAccessToken = errors.New("invalid access token") 38 | // ErrBadAccessTokenFile bad access token file 39 | ErrBadAccessTokenFile = errors.New("bad access token file") 40 | ) 41 | 42 | type Token struct { 43 | AccessToken string `json:"access_token"` 44 | } 45 | 46 | func getAccessTokenFromFile(filepath string) (string, error) { 47 | f, err := os.Open(filepath) 48 | if err != nil { 49 | return "", fmt.Errorf("os.Open: %w", err) 50 | } 51 | defer f.Close() 52 | var t Token 53 | if err := json.NewDecoder(f).Decode(&t); err != nil { 54 | return "", fmt.Errorf("%w: %s", ErrBadAccessTokenFile, filepath) 55 | } 56 | if t.AccessToken == "" { 57 | return t.AccessToken, ErrNoAccessTokenFound 58 | } 59 | return t.AccessToken, nil 60 | } 61 | 62 | // GetAccessToken retrieves the tokens from storage or env var 63 | func GetAccessToken() (string, error) { 64 | // preference to env var first 65 | spaceAccessToken := os.Getenv(spaceAccessTokenEnv) 66 | if spaceAccessToken != "" { 67 | return spaceAccessToken, nil 68 | } 69 | 70 | home, err := os.UserHomeDir() 71 | if err != nil { 72 | return "", fmt.Errorf("failed to get user home directory: %w", err) 73 | } 74 | 75 | tokensFilePath := filepath.Join(home, spaceAuthTokenPath) 76 | accessToken, err := getAccessTokenFromFile(tokensFilePath) 77 | if err != nil { 78 | if !errors.Is(err, os.ErrNotExist) { 79 | return accessToken, fmt.Errorf("failed to get access token from file: %w", err) 80 | } 81 | // fallback to old space auth token path 82 | tokensFilePath = filepath.Join(home, oldSpaceAuthTokenPath) 83 | accessToken, err = getAccessTokenFromFile(tokensFilePath) 84 | if err != nil { 85 | if !errors.Is(err, os.ErrNotExist) { 86 | return accessToken, fmt.Errorf("failed to get access token from file: %w", err) 87 | } 88 | return "", ErrNoAccessTokenFound 89 | } 90 | // store access token in new token directory if old directory 91 | if err := StoreAccessToken(accessToken); err != nil { 92 | return "", fmt.Errorf("failed to store access token from old token path to new path: %w", err) 93 | } 94 | } 95 | return accessToken, nil 96 | } 97 | 98 | func storeAccessToken(t *Token, path string) error { 99 | dir := filepath.Dir(path) 100 | if err := os.MkdirAll(dir, dirModePermReadWriteExecute); err != nil { 101 | return fmt.Errorf("failed to create dir %s: %w", dir, err) 102 | } 103 | marshalled, err := json.Marshal(t) 104 | if err != nil { 105 | return fmt.Errorf("failed to marshall token: %w", err) 106 | } 107 | if err := os.WriteFile(path, marshalled, fileModePermReadWrite); err != nil { 108 | return fmt.Errorf("failed to write token to file %s: %w", path, err) 109 | } 110 | return nil 111 | } 112 | 113 | // StoreAccessToken in the access token directory 114 | func StoreAccessToken(accessToken string) error { 115 | home, err := os.UserHomeDir() 116 | if err != nil { 117 | return fmt.Errorf("failed to get user home directory: %w", err) 118 | } 119 | tokensFilePath := filepath.Join(home, spaceAuthTokenPath) 120 | t := &Token{AccessToken: accessToken} 121 | if err := storeAccessToken(t, tokensFilePath); err != nil { 122 | return err 123 | } 124 | return nil 125 | } 126 | 127 | // CalcSignatureInput input to CalcSignature function 128 | type CalcSignatureInput struct { 129 | AccessToken string 130 | HTTPMethod string 131 | URI string 132 | Timestamp string 133 | ContentType string 134 | RawBody []byte 135 | } 136 | 137 | // CalcSignature calculates the signature for signing the requests 138 | func CalcSignature(i *CalcSignatureInput) (string, error) { 139 | 140 | tokenParts := strings.Split(i.AccessToken, "_") 141 | if len(tokenParts) != 2 { 142 | return "", ErrInvalidAccessToken 143 | } 144 | accessKeyID := tokenParts[0] 145 | accessKeySecret := tokenParts[1] 146 | 147 | stringToSign := fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n", 148 | i.HTTPMethod, 149 | i.URI, 150 | i.Timestamp, 151 | i.ContentType, 152 | i.RawBody, 153 | ) 154 | 155 | mac := hmac.New(sha256.New, []byte(accessKeySecret)) 156 | _, err := mac.Write([]byte(stringToSign)) 157 | if err != nil { 158 | return "", fmt.Errorf("failed to calculate hmac: %w", err) 159 | } 160 | signature := mac.Sum(nil) 161 | hexSign := hex.EncodeToString(signature) 162 | 163 | return fmt.Sprintf("%s=%s:%s", spaceSignVersion, accessKeyID, hexSign), nil 164 | } 165 | 166 | type Keys map[string]string 167 | 168 | // GetProjectKey retrieves a project key storage or env var 169 | func GetProjectKey(projectId string) (string, error) { 170 | home, err := os.UserHomeDir() 171 | if err != nil { 172 | return "", nil 173 | } 174 | 175 | keysFilePath := filepath.Join(home, spaceProjectKeysPath) 176 | f, err := os.Open(keysFilePath) 177 | if err != nil && !os.IsNotExist(err) { 178 | return "", err 179 | } 180 | defer f.Close() 181 | 182 | var keys Keys 183 | contents, _ := ioutil.ReadAll(f) 184 | json.Unmarshal(contents, &keys) 185 | 186 | if key, ok := keys[projectId]; ok { 187 | return key, nil 188 | } 189 | 190 | return "", ErrNoProjectKeyFound 191 | } 192 | 193 | func StoreProjectKey(projectId string, projectKey string) error { 194 | home, err := os.UserHomeDir() 195 | if err != nil { 196 | return err 197 | } 198 | 199 | spaceDirPath := filepath.Join(home, spaceDir) 200 | err = os.MkdirAll(spaceDirPath, 0760) 201 | if err != nil { 202 | return err 203 | } 204 | 205 | keys := make(map[string]interface{}) 206 | keys[projectId] = projectKey 207 | 208 | marshalled, err := json.Marshal(keys) 209 | if err != nil { 210 | return err 211 | } 212 | 213 | keysFilePath := filepath.Join(home, spaceProjectKeysPath) 214 | err = os.WriteFile(keysFilePath, marshalled, 0660) 215 | if err != nil { 216 | return err 217 | } 218 | return nil 219 | } 220 | -------------------------------------------------------------------------------- /internal/auth/auth_test.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | func TestSignature(t *testing.T) { 9 | testCases := []struct { 10 | input *CalcSignatureInput 11 | expected string 12 | }{ 13 | { 14 | input: &CalcSignatureInput{ 15 | // This is a dummy access token, don't worry 16 | AccessToken: "xkcfKpsU_zwDNmNSqG9TGEiR8sSm8HVrSWuJ31b4d", 17 | URI: "/api/v0/space", 18 | Timestamp: "1681294911", 19 | HTTPMethod: "GET", 20 | RawBody: nil, 21 | }, 22 | expected: "v0=xkcfKpsU:120071dd2ce1ca9cc08f76efe4e3b01179ef76ab20e6ad4655b79abdf995146b", 23 | }, 24 | } 25 | 26 | for i, tc := range testCases { 27 | t.Run(fmt.Sprintf("signature %d", i), func(t *testing.T) { 28 | actual, err := CalcSignature(tc.input) 29 | if err != nil { 30 | t.Errorf("expected no error, actual: %s", err) 31 | } 32 | 33 | if actual != tc.expected { 34 | t.Errorf("expected: %s, actual: %s", tc.expected, actual) 35 | } 36 | }) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /internal/discovery/discovery.go: -------------------------------------------------------------------------------- 1 | package discovery 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "strings" 8 | 9 | "github.com/deta/space/shared" 10 | "gopkg.in/yaml.v2" 11 | ) 12 | 13 | const ( 14 | // DiscoveryFilename discovery filename 15 | DiscoveryFilename = "Discovery.md" 16 | ) 17 | 18 | var ( 19 | // ErrDiscoveryFileNotFound dicovery file not found 20 | ErrDiscoveryFileNotFound = errors.New("discovery file not found") 21 | // ErrDiscoveryFileWrongCase discovery file wrong case 22 | ErrDiscoveryFileWrongCase = errors.New("discovery file wrong case") 23 | ) 24 | 25 | func checkDiscoveryFileCase(sourceDir string) (string, bool, error) { 26 | files, err := os.ReadDir(sourceDir) 27 | if err != nil { 28 | return "", false, err 29 | } 30 | for _, f := range files { 31 | if strings.ToLower(f.Name()) == strings.ToLower(DiscoveryFilename) { 32 | if f.Name() != DiscoveryFilename { 33 | return f.Name(), false, nil 34 | } 35 | return f.Name(), true, nil 36 | } 37 | } 38 | return "", false, ErrDiscoveryFileNotFound 39 | } 40 | 41 | func CreateDiscoveryFile(name string, discovery shared.DiscoveryData) error { 42 | f, err := os.Create(name) 43 | if err != nil { 44 | f.Close() 45 | return err 46 | } 47 | 48 | js, _ := yaml.Marshal(discovery) 49 | fmt.Fprintln(f, "---") 50 | fmt.Fprint(f, string(js)) 51 | fmt.Fprintln(f, "---") 52 | fmt.Fprintln(f) 53 | fmt.Fprintln(f, discovery.ContentRaw) 54 | 55 | err = f.Close() 56 | if err != nil { 57 | return err 58 | } 59 | 60 | return nil 61 | } 62 | -------------------------------------------------------------------------------- /internal/discovery/screenshots.go: -------------------------------------------------------------------------------- 1 | package discovery 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "mime" 7 | "net/url" 8 | "os" 9 | "path/filepath" 10 | ) 11 | 12 | var ( 13 | // ErrInvalidMediaPath cannot find media path 14 | ErrInvalidMediaPath = errors.New("invalid media path") 15 | ) 16 | 17 | // Screenshot xx 18 | type Screenshot struct { 19 | Raw []byte `json:"screenshot"` 20 | ContentType string `json:"content_type"` 21 | } 22 | 23 | func ParseScreenshot(paths []string) ([]Screenshot, error) { 24 | screenshots := make([]Screenshot, 0) 25 | 26 | for _, path := range paths { 27 | screenshot := Screenshot{} 28 | if isValidVideoURL(path) { 29 | screenshot.Raw = []byte(path) 30 | screenshot.ContentType = "text/plain" 31 | } else { 32 | absPath, err := filepath.Abs(path) 33 | if err != nil { 34 | return screenshots, ErrInvalidMediaPath 35 | } 36 | 37 | isdir, err := isDir(&absPath) 38 | if err != nil { 39 | return screenshots, err 40 | } 41 | 42 | if isdir { 43 | // get file names in the directory 44 | inFiles, err := getFilesInDirectory(absPath) 45 | if err != nil { 46 | return screenshots, err 47 | } 48 | 49 | // recursive call 50 | res, err := ParseScreenshot(inFiles) 51 | if err != nil { 52 | return screenshots, err 53 | } 54 | return res, nil 55 | } 56 | 57 | file, err := os.Open(absPath) 58 | if err != nil { 59 | return screenshots, ErrInvalidMediaPath 60 | } 61 | defer file.Close() 62 | content, err := io.ReadAll(file) 63 | if err != nil { 64 | return screenshots, err 65 | } 66 | screenshot.Raw = content 67 | 68 | ext := filepath.Ext(absPath) 69 | screenshot.ContentType = mime.TypeByExtension(ext) 70 | } 71 | screenshots = append(screenshots, screenshot) 72 | } 73 | 74 | return screenshots, nil 75 | } 76 | 77 | // getFilesInDirectory xx 78 | func getFilesInDirectory(directoryPath string) ([]string, error) { 79 | var filePaths []string 80 | 81 | files, err := os.ReadDir(directoryPath) 82 | if err != nil { 83 | return nil, ErrInvalidMediaPath 84 | } 85 | 86 | for _, file := range files { 87 | filePath := filepath.Join(directoryPath, file.Name()) 88 | if file.IsDir() { 89 | continue 90 | } 91 | filePaths = append(filePaths, filePath) 92 | } 93 | 94 | return filePaths, nil 95 | } 96 | 97 | // isDir xx 98 | func isDir(path *string) (bool, error) { 99 | fileInfo, err := os.Stat(*path) 100 | if err != nil { 101 | return false, ErrInvalidMediaPath 102 | } 103 | 104 | if fileInfo.IsDir() { 105 | return true, nil 106 | } 107 | return false, nil 108 | } 109 | 110 | // isValidVideoURL xx 111 | func isValidVideoURL(sURL string) bool { 112 | _, err := url.ParseRequestURI(sURL) 113 | if err != nil { 114 | return false 115 | } 116 | return true 117 | } 118 | -------------------------------------------------------------------------------- /internal/discovery/screenshots_test.go: -------------------------------------------------------------------------------- 1 | package discovery 2 | 3 | import ( 4 | // "errors" 5 | "testing" 6 | ) 7 | 8 | func TestImagesDir(t *testing.T) { 9 | 10 | } 11 | -------------------------------------------------------------------------------- /internal/discovery/testdata/images/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deta/space-cli/a7f5d050d8577b55c699a96228ba691038c6df27/internal/discovery/testdata/images/1.png -------------------------------------------------------------------------------- /internal/discovery/testdata/images/2.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deta/space-cli/a7f5d050d8577b55c699a96228ba691038c6df27/internal/discovery/testdata/images/2.jpeg -------------------------------------------------------------------------------- /internal/discovery/testdata/images/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deta/space-cli/a7f5d050d8577b55c699a96228ba691038c6df27/internal/discovery/testdata/images/3.png -------------------------------------------------------------------------------- /internal/discovery/testdata/images/4.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deta/space-cli/a7f5d050d8577b55c699a96228ba691038c6df27/internal/discovery/testdata/images/4.jpeg -------------------------------------------------------------------------------- /internal/discovery/testdata/images/5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deta/space-cli/a7f5d050d8577b55c699a96228ba691038c6df27/internal/discovery/testdata/images/5.png -------------------------------------------------------------------------------- /internal/discovery/testdata/images/6.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deta/space-cli/a7f5d050d8577b55c699a96228ba691038c6df27/internal/discovery/testdata/images/6.webp -------------------------------------------------------------------------------- /internal/discovery/testdata/images/dir1/not_an_image: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deta/space-cli/a7f5d050d8577b55c699a96228ba691038c6df27/internal/discovery/testdata/images/dir1/not_an_image -------------------------------------------------------------------------------- /internal/discovery/testdata/images/dir2/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deta/space-cli/a7f5d050d8577b55c699a96228ba691038c6df27/internal/discovery/testdata/images/dir2/1.png -------------------------------------------------------------------------------- /internal/discovery/testdata/images/dir2/2.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deta/space-cli/a7f5d050d8577b55c699a96228ba691038c6df27/internal/discovery/testdata/images/dir2/2.jpeg -------------------------------------------------------------------------------- /internal/discovery/testdata/images/dir3/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deta/space-cli/a7f5d050d8577b55c699a96228ba691038c6df27/internal/discovery/testdata/images/dir3/3.png -------------------------------------------------------------------------------- /internal/discovery/testdata/images/dir3/not_an_image: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deta/space-cli/a7f5d050d8577b55c699a96228ba691038c6df27/internal/discovery/testdata/images/dir3/not_an_image -------------------------------------------------------------------------------- /internal/proxy/proxy.go: -------------------------------------------------------------------------------- 1 | package proxy 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "net/http/httputil" 9 | "net/url" 10 | "regexp" 11 | "strings" 12 | 13 | "github.com/deta/space/shared" 14 | ) 15 | 16 | const ( 17 | actionEndpoint = "/__space/actions" 18 | clientBaseEndpoint = "/__space/v0/base" 19 | clientDriveEndpoint = "/__space/v0/drive" 20 | ) 21 | 22 | const ( 23 | baseHost = "database.deta.sh" 24 | driveHost = "drive.deta.sh" 25 | ) 26 | 27 | type ProxyEndpoint struct { 28 | Micro shared.Micro 29 | Port int 30 | } 31 | 32 | type ActionMeta struct { 33 | Actions []DevAction `json:"actions"` 34 | } 35 | 36 | type DevAction struct { 37 | Name string `json:"name"` 38 | Title string `json:"title"` 39 | Path string `json:"path"` 40 | Input any `json:"input"` 41 | Output string `json:"output"` 42 | } 43 | 44 | type ProxyAction struct { 45 | Url string `json:"-"` 46 | InstanceID string `json:"instance_id"` 47 | InstanceAlias string `json:"instance_alias"` 48 | AppName string `json:"app_name"` 49 | Name string `json:"name"` 50 | Title string `json:"title"` 51 | Channel string `json:"channel"` 52 | Version string `json:"version"` 53 | Input any `json:"input,omitempty"` 54 | Output string `json:"output,omitempty"` 55 | } 56 | 57 | type ReverseProxy struct { 58 | appID string 59 | appName string 60 | instanceAlias string 61 | prefixToProxy map[string]*httputil.ReverseProxy 62 | actionMap map[string]ProxyAction 63 | projectKey string 64 | client *http.Client 65 | } 66 | 67 | func NewReverseProxy(projectKey string, appID string, appName string, instanceAlias string) *ReverseProxy { 68 | return &ReverseProxy{ 69 | appID: appID, 70 | appName: appName, 71 | instanceAlias: instanceAlias, 72 | prefixToProxy: make(map[string]*httputil.ReverseProxy), 73 | actionMap: make(map[string]ProxyAction), 74 | projectKey: projectKey, 75 | client: &http.Client{}, 76 | } 77 | } 78 | 79 | func (p *ReverseProxy) AddMicro(micro *shared.Micro, port int) (int, error) { 80 | prefix := extractPrefix(micro.Path) 81 | p.prefixToProxy[prefix] = httputil.NewSingleHostReverseProxy(&url.URL{ 82 | Scheme: "http", 83 | Host: fmt.Sprintf("localhost:%d", port), 84 | }) 85 | 86 | if !micro.ProvideActions { 87 | return 0, nil 88 | } 89 | 90 | res, err := http.Get(fmt.Sprintf("http://localhost:%d%s", port, actionEndpoint)) 91 | if err != nil { 92 | return 0, err 93 | } 94 | defer res.Body.Close() 95 | 96 | var actionMeta ActionMeta 97 | decoder := json.NewDecoder(res.Body) 98 | if err := decoder.Decode(&actionMeta); err != nil { 99 | return 0, err 100 | } 101 | 102 | for _, devAction := range actionMeta.Actions { 103 | if devAction.Output == "" { 104 | devAction.Output = "@deta/raw" 105 | } 106 | 107 | var target string 108 | if strings.HasPrefix(devAction.Path, "/") { 109 | target = fmt.Sprintf("http://localhost:%d%s", port, devAction.Path) 110 | } else { 111 | target = fmt.Sprintf("http://localhost:%d/%s", port, devAction.Path) 112 | } 113 | 114 | p.actionMap[devAction.Name] = ProxyAction{ 115 | Url: target, 116 | InstanceID: p.appID, 117 | InstanceAlias: p.instanceAlias, 118 | AppName: p.appName, 119 | Name: devAction.Name, 120 | Title: devAction.Title, 121 | Channel: "local", 122 | Version: "dev", 123 | Input: devAction.Input, 124 | Output: devAction.Output, 125 | } 126 | } 127 | return len(actionMeta.Actions), nil 128 | } 129 | 130 | func extractPrefix(path string) string { 131 | parts := strings.Split(path, "/") 132 | if len(parts) > 1 { 133 | return "/" + parts[1] 134 | } 135 | 136 | return "/" 137 | } 138 | 139 | func (p *ReverseProxy) ServeClientSDKAuth(targetHost string, w http.ResponseWriter, r *http.Request) { 140 | newURL := *r.URL 141 | newURL.Host = targetHost 142 | newURL.Scheme = "https" 143 | newURL.Path = regexp.MustCompile("^/__space/v0/(drive|base)/v1/[^/]+"). 144 | ReplaceAllString(newURL.Path, "/v1/"+strings.Split(p.projectKey, "_")[0]) 145 | 146 | newReq, err := http.NewRequest(r.Method, newURL.String(), r.Body) 147 | if err != nil { 148 | http.Error(w, err.Error(), http.StatusInternalServerError) 149 | return 150 | } 151 | 152 | newReq.Header.Add("X-API-Key", p.projectKey) 153 | newReq.Header.Add("Content-Type", "application/json") 154 | resp, err := p.client.Do(newReq) 155 | if err != nil { 156 | http.Error(w, err.Error(), http.StatusInternalServerError) 157 | return 158 | } 159 | defer resp.Body.Close() 160 | 161 | for key, values := range resp.Header { 162 | for _, value := range values { 163 | w.Header().Add(key, value) 164 | } 165 | } 166 | w.WriteHeader(resp.StatusCode) 167 | io.Copy(w, resp.Body) 168 | } 169 | 170 | func (p *ReverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { 171 | if strings.HasPrefix(r.URL.Path, clientBaseEndpoint) { 172 | p.ServeClientSDKAuth(baseHost, w, r) 173 | return 174 | } 175 | if strings.HasPrefix(r.URL.Path, clientDriveEndpoint) { 176 | p.ServeClientSDKAuth(driveHost, w, r) 177 | return 178 | } 179 | 180 | if r.URL.Path == actionEndpoint { 181 | switch r.Method { 182 | case http.MethodOptions: 183 | w.Header().Set("Access-Control-Allow-Origin", "https://deta.space") 184 | w.Header().Set("Access-Control-Allow-Headers", "*") 185 | w.WriteHeader(http.StatusOK) 186 | return 187 | case http.MethodGet: 188 | var actions = make([]ProxyAction, 0, len(p.actionMap)) 189 | for _, action := range p.actionMap { 190 | actions = append(actions, action) 191 | } 192 | 193 | w.Header().Set("Content-Type", "application/json") 194 | w.Header().Set("Access-Control-Allow-Origin", "https://deta.space") 195 | w.Header().Set("Access-Control-Allow-Headers", "*") 196 | 197 | encoder := json.NewEncoder(w) 198 | if err := encoder.Encode(actions); err != nil { 199 | http.Error(w, err.Error(), http.StatusInternalServerError) 200 | return 201 | } 202 | 203 | return 204 | } 205 | } 206 | 207 | if strings.HasPrefix(r.URL.Path, actionEndpoint) { 208 | actionName := strings.TrimPrefix(r.URL.Path, actionEndpoint+"/") 209 | action, ok := p.actionMap[actionName] 210 | if !ok { 211 | http.NotFound(w, r) 212 | return 213 | } 214 | switch r.Method { 215 | case http.MethodOptions: 216 | w.Header().Set("Access-Control-Allow-Origin", "https://deta.space") 217 | w.Header().Set("Access-Control-Allow-Headers", "*") 218 | w.WriteHeader(http.StatusOK) 219 | return 220 | case http.MethodGet: 221 | action, ok := p.actionMap[actionName] 222 | if !ok { 223 | http.NotFound(w, r) 224 | return 225 | } 226 | 227 | w.Header().Set("Content-Type", "application/json") 228 | w.Header().Set("Access-Control-Allow-Origin", "https://deta.space") 229 | w.Header().Set("Access-Control-Allow-Headers", "*") 230 | 231 | encoder := json.NewEncoder(w) 232 | if err := encoder.Encode(action); err != nil { 233 | http.Error(w, err.Error(), http.StatusInternalServerError) 234 | } 235 | 236 | return 237 | case http.MethodPost: 238 | resp, err := http.Post(action.Url, "application/json", r.Body) 239 | if err != nil { 240 | http.Error(w, err.Error(), http.StatusInternalServerError) 241 | return 242 | } 243 | defer resp.Body.Close() 244 | 245 | body, err := io.ReadAll(resp.Body) 246 | if err != nil { 247 | http.Error(w, err.Error(), http.StatusInternalServerError) 248 | return 249 | } 250 | 251 | var data any 252 | if err := json.Unmarshal(body, &data); err != nil { 253 | data = string(body) 254 | } 255 | 256 | payload := map[string]interface{}{ 257 | "type": action.Output, 258 | "data": data, 259 | } 260 | 261 | w.Header().Set("Content-Type", "application/json") 262 | w.Header().Set("Access-Control-Allow-Origin", "https://deta.space") 263 | w.Header().Set("Access-Control-Allow-Headers", "*") 264 | 265 | encoder := json.NewEncoder(w) 266 | if err := encoder.Encode(payload); err != nil { 267 | http.Error(w, err.Error(), http.StatusInternalServerError) 268 | return 269 | } 270 | 271 | return 272 | default: 273 | http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 274 | return 275 | } 276 | } 277 | 278 | prefix := extractPrefix(r.URL.Path) 279 | if proxy, ok := p.prefixToProxy[prefix]; ok { 280 | if prefix != "/" { 281 | r.URL.Path = strings.TrimPrefix(r.URL.Path, prefix) 282 | } 283 | proxy.ServeHTTP(w, r) 284 | return 285 | } 286 | 287 | fallback, ok := p.prefixToProxy["/"] 288 | if ok { 289 | fallback.ServeHTTP(w, r) 290 | return 291 | } 292 | } 293 | -------------------------------------------------------------------------------- /internal/runtime/.spaceignore: -------------------------------------------------------------------------------- 1 | # space 2 | .space 3 | .spaceignore 4 | Discovery.md 5 | 6 | # version control 7 | .hg 8 | .git 9 | .gitmodules 10 | .gitignore 11 | .svn 12 | 13 | # build 14 | build 15 | dist 16 | .output/ 17 | 18 | # js frameworks 19 | .next 20 | .nuxt 21 | .svelte-kit 22 | .astro 23 | 24 | # node 25 | node_modules 26 | .npmignore 27 | .cache 28 | .yarn 29 | npm-debug.log* 30 | yarn-debug.log* 31 | yarn-error.log* 32 | lerna-debug.log* 33 | .pnpm-debug.log* 34 | 35 | # python 36 | .venv 37 | venv 38 | virtualenv 39 | __pycache__ 40 | 41 | # rust 42 | target 43 | 44 | # coverage 45 | *.lcov 46 | .nyc_output 47 | .coverage 48 | .coverage.* 49 | 50 | # docker 51 | .dockerignore 52 | 53 | # env 54 | .env.local 55 | .env.*.local 56 | .env 57 | .envrc 58 | 59 | # ide 60 | .*.swp 61 | .vscode 62 | .history 63 | 64 | # system 65 | .DS_Store 66 | 67 | # other 68 | .lock-wscript 69 | config.gypi 70 | CVS 71 | -------------------------------------------------------------------------------- /internal/runtime/ignore_test.go: -------------------------------------------------------------------------------- 1 | package runtime 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | ignore "github.com/sabhiram/go-gitignore" 8 | ) 9 | 10 | var files = map[string]bool{ 11 | ".git": true, 12 | ".env": true, 13 | ".envrc": true, 14 | "venv": true, 15 | "virtualenv": true, 16 | "venv/main.py": true, 17 | "node_modules/index.js": true, 18 | "folder/.env": true, 19 | "main.py": false, 20 | } 21 | 22 | func TestIgnorePatterns(t *testing.T) { 23 | lines := strings.Split(defaultSpaceignore, "\n") 24 | 25 | spaceignore := ignore.CompileIgnoreLines(lines...) 26 | 27 | for file, shouldBeIgnored := range files { 28 | if spaceignore.MatchesPath(file) != shouldBeIgnored { 29 | t.Fatalf("expected %s to be ignored: %t", file, shouldBeIgnored) 30 | } 31 | 32 | } 33 | } 34 | 35 | func TestAddNewPattern(t *testing.T) { 36 | lines := strings.Split(defaultSpaceignore, "\n") 37 | lines = append(lines, "main.py") 38 | spaceignore := ignore.CompileIgnoreLines(lines...) 39 | 40 | if !spaceignore.MatchesPath("main.py") { 41 | t.Fatalf("expected main.py to not be ignored") 42 | } 43 | } 44 | 45 | func TestOverrideExistingPattern(t *testing.T) { 46 | lines := strings.Split(defaultSpaceignore, "\n") 47 | lines = append(lines, "!.env") 48 | spaceignore := ignore.CompileIgnoreLines(lines...) 49 | 50 | if spaceignore.MatchesPath(".env") { 51 | t.Fatalf("expected venv/main.py to not be ignored") 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /internal/runtime/manager.go: -------------------------------------------------------------------------------- 1 | package runtime 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "io/ioutil" 7 | "os" 8 | "path/filepath" 9 | ) 10 | 11 | const ( 12 | // drwxrw---- 13 | dirPermMode = 0760 14 | // -rw-rw--- 15 | filePermMode = 0660 16 | ) 17 | 18 | var ( 19 | PythonSkipPattern = `__pycache__` 20 | 21 | NodeSkipPattern = `node_modules` 22 | 23 | spaceDir = ".space" 24 | projectMetaFile = "meta" 25 | ) 26 | 27 | // StoreProjectMeta stores project meta to disk 28 | func StoreProjectMeta(projectDir string, p *ProjectMeta) error { 29 | if _, err := os.Stat(filepath.Join(projectDir, spaceDir)); os.IsNotExist(err) { 30 | err = os.Mkdir(filepath.Join(projectDir, spaceDir), dirPermMode) 31 | if err != nil { 32 | return err 33 | } 34 | } 35 | marshalled, err := json.Marshal(p) 36 | if err != nil { 37 | return err 38 | } 39 | 40 | spaceReadmeNotes := "Don't commit this folder (.space) to git as it may contain security-sensitive data." 41 | ioutil.WriteFile(filepath.Join(projectDir, spaceDir, "README"), []byte(spaceReadmeNotes), filePermMode) 42 | 43 | // Add a .gitignore file so .space is not committed to git 44 | ioutil.WriteFile(filepath.Join(projectDir, spaceDir, ".gitignore"), []byte("*\n"), filePermMode) 45 | 46 | return ioutil.WriteFile(filepath.Join(projectDir, spaceDir, projectMetaFile), marshalled, filePermMode) 47 | } 48 | 49 | func GetProjectID(projectDir string) (string, error) { 50 | projectMeta, err := GetProjectMeta(projectDir) 51 | if err != nil { 52 | return "", err 53 | } 54 | return projectMeta.ID, nil 55 | } 56 | 57 | // GetProjectMeta gets the project info stored 58 | func GetProjectMeta(projectDir string) (*ProjectMeta, error) { 59 | contents, err := os.ReadFile(filepath.Join(projectDir, spaceDir, projectMetaFile)) 60 | if err != nil { 61 | if errors.Is(err, os.ErrNotExist) { 62 | return nil, err 63 | } 64 | return nil, err 65 | } 66 | 67 | projectMeta, err := projectMetaFromBytes(contents) 68 | if err != nil { 69 | return nil, err 70 | } 71 | 72 | return projectMeta, nil 73 | } 74 | 75 | func IsProjectInitialized(projectDir string) (bool, error) { 76 | _, err := os.Stat(filepath.Join(projectDir, spaceDir, projectMetaFile)) 77 | if err != nil { 78 | if os.IsNotExist(err) { 79 | return false, nil 80 | } 81 | return false, err 82 | } 83 | return true, nil 84 | } 85 | -------------------------------------------------------------------------------- /internal/runtime/meta.go: -------------------------------------------------------------------------------- 1 | package runtime 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "path/filepath" 7 | "time" 8 | ) 9 | 10 | const ( 11 | spaceVersionPath = ".detaspace/space_latest_version" 12 | ) 13 | 14 | // ProjectMeta xx 15 | type ProjectMeta struct { 16 | ID string `json:"id"` 17 | Name string `json:"name"` 18 | Alias string `json:"alias"` 19 | } 20 | 21 | // unmarshals data into a ProjectMeta 22 | func projectMetaFromBytes(data []byte) (*ProjectMeta, error) { 23 | var p ProjectMeta 24 | err := json.Unmarshal(data, &p) 25 | if err != nil { 26 | return nil, err 27 | } 28 | return &p, nil 29 | } 30 | 31 | type Version struct { 32 | Version string `json:"version"` 33 | UpdatedAt int64 `json:"updatedAt"` 34 | } 35 | 36 | func CacheLatestVersion(version string) error { 37 | home, err := os.UserHomeDir() 38 | if err != nil { 39 | return err 40 | } 41 | 42 | spaceDirPath := filepath.Join(home, spaceDir) 43 | err = os.MkdirAll(spaceDirPath, 0760) 44 | if err != nil { 45 | return err 46 | } 47 | 48 | versionsFilePath := filepath.Join(home, spaceVersionPath) 49 | content, err := json.Marshal(Version{ 50 | Version: version, 51 | UpdatedAt: int64(time.Now().Unix()), 52 | }) 53 | if err != nil { 54 | return err 55 | } 56 | 57 | os.WriteFile(versionsFilePath, content, 0644) 58 | if err != nil { 59 | return err 60 | } 61 | 62 | return nil 63 | } 64 | 65 | func GetLatestCachedVersion() (string, time.Time, error) { 66 | home, err := os.UserHomeDir() 67 | if err != nil { 68 | return "", time.Time{}, err 69 | } 70 | 71 | versionsFilePath := filepath.Join(home, spaceVersionPath) 72 | content, err := os.ReadFile(versionsFilePath) 73 | if err != nil { 74 | return "", time.Time{}, err 75 | } 76 | 77 | var version Version 78 | if err = json.Unmarshal(content, &version); err != nil { 79 | return "", time.Time{}, err 80 | } 81 | 82 | return version.Version, time.Unix(version.UpdatedAt, 0), nil 83 | } 84 | -------------------------------------------------------------------------------- /internal/runtime/zip.go: -------------------------------------------------------------------------------- 1 | package runtime 2 | 3 | import ( 4 | "archive/zip" 5 | "bytes" 6 | _ "embed" 7 | "fmt" 8 | "io" 9 | "os" 10 | "path/filepath" 11 | "strings" 12 | 13 | ignore "github.com/sabhiram/go-gitignore" 14 | ) 15 | 16 | var ( 17 | spaceignoreFile = ".spaceignore" 18 | ) 19 | 20 | //go:embed .spaceignore 21 | var defaultSpaceignore string 22 | 23 | func ZipDir(sourceDir string) ([]byte, int, error) { 24 | absDir, err := filepath.Abs(sourceDir) 25 | if err != nil { 26 | return nil, 0, fmt.Errorf("failed to resolve absolute path for dir %s to zip, %w", sourceDir, err) 27 | } 28 | 29 | // check if dir exists 30 | if stat, err := os.Stat(absDir); err != nil && stat.IsDir() { 31 | if os.IsNotExist(err) { 32 | return nil, 0, fmt.Errorf("source dir %s not found, %w", absDir, err) 33 | } 34 | } 35 | 36 | lines := strings.Split(string(defaultSpaceignore), "\n") 37 | spaceIgnorePath := filepath.Join(sourceDir, spaceignoreFile) 38 | if _, err := os.Stat(spaceIgnorePath); err == nil { 39 | bytes, err := os.ReadFile(spaceIgnorePath) 40 | if err != nil { 41 | return nil, 0, fmt.Errorf("failed to read .spaceignore: %w", err) 42 | } 43 | lines = append(lines, strings.Split(string(bytes), "\n")...) 44 | } 45 | 46 | spaceignore := ignore.CompileIgnoreLines(lines...) 47 | 48 | files := make(map[string][]byte) 49 | // go through the dir and read all the files 50 | err = filepath.Walk(absDir, func(path string, info os.FileInfo, err error) error { 51 | if err != nil { 52 | return err 53 | } 54 | if path == absDir { 55 | return nil 56 | } 57 | 58 | // matching against the the `TrimPrefix`ed transformation so that you can have your 59 | // project in a folder called `dist`, for example. 60 | // skip if shouldSkip according to skipPaths which are derived from .spaceignore 61 | shouldSkip := spaceignore.MatchesPath(strings.TrimPrefix(path, absDir+"/")) 62 | if shouldSkip && info.IsDir() { 63 | return filepath.SkipDir 64 | } 65 | 66 | if shouldSkip { 67 | return nil 68 | } 69 | 70 | if info.IsDir() { 71 | return nil 72 | } 73 | 74 | absPath, err := filepath.Abs(path) 75 | if err != nil { 76 | return err 77 | } 78 | 79 | // relative path of file from absolute locations of dir and path 80 | relPath, err := filepath.Rel(absDir, absPath) 81 | if err != nil { 82 | return err 83 | } 84 | 85 | // ensures to use forward slashes 86 | relPath = filepath.ToSlash(relPath) 87 | 88 | f, e := os.Open(path) 89 | if e != nil { 90 | return e 91 | } 92 | defer f.Close() 93 | contents, e := io.ReadAll(f) 94 | if e != nil { 95 | return e 96 | } 97 | 98 | files[relPath] = contents 99 | return nil 100 | }) 101 | if err != nil { 102 | return nil, 0, fmt.Errorf("cannot scan contents of dir %s to zip, %w", sourceDir, err) 103 | } 104 | 105 | buf := new(bytes.Buffer) 106 | w := zip.NewWriter(buf) 107 | 108 | filenames := make([]string, 0, len(files)) 109 | for name, content := range files { 110 | filenames = append(filenames, name) 111 | f, err := w.Create(name) 112 | if err != nil { 113 | return nil, 0, fmt.Errorf("cannot compress file %s of dir %s, %w", name, sourceDir, err) 114 | } 115 | _, err = f.Write(content) 116 | if err != nil { 117 | return nil, 0, fmt.Errorf("cannot compress file %s of dir %s, %w", name, sourceDir, err) 118 | } 119 | } 120 | 121 | err = w.Close() 122 | if err != nil { 123 | return nil, 0, fmt.Errorf("cannot close zip writer for dir %s, %w", sourceDir, err) 124 | } 125 | 126 | return buf.Bytes(), len(filenames), nil 127 | } 128 | -------------------------------------------------------------------------------- /internal/spacefile/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deta/space-cli/a7f5d050d8577b55c699a96228ba691038c6df27/internal/spacefile/.DS_Store -------------------------------------------------------------------------------- /internal/spacefile/icon.go: -------------------------------------------------------------------------------- 1 | package spacefile 2 | 3 | import ( 4 | "errors" 5 | "image" 6 | _ "image/gif" 7 | _ "image/jpeg" 8 | _ "image/png" 9 | "mime" 10 | "os" 11 | "path/filepath" 12 | ) 13 | 14 | var ( 15 | // ErrInvalidIconPath cannot find icon path 16 | ErrInvalidIconPath = errors.New("invalid icon path") 17 | 18 | // ErrInvalidIconType 19 | ErrInvalidIconType = errors.New("invalid icon type") 20 | 21 | // ErrInvalidIconSize 22 | ErrInvalidIconSize = errors.New("invalid icon size") 23 | 24 | // MaxIconWidth 25 | MaxIconWidth = 512 26 | 27 | // MaxIconHeight 28 | MaxIconHeight = 512 29 | 30 | // MaxIconSize 31 | MaxIconSize = MaxIconHeight * MaxIconWidth 32 | ) 33 | 34 | // IconMeta xx 35 | type IconMeta struct { 36 | ContentType string `json:"content_type"` 37 | Width int `json:"width"` 38 | Height int `json:"height"` 39 | } 40 | 41 | // Icon xx 42 | type Icon struct { 43 | Raw []byte `json:"icon"` 44 | IconMeta *IconMeta `json:"icon_meta"` 45 | } 46 | 47 | // ValidateSpacefileIcon validate spacefile icon 48 | func ValidateIcon(iconPath string) error { 49 | iconMeta, err := getIconMeta(iconPath) 50 | if err != nil { 51 | return err 52 | } 53 | 54 | if iconMeta.ContentType != "image/png" && iconMeta.ContentType != "image/webp" { 55 | return ErrInvalidIconType 56 | } 57 | 58 | if iconMeta.Height != MaxIconHeight && iconMeta.Width != MaxIconWidth { 59 | return ErrInvalidIconSize 60 | } 61 | 62 | return nil 63 | } 64 | 65 | func getIconMeta(iconPath string) (*IconMeta, error) { 66 | abs, _ := filepath.Abs(iconPath) 67 | imgFile, err := os.Open(abs) 68 | if err != nil { 69 | return nil, ErrInvalidIconPath 70 | } 71 | defer imgFile.Close() 72 | 73 | imgMeta, imgType, err := image.DecodeConfig(imgFile) 74 | if err != nil { 75 | if errors.Is(image.ErrFormat, err) { 76 | return nil, ErrInvalidIconType 77 | } 78 | return nil, ErrInvalidIconPath 79 | } 80 | 81 | return &IconMeta{ 82 | Width: imgMeta.Width, 83 | Height: imgMeta.Height, 84 | ContentType: mime.TypeByExtension("." + imgType), 85 | }, nil 86 | } 87 | -------------------------------------------------------------------------------- /internal/spacefile/icon_test.go: -------------------------------------------------------------------------------- 1 | package spacefile 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | ) 7 | 8 | func TestIconDoesNotExists(t *testing.T) { 9 | iconPath := "./testdata/icons/does-not-exists.png" 10 | err := ValidateIcon(iconPath) 11 | if !errors.Is(err, ErrInvalidIconPath) { 12 | t.Fatalf("expected error %v but got %v", ErrInvalidIconPath, err) 13 | } 14 | } 15 | 16 | func TestIconInvalidSize(t *testing.T) { 17 | iconPath := "./testdata/icons/size-128.png" 18 | err := ValidateIcon(iconPath) 19 | if !errors.Is(err, ErrInvalidIconSize) { 20 | t.Fatalf("expected error %v but got %v", ErrInvalidIconPath, err) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /internal/spacefile/schemas/spacefile.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "$id": "https://deta.space/assets/spacefile.schema.json", 4 | "$ref": "#/definitions/spacefile", 5 | "definitions": { 6 | "spacefile": { 7 | "title": "Spacefile", 8 | "description": "Space app configuration", 9 | "additionalProperties": false, 10 | "type": "object", 11 | "properties": { 12 | "v": { 13 | "description": "Version number of the Spacefile", 14 | "type": "integer", 15 | "enum": [ 16 | 0 17 | ], 18 | "default": 0 19 | }, 20 | "icon": { 21 | "description": "Path to an icon image file (PNG or WebP file of 512x512 pixels)", 22 | "type": "string" 23 | }, 24 | "app_name": { 25 | "description": "Display name of the app", 26 | "type": "string", 27 | "maxLength": 12 28 | }, 29 | "auto_pwa": { 30 | "description": "Turn the application into a PWA by default", 31 | "type": "boolean", 32 | "default": true 33 | }, 34 | "micros": { 35 | "description": "List of Micros in the app", 36 | "type": "array", 37 | "minItems": 1, 38 | "maxItems": 5, 39 | "items": { 40 | "$ref": "#/definitions/micro" 41 | } 42 | } 43 | }, 44 | "required": [ 45 | "v", 46 | "micros" 47 | ] 48 | }, 49 | "micro": { 50 | "title": "Micro", 51 | "description": "Micro configuration", 52 | "type": "object", 53 | "additionalProperties": false, 54 | "properties": { 55 | "name": { 56 | "description": "Name of the Micro", 57 | "type": "string", 58 | "pattern": "^[A-Za-z0-9][A-Za-z0-9_-]*$" 59 | }, 60 | "src": { 61 | "description": "Relative path to the root directory of the Micro", 62 | "type": "string" 63 | }, 64 | "engine": { 65 | "description": "Runtime engine for the Micro", 66 | "type": "string", 67 | "enum": [ 68 | "static", 69 | "react", 70 | "svelte", 71 | "vue", 72 | "next", 73 | "nuxt", 74 | "svelte-kit", 75 | "python3.9", 76 | "python3.8", 77 | "nodejs16", 78 | "custom" 79 | ] 80 | }, 81 | "primary": { 82 | "description": "If the Micro should be the entry point for the app", 83 | "type": "boolean", 84 | "default": true 85 | }, 86 | "path": { 87 | "description": "Path relative to the hostname this Micro should receive requests on", 88 | "type": "string" 89 | }, 90 | "serve": { 91 | "description": "Directory path relative to the Micro's path that should be served for the static Micro", 92 | "type": "string" 93 | }, 94 | "commands": { 95 | "description": "Commands to run before packaging the Micro", 96 | "type": "array", 97 | "minItems": 1, 98 | "items": { 99 | "type": "string" 100 | } 101 | }, 102 | "include": { 103 | "description": "Files and directories in the Micro's source directory that should be part of the final app package", 104 | "type": "array", 105 | "minItems": 1, 106 | "uniqueItems": true, 107 | "items": { 108 | "type": "string" 109 | } 110 | }, 111 | "run": { 112 | "description": "Command to start the Micro", 113 | "type": "string" 114 | }, 115 | "dev": { 116 | "description": "Command to start the Micro in development mode", 117 | "type": "string" 118 | }, 119 | "presets": { 120 | "$ref": "#/definitions/presets" 121 | }, 122 | "public_routes": { 123 | "description": "Routes that will be available publicly", 124 | "type": "array", 125 | "minItems": 1, 126 | "uniqueItems": true, 127 | "items": { 128 | "type": "string", 129 | "minLength": 1 130 | } 131 | }, 132 | "public": { 133 | "description": "If the Micro should be available to the public", 134 | "type": "boolean", 135 | "default": false 136 | }, 137 | "provide_actions": { 138 | "type": "boolean", 139 | "default": false 140 | }, 141 | "actions": { 142 | "description": "Tasks that run on triggers like a schedule", 143 | "type": "array", 144 | "minItems": 1, 145 | "items": { 146 | "$ref": "#/definitions/action" 147 | } 148 | } 149 | }, 150 | "required": [ 151 | "name", 152 | "src", 153 | "engine" 154 | ], 155 | "allOf": [ 156 | { 157 | "$comment": "If engine is static, then serve is required", 158 | "if": { 159 | "properties": { 160 | "engine": { 161 | "const": "static" 162 | } 163 | }, 164 | "required": [ 165 | "engine" 166 | ] 167 | }, 168 | "then": { 169 | "required": [ 170 | "serve" 171 | ] 172 | } 173 | }, 174 | { 175 | "$comment": "public and public_routes are mutually exclusive", 176 | "not": { 177 | "required": [ 178 | "public", 179 | "public_routes" 180 | ] 181 | } 182 | }, 183 | { 184 | "$comment": "If engine is static or static-like, then include is not allowed, otherwise serve is not allowed", 185 | "if": { 186 | "properties": { 187 | "engine": { 188 | "enum": [ 189 | "static", 190 | "react", 191 | "vue", 192 | "svelte" 193 | ] 194 | } 195 | }, 196 | "required": [ 197 | "engine" 198 | ] 199 | }, 200 | "then": { 201 | "not": { 202 | "required": [ 203 | "include" 204 | ] 205 | } 206 | }, 207 | "else": { 208 | "not": { 209 | "required": [ 210 | "serve" 211 | ] 212 | } 213 | } 214 | } 215 | ] 216 | }, 217 | "presets": { 218 | "title": "Presets", 219 | "description": "Presets to use for the Micro", 220 | "type": "object", 221 | "additionalProperties": false, 222 | "properties": { 223 | "env": { 224 | "description": "Environment variables that the user can set for a Micro", 225 | "type": "array", 226 | "minItems": 1, 227 | "items": { 228 | "$ref": "#/definitions/env" 229 | } 230 | }, 231 | "api_keys": { 232 | "description": "Enable the use of API keys to access private routes of a Micro", 233 | "type": "boolean" 234 | } 235 | } 236 | }, 237 | "env": { 238 | "title": "Env", 239 | "description": "Environment variable", 240 | "type": "object", 241 | "additionalProperties": false, 242 | "properties": { 243 | "name": { 244 | "description": "Name of the environment variable", 245 | "type": "string" 246 | }, 247 | "description": { 248 | "description": "Human readable description", 249 | "type": "string" 250 | }, 251 | "default": { 252 | "description": "Default value of the environment variable", 253 | "type": "string" 254 | } 255 | }, 256 | "required": [ 257 | "name" 258 | ] 259 | }, 260 | "action": { 261 | "title": "Action", 262 | "description": "Action configuration", 263 | "type": "object", 264 | "additionalProperties": false, 265 | "properties": { 266 | "id": { 267 | "description": "Unique identifier for the action (needs to be unique across the app)", 268 | "type": "string" 269 | }, 270 | "name": { 271 | "description": "Human readable name for the action (needs to be unique across the app)", 272 | "type": "string" 273 | }, 274 | "description": { 275 | "description": "Human readable description for the action", 276 | "type": "string", 277 | "maxLength": 142 278 | }, 279 | "trigger": { 280 | "description": "Trigger for the action", 281 | "type": "string", 282 | "enum": [ 283 | "schedule" 284 | ], 285 | "default": "schedule" 286 | }, 287 | "default_interval": { 288 | "description": "Interval at which the schedule will run", 289 | "type": "string" 290 | }, 291 | "path": { 292 | "description": "Path of the Micro that will handle the action request", 293 | "type": "string" 294 | } 295 | }, 296 | "required": [ 297 | "id", 298 | "name", 299 | "trigger", 300 | "default_interval" 301 | ] 302 | } 303 | } 304 | } 305 | -------------------------------------------------------------------------------- /internal/spacefile/spacefile_test.go: -------------------------------------------------------------------------------- 1 | package spacefile 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | ) 7 | 8 | type TestCase struct { 9 | projectDir string 10 | expectedError error 11 | } 12 | 13 | func TestValidation(t *testing.T) { 14 | cases := []TestCase{ 15 | { 16 | projectDir: "testdata/spacefile/duplicated_micros", 17 | expectedError: ErrDuplicateMicros, 18 | }, 19 | { 20 | projectDir: "testdata/spacefile/invalid_micro_path", 21 | expectedError: ErrSpacefileNotFound, 22 | }, 23 | { 24 | projectDir: "testdata/spacefile/multiple_primary", 25 | expectedError: ErrMultiplePrimary, 26 | }, 27 | { 28 | projectDir: "testdata/spacefile/no_primary", 29 | expectedError: ErrNoPrimaryMicro, 30 | }, 31 | { 32 | projectDir: "testdata/spacefile/single_micro", 33 | expectedError: nil, 34 | }, 35 | { 36 | projectDir: "testdata/spacefile/multiple_micros", 37 | expectedError: nil, 38 | }, 39 | } 40 | 41 | for _, c := range cases { 42 | t.Run(c.projectDir, func(t *testing.T) { 43 | _, err := LoadSpacefile(c.projectDir) 44 | 45 | if err != nil && c.expectedError == nil { 46 | t.Fatalf("expected no error but got: %v", err) 47 | } 48 | 49 | if err == nil && c.expectedError != nil { 50 | t.Fatalf("expected error but got none") 51 | } 52 | 53 | if !errors.Is(err, c.expectedError) { 54 | t.Fatalf("expected error to be %v but got %v", c.expectedError, err) 55 | } 56 | }) 57 | } 58 | } 59 | 60 | func TestImplicitPrimary(t *testing.T) { 61 | spacefile := "./testdata/spacefile/implicit_primary" 62 | space, err := LoadSpacefile(spacefile) 63 | if err != nil { 64 | t.Fatalf("failed to parse spacefile: %v", err) 65 | } 66 | 67 | if !space.Micros[0].Primary { 68 | t.Fatalf("expected primary to be true but got false") 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /internal/spacefile/testdata/icons/size-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deta/space-cli/a7f5d050d8577b55c699a96228ba691038c6df27/internal/spacefile/testdata/icons/size-128.png -------------------------------------------------------------------------------- /internal/spacefile/testdata/spacefile/duplicated_micros/Spacefile: -------------------------------------------------------------------------------- 1 | v: 0 2 | micros: 3 | - name: go-app 4 | src: . 5 | engine: custom 6 | commands: 7 | - go get 8 | - go build main.go 9 | include: 10 | - main 11 | - static 12 | run: ./main 13 | - name: go-app 14 | src: . 15 | engine: python3.9 16 | primary: true 17 | -------------------------------------------------------------------------------- /internal/spacefile/testdata/spacefile/implicit_primary/Spacefile: -------------------------------------------------------------------------------- 1 | # Spacefile Docs: https://go.deta.dev/docs/spacefile/v0 2 | v: 0 3 | micros: 4 | - name: go-app 5 | src: . 6 | engine: custom 7 | commands: 8 | - go get 9 | - go build main.go 10 | include: 11 | - main 12 | - static 13 | run: ./main 14 | -------------------------------------------------------------------------------- /internal/spacefile/testdata/spacefile/multiple_micros/Spacefile: -------------------------------------------------------------------------------- 1 | # Spacefile Docs: https://go.deta.dev/docs/spacefile/v0 2 | v: 0 3 | micros: 4 | - name: go-app 5 | src: . 6 | engine: custom 7 | commands: 8 | - go get 9 | - go build main.go 10 | include: 11 | - main 12 | - static 13 | run: ./main 14 | - name: python-app 15 | src: . 16 | engine: python3.9 17 | primary: true 18 | -------------------------------------------------------------------------------- /internal/spacefile/testdata/spacefile/multiple_primary/Spacefile: -------------------------------------------------------------------------------- 1 | v: 0 2 | micros: 3 | - name: go-app 4 | src: . 5 | engine: custom 6 | primary: true 7 | commands: 8 | - go get 9 | - go build main.go 10 | include: 11 | - main 12 | - static 13 | run: ./main 14 | - name: python-app 15 | src: . 16 | engine: python3.9 17 | primary: true 18 | -------------------------------------------------------------------------------- /internal/spacefile/testdata/spacefile/no_primary/Spacefile: -------------------------------------------------------------------------------- 1 | v: 0 2 | micros: 3 | - name: micro-1 4 | src: . 5 | engine: svelte 6 | - name: micro-2 7 | src: . 8 | engine: svelte 9 | -------------------------------------------------------------------------------- /internal/spacefile/testdata/spacefile/single_micro/Spacefile: -------------------------------------------------------------------------------- 1 | # Spacefile Docs: https://go.deta.dev/docs/spacefile/v0 2 | v: 0 3 | micros: 4 | - name: go-app 5 | src: . 6 | engine: custom 7 | primary: true 8 | commands: 9 | - go get 10 | - go build main.go 11 | include: 12 | - main 13 | - static 14 | run: ./main 15 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "github.com/deta/space/cmd/utils" 6 | "github.com/deta/space/pkg/components" 7 | "github.com/deta/space/pkg/components/emoji" 8 | "github.com/deta/space/pkg/components/styles" 9 | "os" 10 | 11 | "github.com/deta/space/cmd" 12 | ) 13 | 14 | func main() { 15 | if err := cmd.NewSpaceCmd().Execute(); err != nil { 16 | // user prompt cancellation is not an error 17 | if errors.Is(err, components.ErrPromptCancelled) { 18 | return 19 | } 20 | utils.StdErrLogger.Println(styles.Errorf("%s Error: %v", emoji.ErrorExclamation, err)) 21 | os.Exit(1) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /pkg/components/choose/choose.go: -------------------------------------------------------------------------------- 1 | package choose 2 | 3 | import ( 4 | "fmt" 5 | "github.com/deta/space/pkg/components" 6 | 7 | tea "github.com/charmbracelet/bubbletea" 8 | "github.com/deta/space/pkg/components/styles" 9 | ) 10 | 11 | type Model struct { 12 | Cursor int 13 | Chosen bool 14 | Cancelled bool 15 | Prompt string 16 | Choices []string 17 | } 18 | 19 | type Input struct { 20 | Prompt string 21 | Choices []string 22 | } 23 | 24 | func initialModel(i *Input) Model { 25 | return Model{ 26 | Cursor: 0, 27 | Chosen: false, 28 | Prompt: i.Prompt, 29 | Choices: i.Choices, 30 | } 31 | } 32 | 33 | func (m Model) Init() tea.Cmd { 34 | return nil 35 | } 36 | 37 | func (m Model) Selection() string { 38 | if m.Cursor >= len(m.Choices) { 39 | return "" 40 | } 41 | return m.Choices[m.Cursor] 42 | } 43 | 44 | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 45 | switch msg := msg.(type) { 46 | case tea.KeyMsg: 47 | switch msg.Type { 48 | case tea.KeyEnter: 49 | m.Chosen = true 50 | return m, tea.Quit 51 | case tea.KeyCtrlC: 52 | m.Cancelled = true 53 | return m, tea.Quit 54 | } 55 | } 56 | 57 | return updateChoices(msg, m) 58 | } 59 | 60 | func (m Model) View() string { 61 | if m.Chosen { 62 | return fmt.Sprintf("%s %s %s\n", styles.Question, styles.Bold(m.Prompt), m.Selection()) 63 | } 64 | 65 | tpl := fmt.Sprintf("%s %s \n", styles.Question, styles.Bold(m.Prompt)) 66 | tpl += "%s\n" 67 | choices := "" 68 | for i, choice := range m.Choices { 69 | choices += fmt.Sprintf("\n%s", RenderChoice(choice, m.Cursor == i)) 70 | } 71 | 72 | return fmt.Sprintf(tpl, choices) 73 | } 74 | 75 | // Update loop for the first view where you're choosing a task. 76 | func updateChoices(msg tea.Msg, m Model) (tea.Model, tea.Cmd) { 77 | switch msg := msg.(type) { 78 | 79 | case tea.KeyMsg: 80 | switch msg.String() { 81 | case "j", "down": 82 | m.Cursor += 1 83 | if m.Cursor >= len(m.Choices) { 84 | m.Cursor = 0 85 | } 86 | case "k", "up": 87 | m.Cursor -= 1 88 | if m.Cursor < 0 { 89 | m.Cursor = len(m.Choices) - 1 90 | } 91 | } 92 | } 93 | 94 | return m, nil 95 | } 96 | 97 | func RenderChoice(choice string, chosen bool) string { 98 | if chosen { 99 | return fmt.Sprintf("%s %s", styles.SelectTag, choice) 100 | } 101 | return fmt.Sprintf(" %s", choice) 102 | } 103 | 104 | func Run(prompt string, choices ...string) (string, error) { 105 | program := tea.NewProgram(initialModel(&Input{ 106 | Prompt: prompt, 107 | Choices: choices, 108 | })) 109 | 110 | m, err := program.Run() 111 | if err != nil { 112 | return "", err 113 | } 114 | 115 | model, ok := m.(Model) 116 | if !ok { 117 | return "", err 118 | } 119 | 120 | if model.Cancelled { 121 | return "", components.ErrPromptCancelled 122 | } 123 | 124 | return model.Selection(), nil 125 | } 126 | -------------------------------------------------------------------------------- /pkg/components/components.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import "errors" 4 | 5 | var ( 6 | // ErrPromptCancelled is returned when prompt is cancelled 7 | ErrPromptCancelled = errors.New("prompt cancelled") 8 | ) 9 | -------------------------------------------------------------------------------- /pkg/components/confirm/confirm.go: -------------------------------------------------------------------------------- 1 | package confirm 2 | 3 | import ( 4 | "fmt" 5 | "github.com/deta/space/pkg/components" 6 | 7 | tea "github.com/charmbracelet/bubbletea" 8 | "github.com/deta/space/pkg/components/styles" 9 | ) 10 | 11 | type Model struct { 12 | Prompt string 13 | Confirm bool 14 | Quitting bool 15 | Cancelled bool 16 | } 17 | 18 | type Input struct { 19 | Prompt string 20 | } 21 | 22 | func initialModel(input string) Model { 23 | return Model{ 24 | Prompt: input, 25 | } 26 | } 27 | 28 | func (m Model) Init() tea.Cmd { 29 | return nil 30 | } 31 | 32 | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 33 | var cmd tea.Cmd 34 | 35 | switch msg := msg.(type) { 36 | case tea.KeyMsg: 37 | switch msg.String() { 38 | case "y", "Y": 39 | m.Confirm = true 40 | m.Quitting = true 41 | return m, tea.Quit 42 | case "n", "N": 43 | m.Confirm = false 44 | m.Quitting = true 45 | return m, tea.Quit 46 | case "enter": 47 | m.Confirm = true 48 | m.Quitting = true 49 | return m, tea.Quit 50 | case "ctrl+c": 51 | m.Cancelled = true 52 | m.Quitting = true 53 | return m, tea.Quit 54 | } 55 | } 56 | return m, cmd 57 | } 58 | 59 | func (m Model) View() string { 60 | input := "(Y/n)" 61 | if m.Quitting && m.Confirm { 62 | input = "y" 63 | } else if m.Quitting && !m.Confirm { 64 | input = "n" 65 | } 66 | 67 | return fmt.Sprintf("%s %s %s\n", styles.Question, styles.Bold(m.Prompt), styles.Subtle(input)) 68 | } 69 | 70 | func Run(input string) (bool, error) { 71 | program := tea.NewProgram(initialModel(input)) 72 | 73 | m, err := program.Run() 74 | if err != nil { 75 | return false, err 76 | } 77 | 78 | model, ok := m.(Model) 79 | if !ok { 80 | return false, err 81 | } 82 | 83 | if model.Cancelled { 84 | return false, components.ErrPromptCancelled 85 | } 86 | return model.Confirm, nil 87 | } 88 | -------------------------------------------------------------------------------- /pkg/components/emoji/codes.go: -------------------------------------------------------------------------------- 1 | package emoji 2 | 3 | import ( 4 | "github.com/deta/space/pkg/components/styles" 5 | ) 6 | 7 | var ( 8 | Cowboy = Emoji{Emoji: "🤠 ", Fallback: ""} 9 | Laptop = Emoji{Emoji: "💻 ", Fallback: ""} 10 | Gear = Emoji{Emoji: "⚙️ ", Fallback: ""} 11 | PointDown = Emoji{Emoji: "👇 ", Fallback: ""} 12 | Link = Emoji{Emoji: "🔗 ", Fallback: ""} 13 | ErrorExclamation = Emoji{Emoji: "❗", Fallback: styles.ErrorExclamation} 14 | ThumbsUp = Emoji{Emoji: "👍 ", Fallback: styles.CheckMark} 15 | Check = Emoji{Emoji: styles.CheckMark, Fallback: styles.CheckMark} 16 | PartyPopper = Emoji{Emoji: "🎉 ", Fallback: styles.CheckMark} 17 | Rocket = Emoji{Emoji: "🚀 ", Fallback: ""} 18 | Earth = Emoji{Emoji: "🌍 ", Fallback: ""} 19 | PartyFace = Emoji{Emoji: "🥳 ", Fallback: ""} 20 | X = Emoji{Emoji: "❌ ", Fallback: styles.X} 21 | Waving = Emoji{Emoji: "👋 ", Fallback: ""} 22 | Swirl = Emoji{Emoji: "🌀 ", Fallback: ""} 23 | Sparkles = Emoji{Emoji: "✨ ", Fallback: styles.CheckMark} 24 | File = Emoji{Emoji: "📄 ", Fallback: ""} 25 | Files = Emoji{Emoji: "🗂️ ", Fallback: ""} 26 | Package = Emoji{Emoji: "📦 ", Fallback: styles.Boldf("~")} 27 | Eyes = Emoji{Emoji: "👀 ", Fallback: ""} 28 | Lightning = Emoji{Emoji: "⚡ ", Fallback: ""} 29 | LightBulb = Emoji{Emoji: "💡 ", Fallback: ""} 30 | Pistol = Emoji{Emoji: "🔫 ", Fallback: ""} 31 | Tools = Emoji{Emoji: "💻 ", Fallback: styles.Info} 32 | CrystalBall = Emoji{Emoji: "🔮 ", Fallback: ""} 33 | Label = Emoji{Emoji: "🏷️ ", Fallback: ""} 34 | Key = Emoji{Emoji: "🔑 ", Fallback: ""} 35 | ) 36 | -------------------------------------------------------------------------------- /pkg/components/emoji/emoji.go: -------------------------------------------------------------------------------- 1 | package emoji 2 | 3 | import ( 4 | "os" 5 | "runtime" 6 | "syscall" 7 | 8 | "golang.org/x/term" 9 | ) 10 | 11 | type Emoji struct { 12 | Emoji string 13 | Fallback string 14 | } 15 | 16 | func (e Emoji) String() string { 17 | 18 | if SupportsEmoji() { 19 | return e.Emoji 20 | } 21 | 22 | return e.Fallback 23 | } 24 | 25 | func SupportsEmoji() bool { 26 | 27 | if !term.IsTerminal(int(syscall.Stdout)) { 28 | return false 29 | } 30 | 31 | platform := runtime.GOOS 32 | switch platform { 33 | case "windows": 34 | _, isWindowsTerminal := os.LookupEnv("WT_SESSION") 35 | return isWindowsTerminal 36 | case "darwin": 37 | return true 38 | case "linux": 39 | return false 40 | } 41 | 42 | return false 43 | } 44 | -------------------------------------------------------------------------------- /pkg/components/styles/styles.go: -------------------------------------------------------------------------------- 1 | package styles 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/charmbracelet/lipgloss" 7 | ) 8 | 9 | var ( 10 | SubtleStyle = ColorStyle("#383838") 11 | GreenStyle = ColorStyle("#16E58A") 12 | BlueStyle = ColorStyle("#4D73E0") 13 | PinkStyle = ColorStyle("#F26DAA") 14 | ErrorStyle = ColorStyle("#FFA7A7") 15 | BoldStyle = lipgloss.NewStyle().Bold(true) 16 | ) 17 | 18 | func ColorStyle(str string) lipgloss.Style { 19 | return lipgloss.NewStyle().Foreground(lipgloss.Color(str)) 20 | } 21 | 22 | func Subtlef(str string, a ...interface{}) string { 23 | return SubtleStyle.Render(fmt.Sprintf(str, a...)) 24 | } 25 | 26 | func Subtle(str string) string { 27 | return SubtleStyle.Render(str) 28 | } 29 | 30 | func Greenf(str string, a ...interface{}) string { 31 | return GreenStyle.Render(fmt.Sprintf(str, a...)) 32 | } 33 | 34 | func Green(str string) string { 35 | return GreenStyle.Render(str) 36 | } 37 | 38 | func Bluef(str string, a ...interface{}) string { 39 | return BlueStyle.Render(fmt.Sprintf(str, a...)) 40 | } 41 | 42 | func Blue(str string) string { 43 | return BlueStyle.Render(str) 44 | } 45 | 46 | func Pinkf(str string, a ...interface{}) string { 47 | return PinkStyle.Render(fmt.Sprintf(str, a...)) 48 | } 49 | 50 | func Pink(str string) string { 51 | return PinkStyle.Render(str) 52 | } 53 | 54 | func Errorf(str string, a ...interface{}) string { 55 | return ErrorStyle.Render(fmt.Sprintf(str, a...)) 56 | } 57 | 58 | func Error(str string) string { 59 | return ErrorStyle.Render(str) 60 | } 61 | 62 | func Boldf(str string, a ...interface{}) string { 63 | return BoldStyle.Render(fmt.Sprintf(str, a...)) 64 | } 65 | 66 | func Bold(str string) string { 67 | return BoldStyle.Render(str) 68 | } 69 | 70 | func Codef(str string, a ...interface{}) string { 71 | return BoldStyle.Render(Bluef(str, a...)) 72 | } 73 | 74 | func Code(str string) string { 75 | return BoldStyle.Render(Blue(str)) 76 | } 77 | 78 | func Highlightf(str string, a ...interface{}) string { 79 | return BoldStyle.Background(PinkStyle.GetForeground()).Render(fmt.Sprintf(str, a...)) 80 | } 81 | 82 | func Highlight(str string) string { 83 | return BoldStyle.Background(PinkStyle.GetForeground()).Render(str) 84 | } 85 | 86 | var ( 87 | Question = BoldStyle.Render(Pink("?")) 88 | SelectTag = BoldStyle.Render(Pink(">")) 89 | CheckMark = BoldStyle.Render(Green("✓")) 90 | X = BoldStyle.Render(ErrorStyle.Render("x")) 91 | ErrorExclamation = BoldStyle.Render(ErrorStyle.Render("!")) 92 | Info = BoldStyle.Render(Blue("i")) 93 | ) 94 | -------------------------------------------------------------------------------- /pkg/components/text/text.go: -------------------------------------------------------------------------------- 1 | package text 2 | 3 | import ( 4 | "fmt" 5 | "github.com/deta/space/pkg/components" 6 | 7 | "github.com/charmbracelet/bubbles/textinput" 8 | tea "github.com/charmbracelet/bubbletea" 9 | "github.com/deta/space/pkg/components/styles" 10 | ) 11 | 12 | type Model struct { 13 | TextInput textinput.Model 14 | Cancelled bool 15 | Prompt string 16 | ValidationMsg string 17 | Validator func(value string) error 18 | } 19 | 20 | type Input struct { 21 | Prompt string 22 | Placeholder string 23 | Validator func(value string) error 24 | PasswordMode bool 25 | } 26 | 27 | func initialModel(i *Input) Model { 28 | ti := textinput.New() 29 | ti.Placeholder = i.Placeholder 30 | ti.Focus() 31 | if i.PasswordMode { 32 | ti.EchoMode = textinput.EchoPassword 33 | } 34 | 35 | return Model{ 36 | TextInput: ti, 37 | Prompt: i.Prompt, 38 | Validator: i.Validator, 39 | } 40 | } 41 | 42 | func (m Model) Init() tea.Cmd { 43 | return nil 44 | } 45 | 46 | func (m Model) Value() string { 47 | if m.TextInput.Value() == "" { 48 | return m.TextInput.Placeholder 49 | } 50 | return m.TextInput.Value() 51 | } 52 | 53 | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 54 | var cmd tea.Cmd 55 | 56 | switch msg := msg.(type) { 57 | case tea.KeyMsg: 58 | switch msg.Type { 59 | case tea.KeyEnter: 60 | if m.Validator != nil { 61 | value := m.TextInput.Value() 62 | if value == "" { 63 | value = m.TextInput.Placeholder 64 | } 65 | 66 | err := m.Validator(value) 67 | if err != nil { 68 | m.ValidationMsg = fmt.Sprintf("❗ Error: %s", err.Error()) 69 | return m, nil 70 | } 71 | m.ValidationMsg = "" 72 | } 73 | return m, tea.Quit 74 | 75 | case tea.KeyCtrlC: 76 | m.Cancelled = true 77 | return m, tea.Quit 78 | } 79 | } 80 | 81 | m.TextInput, cmd = m.TextInput.Update(msg) 82 | return m, cmd 83 | } 84 | 85 | func (m Model) View() string { 86 | var s string 87 | if m.TextInput.EchoMode == textinput.EchoPassword { 88 | s = fmt.Sprintf( 89 | "%s %s (%s) %s\n", 90 | styles.Question, 91 | styles.Bold(m.Prompt), 92 | fmt.Sprintf("%d chars", len(m.TextInput.Value())), 93 | m.TextInput.View(), 94 | ) 95 | } else { 96 | s = fmt.Sprintf( 97 | "%s %s %s\n", 98 | styles.Question, 99 | styles.Bold(m.Prompt), 100 | m.TextInput.View(), 101 | ) 102 | } 103 | if m.ValidationMsg != "" { 104 | s += "\n" + m.ValidationMsg 105 | } 106 | return s 107 | } 108 | 109 | func Run(i *Input) (string, error) { 110 | program := tea.NewProgram(initialModel(i)) 111 | 112 | m, err := program.Run() 113 | if err != nil { 114 | return "", err 115 | } 116 | 117 | model, ok := m.(Model) 118 | if !ok { 119 | return "", fmt.Errorf("invalid model type") 120 | } 121 | 122 | if model.Cancelled { 123 | return "", components.ErrPromptCancelled 124 | } 125 | 126 | return model.Value(), nil 127 | } 128 | -------------------------------------------------------------------------------- /pkg/scanner/frameworks.go: -------------------------------------------------------------------------------- 1 | package scanner 2 | 3 | import ( 4 | "io/ioutil" 5 | "path/filepath" 6 | "regexp" 7 | 8 | "github.com/deta/space/pkg/util/fs" 9 | "github.com/deta/space/shared" 10 | ) 11 | 12 | var NodeFrameworks = [...]NodeFramework{ 13 | { 14 | Name: shared.React, 15 | Detectors: Detectors{ 16 | Matches: []Match{ 17 | {Path: "package.json", MatchContent: `"(dev)?(d|D)ependencies":\s*{[^}]*"react-scripts":\s*".+?"[^}]*}`}, 18 | {Path: "package.json", MatchContent: `"(dev)?(d|D)ependencies":\s*{[^}]*"react-dev-utils":\s*".+?"[^}]*}`}, 19 | }, 20 | }, 21 | }, 22 | { 23 | Name: shared.Svelte, 24 | Detectors: Detectors{ 25 | Matches: []Match{ 26 | {Path: "package.json", MatchContent: `"(dev)?(d|D)ependencies":\s*{[^}]*"svelte":\s*".+?"[^}]*}`}, 27 | {Path: "package.json", MatchContent: `"(dev)?(d|D)ependencies":\s*{[^}]*"@sveltejs/vite-plugin-svelte":\s*".+?"[^}]*}`}, 28 | }, 29 | Strict: true, 30 | }, 31 | }, 32 | { 33 | Name: shared.Vue, 34 | Detectors: Detectors{ 35 | Matches: []Match{ 36 | {Path: "package.json", MatchContent: `"(dev)?(d|D)ependencies":\s*{[^}]*"@vue\/cli-service":\s*".+?"[^}]*}`}, 37 | }, 38 | Strict: true, 39 | }, 40 | }, 41 | { 42 | Name: shared.SvelteKit, 43 | Detectors: Detectors{ 44 | Matches: []Match{ 45 | {Path: "package.json", MatchContent: `"(dev)?(d|D)ependencies":\s*{[^}]*"@sveltejs\/kit":\s*".+?"[^}]*}`}, 46 | }, 47 | Strict: true, 48 | }, 49 | }, 50 | { 51 | Name: shared.Next, 52 | Detectors: Detectors{ 53 | Matches: []Match{ 54 | {Path: "package.json", MatchContent: `"(dev)?(d|D)ependencies":\s*{[^}]*"next":\s*".+?"[^}]*}`}, 55 | }, 56 | Strict: true, 57 | }, 58 | }, 59 | { 60 | Name: shared.Nuxt, 61 | Detectors: Detectors{ 62 | Matches: []Match{ 63 | {Path: "package.json", MatchContent: `"(dev)?(d|D)ependencies":\s*{[^}]*"nuxt3?(-edge)?":\s*".+?"[^}]*}`}, 64 | }, 65 | Strict: true, 66 | }, 67 | }, 68 | } 69 | 70 | func check(dir string, framework *NodeFramework) (bool, error) { 71 | passed := false 72 | for _, match := range (*framework).Detectors.Matches { 73 | 74 | // check to see if the file exists before checking for pattern match 75 | exists, err := fs.FileExists(dir, match.Path) 76 | if err != nil { 77 | return false, err 78 | } 79 | if !exists { 80 | return false, nil 81 | } 82 | 83 | path := filepath.Join(dir, match.Path) 84 | 85 | b, err := ioutil.ReadFile(path) 86 | if err != nil { 87 | return false, err 88 | } 89 | 90 | pass, _ := regexp.MatchString(match.MatchContent, string(b)) 91 | 92 | if !pass && (*framework).Detectors.Strict { 93 | return false, nil 94 | } 95 | if pass { 96 | passed = true 97 | } 98 | } 99 | return passed, nil 100 | } 101 | 102 | func detectFramework(dir string) (string, error) { 103 | for _, framework := range NodeFrameworks { 104 | check, err := check(dir, &framework) 105 | if err != nil { 106 | return "", err 107 | } 108 | if check { 109 | return framework.Name, nil 110 | } 111 | } 112 | return "nodejs16", nil 113 | } 114 | -------------------------------------------------------------------------------- /pkg/scanner/runtimes.go: -------------------------------------------------------------------------------- 1 | package scanner 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/deta/space/pkg/util/fs" 7 | "github.com/deta/space/shared" 8 | ) 9 | 10 | func pythonScanner(dir string) (*shared.Micro, error) { 11 | // if any of the following files exist detect as python app 12 | exists, err := fs.CheckIfAnyFileExists(dir, "requirements.txt", "Pipfile", "setup.py", "main.py") 13 | if err != nil { 14 | return nil, err 15 | } 16 | 17 | if !exists { 18 | return nil, nil 19 | } 20 | 21 | name, err := getMicroNameFromPath(dir) 22 | if err != nil { 23 | return nil, fmt.Errorf("failed to extract micro name from it's path, %v", err) 24 | } 25 | m := &shared.Micro{ 26 | Name: name, 27 | Src: dir, 28 | Engine: shared.Python39, 29 | } 30 | 31 | return m, nil 32 | } 33 | 34 | func nodeScanner(dir string) (*shared.Micro, error) { 35 | // if any of the following files exist detect as a node app 36 | exists, err := fs.CheckIfAnyFileExists(dir, "package.json") 37 | if err != nil { 38 | return nil, err 39 | } 40 | if !exists { 41 | return nil, nil 42 | } 43 | 44 | name, err := getMicroNameFromPath(dir) 45 | if err != nil { 46 | return nil, fmt.Errorf("failed to extract micro name from it's path, %v", err) 47 | } 48 | 49 | m := &shared.Micro{ 50 | Name: name, 51 | Src: dir, 52 | Engine: shared.Node16x, 53 | } 54 | 55 | framework, err := detectFramework(dir) 56 | if err != nil { 57 | return nil, err 58 | } 59 | m.Engine = framework 60 | 61 | return m, nil 62 | } 63 | 64 | func goScanner(dir string) (*shared.Micro, error) { 65 | exists, err := fs.CheckIfAnyFileExists(dir, "go.mod") 66 | if err != nil { 67 | return nil, err 68 | } 69 | if !exists { 70 | return nil, nil 71 | } 72 | 73 | name, err := getMicroNameFromPath(dir) 74 | if err != nil { 75 | return nil, fmt.Errorf("failed to extract micro name from it's path, %v", err) 76 | } 77 | m := &shared.Micro{ 78 | Name: name, 79 | Src: dir, 80 | Engine: "custom", 81 | Commands: []string{"go build -o server"}, 82 | Include: []string{"server"}, 83 | Run: "./server", 84 | } 85 | 86 | return m, nil 87 | } 88 | 89 | func staticScanner(dir string) (*shared.Micro, error) { 90 | // if any of the following files exist, detect as a static app 91 | exists, err := fs.CheckIfAnyFileExists(dir, "index.html") 92 | if err != nil { 93 | return nil, err 94 | } 95 | if !exists { 96 | return nil, nil 97 | } 98 | 99 | name, err := getMicroNameFromPath(dir) 100 | if err != nil { 101 | return nil, fmt.Errorf("failed to extract micro name from it's path, %v", err) 102 | } 103 | m := &shared.Micro{ 104 | Name: name, 105 | Src: dir, 106 | Serve: "./", 107 | Engine: shared.Static, 108 | } 109 | return m, nil 110 | } 111 | -------------------------------------------------------------------------------- /pkg/scanner/scan.go: -------------------------------------------------------------------------------- 1 | package scanner 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "regexp" 7 | 8 | "github.com/deta/space/shared" 9 | ) 10 | 11 | func Scan(sourceDir string) ([]*shared.Micro, error) { 12 | files, err := os.ReadDir(sourceDir) 13 | if err != nil { 14 | return nil, err 15 | } 16 | 17 | var micros []*shared.Micro 18 | 19 | // scan root source dir for a micro 20 | m, err := scanDir(sourceDir) 21 | if err != nil { 22 | return nil, err 23 | } 24 | 25 | if m != nil { 26 | // root folder has a micro return as a single micro app 27 | micros = append(micros, m) 28 | return micros, nil 29 | } 30 | 31 | // scan subfolders for micros 32 | for _, file := range files { 33 | if file.IsDir() { 34 | m, err = scanDir(filepath.Join(sourceDir, file.Name())) 35 | if err != nil { 36 | return nil, err 37 | } 38 | if m != nil { 39 | micros = append(micros, m) 40 | } 41 | } 42 | } 43 | 44 | return micros, nil 45 | } 46 | 47 | func scanDir(dir string) (*shared.Micro, error) { 48 | runtimeScanners := []engineScanner{ 49 | pythonScanner, 50 | nodeScanner, 51 | goScanner, 52 | staticScanner, 53 | } 54 | 55 | for _, scanner := range runtimeScanners { 56 | m, err := scanner(dir) 57 | if err != nil { 58 | return nil, err 59 | } 60 | if m != nil { 61 | return m, nil 62 | } 63 | } 64 | return nil, nil 65 | } 66 | 67 | var nonAlphaNumeric = regexp.MustCompile(`[^a-zA-Z0-9]+`) 68 | 69 | func cleanMicroName(name string) string { 70 | return nonAlphaNumeric.ReplaceAllString(name, "-") 71 | } 72 | 73 | func getMicroNameFromPath(dir string) (string, error) { 74 | absPath, err := filepath.Abs(dir) 75 | if err != nil { 76 | return "", err 77 | } 78 | 79 | base := filepath.Base(absPath) 80 | return cleanMicroName(base), nil 81 | } 82 | -------------------------------------------------------------------------------- /pkg/scanner/scan_test.go: -------------------------------------------------------------------------------- 1 | package scanner 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/deta/space/shared" 7 | "golang.org/x/exp/slices" 8 | "gotest.tools/v3/assert" 9 | ) 10 | 11 | type ScanTestInfo struct { 12 | Name string 13 | Path string 14 | ExpectedEngine string 15 | } 16 | 17 | var ( 18 | microsTestInfo = []ScanTestInfo{ 19 | {Name: "python", Path: "testdata/micros/python", ExpectedEngine: shared.Python39}, 20 | {Name: "go", Path: "testdata/micros/go", ExpectedEngine: shared.Custom}, 21 | {Name: "next", Path: "testdata/micros/next", ExpectedEngine: shared.Next}, 22 | {Name: "node", Path: "testdata/micros/node", ExpectedEngine: "nodejs16"}, 23 | {Name: "nuxt", Path: "testdata/micros/nuxt", ExpectedEngine: shared.Nuxt}, 24 | {Name: "react", Path: "testdata/micros/react", ExpectedEngine: shared.React}, 25 | {Name: "static", Path: "testdata/micros/static", ExpectedEngine: shared.Static}, 26 | {Name: "svelte", Path: "testdata/micros/svelte", ExpectedEngine: shared.Svelte}, 27 | {Name: "svelte-kit", Path: "testdata/micros/svelte-kit", ExpectedEngine: shared.SvelteKit}, 28 | {Name: "vue", Path: "testdata/micros/vue", ExpectedEngine: shared.Vue}, 29 | } 30 | ) 31 | 32 | func TestScanSingleMicroProjects(t *testing.T) { 33 | for _, project := range microsTestInfo { 34 | t.Run(project.Path, func(t *testing.T) { 35 | micros, err := Scan(project.Path) 36 | if err != nil { 37 | t.Fatalf("failed to scan project %s at %s while testing, %v", project.Name, project.Path, err) 38 | } 39 | assert.Equal(t, len(micros), 1, "detected multiple micros in a single micro project") 40 | micro := micros[0] 41 | assert.Equal(t, micro.Engine, project.ExpectedEngine, "detected engine as %s but expected %s", micro.Engine, project.ExpectedEngine) 42 | }) 43 | } 44 | } 45 | 46 | func TestScanMultiMicroProject(t *testing.T) { 47 | 48 | expectedMicros := []string{"python", "go", "next", "node", "nuxt", "react", "static", "svelte", "svelte-kit", "vue"} 49 | expectedMicrosToEngines := map[string]string{ 50 | "python": shared.Python39, 51 | "go": shared.Custom, 52 | "next": shared.Next, 53 | "node": "nodejs16", 54 | "nuxt": shared.Nuxt, 55 | "react": shared.React, 56 | "static": shared.Static, 57 | "svelte": shared.Svelte, 58 | "svelte-kit": shared.SvelteKit, 59 | "vue": shared.Vue, 60 | } 61 | 62 | sourceDir := "testdata/micros" 63 | 64 | micros, err := Scan(sourceDir) 65 | if err != nil { 66 | t.Fatalf("failed to scan project at %s while testing multi micros auto-detection, %v", sourceDir, err) 67 | } 68 | 69 | assert.Equal(t, len(micros), len(expectedMicros), "detected %d micros, but expected %d", len(micros), len(expectedMicros)) 70 | 71 | for _, micro := range micros { 72 | t.Run(micro.Name, func(t *testing.T) { 73 | if !slices.Contains(expectedMicros, micro.Name) { 74 | t.Fatalf("micro %s at %s is detected, but should not be detected as part of a multi-micro project", micro.Name, micro.Src) 75 | } 76 | assert.Equal(t, micro.Engine, expectedMicrosToEngines[micro.Name], "detected engine for micro %s as %s, but expected %s", micro.Name, micro.Engine, expectedMicrosToEngines[micro.Name]) 77 | }) 78 | } 79 | } 80 | 81 | func TestEmptyProject(t *testing.T) { 82 | sourceDir := "testdata/empty" 83 | 84 | micros, err := Scan(sourceDir) 85 | if err != nil { 86 | t.Fatalf("failed to scan project at %s while testing empty project auto-detection, %v", sourceDir, err) 87 | } 88 | 89 | assert.Equal(t, 0, len(micros), "detected micros in empty project") 90 | } 91 | 92 | func TestCleanMicroName(t *testing.T) { 93 | cases := []struct { 94 | path string 95 | expected string 96 | }{ 97 | {path: "my.app", expected: "my-app"}, 98 | {path: "python", expected: "python"}, 99 | {path: "I'm a micro", expected: "I-m-a-micro"}, 100 | } 101 | 102 | for _, c := range cases { 103 | t.Run(c.path, func(t *testing.T) { 104 | assert.Equal(t, cleanMicroName(c.path), c.expected) 105 | }) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /pkg/scanner/testdata/empty/readme: -------------------------------------------------------------------------------- 1 | empty project test 2 | scanner shouldn't detect any micros in this folder -------------------------------------------------------------------------------- /pkg/scanner/testdata/micros/go/go.mod: -------------------------------------------------------------------------------- 1 | module testproject 2 | 3 | go 1.18 4 | -------------------------------------------------------------------------------- /pkg/scanner/testdata/micros/invalid/invalid: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deta/space-cli/a7f5d050d8577b55c699a96228ba691038c6df27/pkg/scanner/testdata/micros/invalid/invalid -------------------------------------------------------------------------------- /pkg/scanner/testdata/micros/next/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "dev": "next dev", 5 | "build": "next build", 6 | "start": "next start", 7 | "lint": "next lint" 8 | }, 9 | "dependencies": { 10 | "next": "12.1.4", 11 | "react": "18.0.0", 12 | "react-dom": "18.0.0" 13 | }, 14 | "devDependencies": { 15 | "eslint": "8.12.0", 16 | "eslint-config-next": "12.1.4" 17 | } 18 | } -------------------------------------------------------------------------------- /pkg/scanner/testdata/micros/node/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC", 12 | "dependencies": { 13 | "express": "^4.18.1" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /pkg/scanner/testdata/micros/nuxt/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "dev": "nuxi dev", 5 | "build": "nuxi build", 6 | "start": "node .output/server/index.mjs" 7 | }, 8 | "devDependencies": { 9 | "nuxt3": "latest" 10 | } 11 | } -------------------------------------------------------------------------------- /pkg/scanner/testdata/micros/python/requirements.txt: -------------------------------------------------------------------------------- 1 | fastapi -------------------------------------------------------------------------------- /pkg/scanner/testdata/micros/react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "dependencies": { 4 | "@testing-library/jest-dom": "^5.16.4", 5 | "@testing-library/react": "^13.3.0", 6 | "@testing-library/user-event": "^14.2.0", 7 | "react": "^18.1.0", 8 | "react-dom": "^18.1.0", 9 | "react-scripts": "5.0.1", 10 | "web-vitals": "^2.1.4" 11 | }, 12 | "scripts": { 13 | "start": "react-scripts start", 14 | "build": "react-scripts build", 15 | "test": "react-scripts test", 16 | "eject": "react-scripts eject" 17 | }, 18 | "eslintConfig": { 19 | "extends": [ 20 | "react-app", 21 | "react-app/jest" 22 | ] 23 | }, 24 | "browserslist": { 25 | "production": [ 26 | ">0.2%", 27 | "not dead", 28 | "not op_mini all" 29 | ], 30 | "development": [ 31 | "last 1 chrome version", 32 | "last 1 firefox version", 33 | "last 1 safari version" 34 | ] 35 | } 36 | } -------------------------------------------------------------------------------- /pkg/scanner/testdata/micros/static/index.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deta/space-cli/a7f5d050d8577b55c699a96228ba691038c6df27/pkg/scanner/testdata/micros/static/index.html -------------------------------------------------------------------------------- /pkg/scanner/testdata/micros/static/index.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deta/space-cli/a7f5d050d8577b55c699a96228ba691038c6df27/pkg/scanner/testdata/micros/static/index.js -------------------------------------------------------------------------------- /pkg/scanner/testdata/micros/static/styles.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deta/space-cli/a7f5d050d8577b55c699a96228ba691038c6df27/pkg/scanner/testdata/micros/static/styles.css -------------------------------------------------------------------------------- /pkg/scanner/testdata/micros/svelte-kit/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "dev": "svelte-kit dev", 5 | "build": "svelte-kit build", 6 | "package": "svelte-kit package", 7 | "preview": "svelte-kit preview", 8 | "prepare": "svelte-kit sync", 9 | "check": "svelte-check --tsconfig ./jsconfig.json", 10 | "check:watch": "svelte-check --tsconfig ./jsconfig.json --watch", 11 | "lint": "prettier --check --plugin-search-dir=. .", 12 | "format": "prettier --write --plugin-search-dir=. ." 13 | }, 14 | "devDependencies": { 15 | "@sveltejs/adapter-auto": "1.0.0-next.50", 16 | "@sveltejs/kit": "1.0.0-next.347", 17 | "@types/cookie": "^0.4.1", 18 | "prettier": "^2.5.1", 19 | "prettier-plugin-svelte": "^2.5.0", 20 | "svelte": "^3.46.0", 21 | "svelte-check": "^2.2.6", 22 | "svelte-preprocess": "^4.10.6", 23 | "typescript": "~4.6.2" 24 | }, 25 | "type": "module", 26 | "dependencies": { 27 | "@fontsource/fira-mono": "^4.5.0", 28 | "cookie": "^0.4.1", 29 | "web-vitals": "^2.1.4" 30 | } 31 | } -------------------------------------------------------------------------------- /pkg/scanner/testdata/micros/svelte/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "myapp", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "preview": "vite preview" 10 | }, 11 | "devDependencies": { 12 | "@sveltejs/vite-plugin-svelte": "^1.0.2", 13 | "svelte": "^3.49.0", 14 | "vite": "^3.1.0" 15 | } 16 | } -------------------------------------------------------------------------------- /pkg/scanner/testdata/micros/vue/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "serve": "vue-cli-service serve", 5 | "build": "vue-cli-service build", 6 | "lint": "vue-cli-service lint" 7 | }, 8 | "dependencies": { 9 | "core-js": "^3.6.5", 10 | "vue": "^3.0.0" 11 | }, 12 | "devDependencies": { 13 | "@vue/cli-plugin-babel": "~4.5.0", 14 | "@vue/cli-plugin-eslint": "~4.5.0", 15 | "@vue/cli-service": "~4.5.0", 16 | "@vue/compiler-sfc": "^3.0.0", 17 | "babel-eslint": "^10.1.0", 18 | "eslint": "^6.7.2", 19 | "eslint-plugin-vue": "^7.0.0" 20 | }, 21 | "eslintConfig": { 22 | "root": true, 23 | "env": { 24 | "node": true 25 | }, 26 | "extends": [ 27 | "plugin:vue/vue3-essential", 28 | "eslint:recommended" 29 | ], 30 | "parserOptions": { 31 | "parser": "babel-eslint" 32 | }, 33 | "rules": {} 34 | }, 35 | "browserslist": [ 36 | "> 1%", 37 | "last 2 versions", 38 | "not dead" 39 | ] 40 | } -------------------------------------------------------------------------------- /pkg/scanner/types.go: -------------------------------------------------------------------------------- 1 | package scanner 2 | 3 | import "github.com/deta/space/shared" 4 | 5 | type engineScanner func(dir string) (*shared.Micro, error) 6 | 7 | type Match struct { 8 | Path string 9 | MatchContent string 10 | } 11 | 12 | type Detectors struct { 13 | Matches []Match 14 | Strict bool // strict requires all the matches to pass 15 | } 16 | 17 | type NodeFramework struct { 18 | Name string 19 | Detectors Detectors 20 | } 21 | -------------------------------------------------------------------------------- /pkg/util/fs/fs.go: -------------------------------------------------------------------------------- 1 | package fs 2 | 3 | import ( 4 | "archive/zip" 5 | "bytes" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "io/ioutil" 10 | "os" 11 | "path/filepath" 12 | "strings" 13 | "time" 14 | ) 15 | 16 | func UnzipTemplates(rootZip []byte, dest string, rootDir string) error { 17 | r, err := zip.NewReader(bytes.NewReader(rootZip), int64(len(rootZip))) 18 | if err != nil { 19 | return err 20 | } 21 | 22 | for _, f := range r.File { 23 | if !strings.Contains(strings.TrimPrefix(filepath.ToSlash(f.Name), "/"), rootDir) { 24 | continue 25 | } 26 | 27 | fpath := strings.ReplaceAll(f.Name, rootDir, dest) 28 | 29 | // make folder if it is a folder 30 | if f.FileInfo().IsDir() { 31 | err = os.MkdirAll(fpath, os.ModePerm) 32 | if err != nil { 33 | return err 34 | } 35 | continue 36 | } 37 | 38 | // make and copy file 39 | if err = os.MkdirAll(filepath.Dir(fpath), os.ModePerm); err != nil { 40 | return err 41 | } 42 | 43 | copyDest, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) 44 | if err != nil { 45 | return err 46 | } 47 | 48 | srcFile, err := f.Open() 49 | if err != nil { 50 | return err 51 | } 52 | 53 | _, err = io.Copy(copyDest, srcFile) 54 | if err != nil { 55 | return err 56 | } 57 | 58 | // close files without defer to close before next iteration of loop 59 | copyDest.Close() 60 | srcFile.Close() 61 | } 62 | return nil 63 | } 64 | 65 | // FileExists returns a bool indicating if a certain file exists in a dir 66 | func FileExists(dir, filename string) (bool, error) { 67 | info, err := os.Stat(filepath.Join(dir, filename)) 68 | if os.IsNotExist(err) { 69 | return false, nil 70 | } 71 | 72 | if err != nil { 73 | return false, fmt.Errorf("failed to check if filename %s exists in %s dir: %w", filename, dir, err) 74 | } 75 | 76 | return !info.IsDir(), nil 77 | } 78 | 79 | func IsEmpty(dir string) (bool, error) { 80 | _, err := os.Stat(dir) 81 | if os.IsNotExist(err) { 82 | return true, nil 83 | } 84 | 85 | f, err := os.Open(dir) 86 | if err != nil { 87 | if os.IsNotExist(err) { 88 | return true, err 89 | } 90 | return false, err 91 | } 92 | defer f.Close() 93 | 94 | _, err = f.Readdirnames(1) 95 | if errors.Is(err, io.EOF) { 96 | return true, nil 97 | } 98 | 99 | files, err := ioutil.ReadDir(dir) 100 | if err != nil { 101 | return false, err 102 | } 103 | 104 | if len(files) == 1 && files[0].Name() == ".space" { 105 | return true, err 106 | } 107 | 108 | return false, err 109 | } 110 | 111 | // CheckIfAnyFileExists returns true if any of the filenames exist in a dir 112 | func CheckIfAnyFileExists(dir string, filenames ...string) (bool, error) { 113 | for _, filename := range filenames { 114 | exists, err := FileExists(dir, filename) 115 | if err != nil { 116 | return false, err 117 | } 118 | if exists { 119 | return true, err 120 | } 121 | } 122 | return false, nil 123 | } 124 | 125 | func GetFileLastChanged(name string) (time.Time, error) { 126 | file, err := os.Stat(name) 127 | 128 | if err != nil { 129 | return time.Time{}, err 130 | } 131 | 132 | modifiedtime := file.ModTime() 133 | 134 | return modifiedtime, nil 135 | } 136 | -------------------------------------------------------------------------------- /pkg/writer/prefixer.go: -------------------------------------------------------------------------------- 1 | package writer 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "strings" 7 | ) 8 | 9 | type Prefixer struct { 10 | scope string 11 | dest io.Writer 12 | } 13 | 14 | func NewPrefixer(scope string, dest io.Writer) *Prefixer { 15 | return &Prefixer{ 16 | scope: scope, 17 | dest: dest, 18 | } 19 | } 20 | 21 | // parse the logs and prefix them with the scope 22 | func (p Prefixer) Write(bytes []byte) (int, error) { 23 | normalized := strings.ReplaceAll(string(bytes), "\r\n", "\n") 24 | lines := strings.Split(normalized, "\n") 25 | 26 | for _, line := range lines { 27 | fmt.Printf("[%s] %s\n", p.scope, line) 28 | } 29 | 30 | return len(bytes), nil 31 | } 32 | -------------------------------------------------------------------------------- /scripts/generate-docs.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | 8 | "github.com/deta/space/cmd" 9 | "github.com/spf13/cobra" 10 | "github.com/spf13/cobra/doc" 11 | ) 12 | 13 | func buildDoc(command *cobra.Command) (string, error) { 14 | var page strings.Builder 15 | err := doc.GenMarkdown(command, &page) 16 | if err != nil { 17 | return "", err 18 | } 19 | 20 | out := strings.Builder{} 21 | for _, line := range strings.Split(page.String(), "\n") { 22 | if strings.Contains(line, "SEE ALSO") { 23 | break 24 | } 25 | out.WriteString(line + "\n") 26 | } 27 | 28 | for _, child := range command.Commands() { 29 | childPage, err := buildDoc(child) 30 | if err != nil { 31 | return "", err 32 | } 33 | out.WriteString(childPage) 34 | } 35 | 36 | return out.String(), nil 37 | } 38 | 39 | func main() { 40 | cmd := cmd.NewSpaceCmd() 41 | 42 | doc, err := buildDoc(cmd) 43 | if err != nil { 44 | fmt.Fprintln(os.Stderr, "Error generating docs:", err) 45 | os.Exit(1) 46 | } 47 | 48 | fmt.Println(doc) 49 | } 50 | -------------------------------------------------------------------------------- /scripts/install-unix.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | matches() { 6 | input="$1" 7 | pattern="$2" 8 | echo "$input" | grep -q "$pattern" 9 | } 10 | 11 | supported_architectures="x86_64 arm64 aarch64 aarch64_be armv8b armv8l" 12 | 13 | if ! matches "${supported_architectures}" "$(uname -m)"; then 14 | echo "Error: Unsupported architecture $(uname -m). Only x64 and arm64 binaries are available." 1>&2 15 | exit 1 16 | fi 17 | 18 | if ! command -v unzip >/dev/null; then 19 | echo "Error: unzip is required to install space cli." 1>&2 20 | exit 1 21 | fi 22 | 23 | case $(uname -m) in 24 | x86_64) target_arch="x86_64" ;; 25 | *) target_arch="arm64" ;; 26 | esac 27 | 28 | case $(uname -s) in 29 | Darwin) target_os="darwin" ;; 30 | *) target_os="linux" ;; 31 | esac 32 | 33 | if [ $# -eq 0 ]; then 34 | space_uri="https://github.com/deta/space-cli/releases/latest/download/space-${target_os}-${target_arch}.zip" 35 | else 36 | space_uri="https://github.com/deta/space-cli/releases/download/${1}/space-${target_os}-${target_arch}.zip" 37 | fi 38 | 39 | space_install="${SPACE_INSTALL:-$HOME/.detaspace}" 40 | bin_dir="$space_install/bin" 41 | bin="space" 42 | tempfile="$(mktemp -d)/space.zip" 43 | trap 'rm "$tempfile"' EXIT 44 | 45 | if [ ! -d "$bin_dir" ]; then 46 | mkdir -p "$bin_dir" 47 | fi 48 | 49 | curl --fail --location --progress-bar --output "$tempfile" "$space_uri" 50 | unzip -o "$tempfile" "$bin" -d "$bin_dir" 51 | 52 | echo "Deta Space CLI was installed successfully to $bin_dir" 53 | if command -v "$bin" >/dev/null; then 54 | echo "Run 'space --help' to get started" 55 | else 56 | case $SHELL in 57 | /bin/zsh) shell_profile="$HOME/.zshrc" ;; 58 | /bin/bash) shell_profile="$HOME/.bashrc" ;; 59 | *) shell_profile="";; 60 | esac 61 | 62 | if [ -n "$shell_profile" ]; then 63 | cp "$shell_profile" "$shell_profile.bk" 2>/dev/null || true 64 | echo "" >> "$shell_profile" 65 | echo "export PATH=\"$bin_dir:\$PATH\"" >> "$shell_profile" 66 | echo "Run '$bin --help' in a new shell to get started" 67 | else 68 | echo "Manually add $bin_dir to your path:" 69 | echo " export PATH=\"$bin_dir:\$PATH\"" 70 | echo " " 71 | echo " Run '$bin --help' to get started" 72 | fi 73 | fi 74 | -------------------------------------------------------------------------------- /scripts/install-windows.ps1: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env pwsh 2 | 3 | $ErrorActionPreference = 'Stop' 4 | 5 | if ($v) { 6 | if ($v[0] -match "v") { 7 | $Version = "${v}" 8 | } 9 | else { 10 | $Version = "v${v}" 11 | } 12 | } 13 | 14 | if ($args.Length -eq 1) { 15 | $Version = $args.Get(0) 16 | } 17 | 18 | $SpaceInstall = $env:space_INSTALL 19 | $BinDir = if ($SpaceInstall) { 20 | "$SpaceInstall\bin" 21 | } 22 | else { 23 | "$Home\.detaspace\bin" 24 | } 25 | 26 | $SpaceZip = "$BinDir\space.zip" 27 | $SpaceExe = "$BinDir\space.exe" 28 | $SpaceOldExe = "$env:Temp\spaceold.exe" 29 | $Target = 'windows-x86_64' 30 | 31 | # GitHub requires TLS 1.2 32 | [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 33 | 34 | $SpaceUri = if (!$Version) { 35 | "https://github.com/deta/space-cli/releases/latest/download/space-${Target}.zip" 36 | } 37 | else { 38 | "https://github.com/deta/space-cli/releases/download/${Version}/space-${Target}.zip" 39 | } 40 | 41 | if (!(Test-Path $BinDir)) { 42 | New-Item $BinDir -ItemType Directory | Out-Null 43 | } 44 | 45 | Invoke-WebRequest $SpaceUri -OutFile $SpaceZip -UseBasicParsing 46 | 47 | if (Test-Path $SpaceExe) { 48 | Move-Item -Path $SpaceExe -Destination $SpaceOldExe -Force 49 | } 50 | 51 | if (Get-Command Expand-Archive -ErrorAction SilentlyContinue) { 52 | Expand-Archive $SpaceZip -Destination $BinDir -Force 53 | } 54 | else { 55 | Add-Type -AssemblyName System.IO.Compression.FileSystem 56 | [IO.Compression.ZipFile]::ExtractToDirectory($SpaceZip, $BinDir) 57 | } 58 | 59 | Remove-Item $SpaceZip 60 | 61 | $User = [EnvironmentVariableTarget]::User 62 | $Path = [Environment]::GetEnvironmentVariable('Path', $User) 63 | if (!(";$Path;".ToLower() -like "*;$BinDir;*".ToLower())) { 64 | [Environment]::SetEnvironmentVariable('Path', "$Path;$BinDir", $User) 65 | $Env:Path += ";$BinDir" 66 | } 67 | 68 | Write-Output "Space was installed successfully to $SpaceExe" 69 | Write-Output "Run 'space --help' to get started" 70 | -------------------------------------------------------------------------------- /shared/types.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | "path" 9 | 10 | "github.com/deta/space/pkg/writer" 11 | "mvdan.cc/sh/v3/shell" 12 | ) 13 | 14 | // supported engines 15 | const ( 16 | Static = "static" 17 | React = "react" 18 | Svelte = "svelte" 19 | Vue = "vue" 20 | Next = "next" 21 | Nuxt = "nuxt" 22 | SvelteKit = "svelte-kit" 23 | Python38 = "python3.8" 24 | Python39 = "python3.9" 25 | Node14x = "nodejs14.x" 26 | Node16x = "nodejs16.x" 27 | Custom = "custom" 28 | ) 29 | 30 | var ( 31 | SupportedEngines = []string{Static, React, Svelte, Vue, Next, Nuxt, SvelteKit, Python38, Python39, Node14x, Node16x, Custom} 32 | 33 | EngineAliases = map[string]string{ 34 | "static": Static, 35 | "react": React, 36 | "svelte": Svelte, 37 | "vue": Vue, 38 | "next": Next, 39 | "nuxt": Nuxt, 40 | "svelte-kit": SvelteKit, 41 | "python3.9": Python39, 42 | "python3.8": Python38, 43 | "nodejs14.x": Node14x, 44 | "nodejs14": Node14x, 45 | "nodejs16.x": Node16x, 46 | "nodejs16": Node16x, 47 | "custom": Custom, 48 | } 49 | 50 | EnginesToRuntimes = map[string]string{ 51 | Static: Node14x, 52 | React: Node14x, 53 | Svelte: Node14x, 54 | Vue: Node14x, 55 | Next: Node16x, 56 | Nuxt: Node16x, 57 | SvelteKit: Node16x, 58 | Python38: Python38, 59 | Python39: Python38, 60 | Node14x: Node14x, 61 | Node16x: Node16x, 62 | Custom: Custom, 63 | } 64 | 65 | supportedFrontendEngines = map[string]struct{}{ 66 | React: {}, 67 | Vue: {}, 68 | Svelte: {}, 69 | Static: {}, 70 | } 71 | 72 | supportedFullstackEngines = map[string]struct{}{ 73 | Next: {}, 74 | Nuxt: {}, 75 | SvelteKit: {}, 76 | } 77 | 78 | engineToDevCommand = map[string]string{ 79 | React: "npm run start -- --port $PORT", 80 | Vue: "npm run dev -- --port $PORT", 81 | Svelte: "npm run dev -- --port $PORT", 82 | Next: "npm run dev -- --port $PORT", 83 | Nuxt: "npm run dev -- --port $PORT", 84 | SvelteKit: "npm run dev -- --port $PORT", 85 | } 86 | ) 87 | 88 | type ActionEvent struct { 89 | ID string `json:"id"` 90 | Trigger string `json:"trigger"` 91 | } 92 | 93 | type ActionRequest struct { 94 | Event ActionEvent `json:"event"` 95 | } 96 | 97 | // Environment xx 98 | type Environment struct { 99 | Name string `yaml:"name"` 100 | Description string `yaml:"description"` 101 | Default string `yaml:"default"` 102 | } 103 | 104 | // Presets xx 105 | type Presets struct { 106 | Env []Environment `yaml:"env"` 107 | APIKeys bool `yaml:"api_keys"` 108 | } 109 | 110 | // Action xx 111 | type Action struct { 112 | ID string `yaml:"id"` 113 | Name string `yaml:"name"` 114 | Description string `yaml:"description"` 115 | Trigger string `yaml:"trigger"` 116 | Interval string `yaml:"default_interval"` 117 | Path string `yaml:"path"` 118 | } 119 | 120 | // Micro xx 121 | type Micro struct { 122 | Name string `yaml:"name"` 123 | Src string `yaml:"src"` 124 | Engine string `yaml:"engine"` 125 | Path string `yaml:"path,omitempty"` 126 | Presets *Presets `yaml:"presets,omitempty"` 127 | Public bool `yaml:"public,omitempty"` 128 | PublicRoutes []string `yaml:"public_routes,omitempty"` 129 | ProvideActions bool `yaml:"provide_actions,omitempty"` 130 | Primary bool `yaml:"primary"` 131 | Runtime string `yaml:"runtime,omitempty"` 132 | Commands []string `yaml:"commands,omitempty"` 133 | Include []string `yaml:"include,omitempty"` 134 | Actions []Action `yaml:"actions,omitempty"` 135 | Serve string `yaml:"serve,omitempty"` 136 | Run string `yaml:"run,omitempty"` 137 | Dev string `yaml:"dev,omitempty"` 138 | } 139 | 140 | type DiscoveryData struct { 141 | AppName string `yaml:"app_name,omitempty" json:"app_name"` 142 | Title string `yaml:"title,omitempty" json:"title"` 143 | Tagline string `yaml:"tagline,omitempty" json:"tagline"` 144 | ThemeColor string `yaml:"theme_color,omitempty" json:"theme_color"` 145 | Git string `yaml:"git,omitempty" json:"git"` 146 | Homepage string `yaml:"homepage,omitempty" json:"homepage"` 147 | Media []string `yaml:"media" json:"media"` 148 | PortedFrom string `yaml:"ported_from" json:"ported_from"` 149 | OpenCode bool `yaml:"open_code,omitempty" json:"open_code"` 150 | WorksWith []string `yaml:"works_with" json:"works_with"` 151 | ContentRaw string `yaml:"-" json:"content_raw"` 152 | } 153 | 154 | func (m Micro) Type() string { 155 | if m.Primary { 156 | return "primary" 157 | } 158 | return "normal" 159 | } 160 | 161 | var ErrNoDevCommand = errors.New("no dev command found for micro") 162 | 163 | func (micro *Micro) Command(directory, projectKey string, port int) (*exec.Cmd, error) { 164 | var devCommand string 165 | 166 | if micro.Dev != "" { 167 | devCommand = micro.Dev 168 | } else if engineToDevCommand[micro.Engine] != "" { 169 | devCommand = engineToDevCommand[micro.Engine] 170 | } else { 171 | return nil, ErrNoDevCommand 172 | } 173 | 174 | commandDir := path.Join(directory, micro.Src) 175 | 176 | environ := map[string]string{ 177 | "PORT": fmt.Sprintf("%d", port), 178 | "DETA_PROJECT_KEY": projectKey, 179 | "DETA_SPACE_APP_HOSTNAME": fmt.Sprintf("localhost:%d", port), 180 | "DETA_SPACE_APP_MICRO_NAME": micro.Name, 181 | "DETA_SPACE_APP_MICRO_TYPE": micro.Type(), 182 | } 183 | 184 | if micro.Presets != nil { 185 | for _, env := range micro.Presets.Env { 186 | // If the env is already set by the user, don't override it 187 | if os.Getenv(env.Name) != "" { 188 | continue 189 | } 190 | environ[env.Name] = env.Default 191 | } 192 | } 193 | 194 | fields, err := shell.Fields(devCommand, func(s string) string { 195 | if env, ok := environ[s]; ok { 196 | return env 197 | } 198 | 199 | return os.Getenv(s) 200 | }) 201 | if err != nil { 202 | return nil, err 203 | } 204 | 205 | if len(fields) == 0 { 206 | return nil, fmt.Errorf("no command found for micro %s", micro.Name) 207 | } 208 | commandName := fields[0] 209 | var commandArgs []string 210 | if len(fields) > 0 { 211 | commandArgs = fields[1:] 212 | } 213 | 214 | cmd := exec.Command(commandName, commandArgs...) 215 | cmd.Env = os.Environ() 216 | for key, value := range environ { 217 | cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", key, value)) 218 | } 219 | cmd.Dir = commandDir 220 | cmd.Stdout = writer.NewPrefixer(micro.Name, os.Stdout) 221 | cmd.Stderr = writer.NewPrefixer(micro.Name, os.Stderr) 222 | 223 | return cmd, nil 224 | } 225 | 226 | func IsFrontendEngine(engine string) bool { 227 | _, ok := supportedFrontendEngines[engine] 228 | return ok 229 | } 230 | 231 | func IsPythonEngine(engine string) bool { 232 | return engine == Python38 || engine == Python39 233 | } 234 | 235 | func IsFullstackEngine(engine string) bool { 236 | _, ok := supportedFullstackEngines[engine] 237 | return ok 238 | } 239 | --------------------------------------------------------------------------------