├── .editorconfig ├── .github └── workflows │ └── ci.yaml ├── .gitignore ├── .tool-versions ├── .vscode └── settings.json ├── CODEOWNERS ├── LICENSE ├── Makefile ├── README.md ├── _build ├── build-local.bash ├── nupkg │ ├── ReadMe.md │ ├── _TODO.txt │ ├── rendercli.nuspec.template │ └── tools │ │ ├── LICENSE.txt │ │ └── VERIFICATION.txt └── write-version.bash ├── _data └── render.schema.json ├── _share ├── bash │ └── bash_completion.d │ │ └── render.bash ├── fish │ └── vendor_completions.d │ │ └── render.fish └── zsh │ └── site-functions │ └── render.zsh ├── _test ├── invalid-render.yaml └── sveltekit-render.yaml ├── api ├── config.ts ├── error-handling.ts ├── index.ts └── requests.ts ├── blueprints └── validator.ts ├── buildpack ├── constants.ts ├── crud.ts ├── templates │ └── dockerfile_template.ts ├── types.ts └── validate.ts ├── commands ├── _helpers.ts ├── blueprint │ ├── edit.ts │ ├── from-template.ts │ ├── index.ts │ └── launch.ts ├── buildpack │ ├── add.ts │ ├── index.ts │ ├── init.ts │ └── remove.ts ├── commands.ts ├── config │ ├── _shared.ts │ ├── add-profile.ts │ ├── index.ts │ ├── init.ts │ ├── profiles.ts │ └── schema.ts ├── custom-domains │ ├── index.ts │ └── list.ts ├── dashboard.ts ├── deploys │ ├── index.ts │ └── list.ts ├── docs.ts ├── index.ts ├── jobs │ ├── index.ts │ └── list.ts ├── regions.ts ├── repo │ ├── from-template.ts │ └── index.ts ├── services │ ├── delete.ts │ ├── index.ts │ ├── list.ts │ ├── show.ts │ ├── ssh.ts │ └── tail.ts └── version.ts ├── config ├── index.ts └── types │ ├── enums.ts │ ├── index.ts │ └── v1.ts ├── deno.json ├── deno.lock ├── deps-lock.json ├── deps.ts ├── entry-point.ts ├── errors.ts ├── import_map.json ├── new ├── blueprint │ └── index.ts └── repo │ ├── index.test.ts │ └── index.ts ├── render.yaml ├── services ├── constants.ts ├── ssh │ ├── index.ts │ └── known-hosts.ts └── types.ts ├── util ├── ajv.ts ├── errors.ts ├── find-up.test.ts ├── find-up.ts ├── fn.ts ├── git.ts ├── iter.ts ├── logging.ts ├── objects.ts ├── paths.ts └── shell.ts └── version.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.{js,ts,jsx,tsx,json,json5,yaml,yml,toml,md,sh,bash}] 4 | indent_size = 2 5 | tab_width = 2 6 | indent_style = space 7 | end_of_line = lf 8 | insert_final_newline = true 9 | charset = utf-8 10 | trim_trailing_whitespace = true -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | pull_request: {} 4 | push: 5 | branches: [main] 6 | tags: ['v*'] 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: denoland/setup-deno@v1 13 | with: 14 | deno-version: v1.x 15 | - name: Check out code (full depth for tags) 16 | uses: actions/checkout@v2 17 | with: 18 | fetch-depth: 0 19 | - name: caching deps 20 | run: make deps 21 | # this version will be a git describe-tags output, so if the commit is the most recent tag 22 | # it will BE the tag, or will be of the form `v0.1.0-N-aabbccdd`. We then append to it 23 | # `-$1-$kernel-$arch`. 24 | - name: outputting version 25 | run: ./_build/write-version.bash gha > version.ts 26 | - name: running tests 27 | run: make test 28 | - name: compiling executable for x86_64-unknown-linux-gnu 29 | run: make build-linux-x86_64 30 | - name: compiling executable for x86_64-apple-darwin 31 | run: make build-macos-x86_64 32 | - name: compiling executable for aarch64-apple-darwin 33 | run: make build-macos-aarch64 34 | - name: compiling executable for x86_64-pc-windows-msvc 35 | run: make build-windows-x86_64 36 | - name: setting chmod 37 | run: chmod +x /tmp/render-* 38 | - name: test-running linux artifact just to be sure... 39 | run: /tmp/render-linux-x86_64 version 40 | - name: uploading artifacts (all) 41 | uses: actions/upload-artifact@v2 42 | # if: startsWith(github.ref, 'refs/tags/') 43 | with: 44 | name: render-binaries 45 | path: /tmp/render-* 46 | 47 | make-nupkg: 48 | runs-on: ubuntu-latest 49 | needs: build 50 | if: startsWith(github.ref, 'refs/tags/') 51 | steps: 52 | # we need to remove the 'v' from this, choco doesn't like it 53 | - name: Check out code 54 | uses: actions/checkout@v2 55 | - name: Create temp directories 56 | run: 57 | mkdir -p ./tmp/artifacts ./tmp/nupkg 58 | - uses: actions/download-artifact@v3 59 | with: 60 | name: render-binaries 61 | path: ./tmp/artifacts 62 | - name: Prep executable 63 | run: cp ./tmp/artifacts/render-windows-x86_64.exe ./tmp/nupkg/render.exe 64 | - name: copy tools to nupkg 65 | run: 66 | cp -r ./_build/nupkg/tools ./tmp/nupkg/tools 67 | - name: output templated nuspec 68 | run: 69 | cat ./_build/nupkg/rendercli.nuspec.template | sed "s/NUSPEC_PACKAGE_VERSION/${GITHUB_REF/refs\/tags\/v/}/g" > ./tmp/nupkg/rendercli.nuspec 70 | - name: show templated nuspec 71 | run: cat ./tmp/nupkg/rendercli.nuspec 72 | - name: enumerating nupkg files 73 | run: find ./tmp/nupkg 74 | - name: pack nupkg 75 | uses: crazy-max/ghaction-chocolatey@v2 76 | with: 77 | args: pack ./tmp/nupkg/rendercli.nuspec 78 | - name: check for nupkg 79 | run: ls . && find ./tmp 80 | - name: uploading nupkg 81 | uses: actions/upload-artifact@v2 82 | # if: startsWith(github.ref, 'refs/tags/') 83 | with: 84 | name: nupkg 85 | path: ./rendercli.*.nupkg 86 | 87 | make-release: 88 | runs-on: ubuntu-latest 89 | needs: 90 | - build 91 | - make-nupkg 92 | if: startsWith(github.ref, 'refs/tags/') 93 | steps: 94 | - name: Check out code 95 | uses: actions/checkout@v2 96 | - run: mkdir -p /tmp/artifacts /tmp/nupkg 97 | - uses: actions/download-artifact@v3 98 | with: 99 | name: render-binaries 100 | path: /tmp/artifacts 101 | - uses: actions/download-artifact@v3 102 | with: 103 | name: nupkg 104 | path: /tmp/nupkg 105 | - name: Do GitHub release 106 | uses: softprops/action-gh-release@v1 107 | with: 108 | draft: true 109 | files: | 110 | /tmp/artifacts/* 111 | /tmp/nupkg/* 112 | LICENSE 113 | README.md 114 | 115 | push-nupkg: 116 | runs-on: ubuntu-latest 117 | needs: 118 | - make-release 119 | if: startsWith(github.ref, 'refs/tags/') 120 | steps: 121 | - uses: actions/download-artifact@v3 122 | with: 123 | name: nupkg 124 | path: ./tmp/nupkg 125 | - name: rename nupkg for action 126 | run: cp ./tmp/nupkg/rendercli.${GITHUB_REF/refs\/tags\/v/}.nupkg ./tmp/nupkg/rendercli.nupkg 127 | - name: "push nupkg to chocolatey" 128 | uses: crazy-max/ghaction-chocolatey@v2 129 | with: 130 | args: push --key ${{ secrets.CHOCOLATEY_APIKEY }} --source https://push.chocolatey.org ./tmp/nupkg/rendercli.nupkg 131 | 132 | permissions: 133 | contents: write 134 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ~* 2 | *~ 3 | .DS_Store 4 | 5 | build/ 6 | dist/ 7 | tmp/ 8 | vendor/ 9 | node_modules/ 10 | 11 | *.log 12 | 13 | # executables and build artifacts 14 | /share 15 | /bin 16 | render 17 | render-* 18 | !render-cli.nuspec.template 19 | 20 | # this is now automatically updated 21 | version.ts 22 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | deno 1.28.2 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": true, 3 | "deno.unstable": true, 4 | } 5 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @growth 2 | 3 | .editorconfig ed@render.com 4 | .gitignore ed@render.com 5 | .tool-versions ed@render.com 6 | CODEOWNERS ed@render.com 7 | deno.json ed@render.com 8 | deps-lock.json ed@render.com 9 | import_map.json ed@render.com 10 | deps.ts ed@render.com 11 | .github/**/* ed@render.com -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2022 Render, Inc. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | OUTDIR ?= "/tmp" 2 | 3 | build-local: 4 | ./_build/build-local.bash 5 | 6 | cache-deps: 7 | deno cache --lock=deps-lock.json --lock-write --import-map=import_map.json deps.ts 8 | 9 | deps: 10 | deno cache --lock=deps-lock.json deps.ts 11 | 12 | test: 13 | deno test --allow-write --allow-read --allow-net --allow-env --allow-run 14 | 15 | build-linux-x86_64: deps 16 | $(eval OUTFILE ?= render-linux-x86_64) 17 | deno compile \ 18 | --unstable \ 19 | --allow-net \ 20 | --allow-read \ 21 | --allow-run \ 22 | --allow-write \ 23 | --allow-env \ 24 | --target=x86_64-unknown-linux-gnu \ 25 | --output=${OUTDIR}/${OUTFILE} \ 26 | ./entry-point.ts 27 | 28 | build-macos-x86_64: deps 29 | $(eval OUTFILE ?= render-macos-x86_64) 30 | deno compile \ 31 | --unstable \ 32 | --allow-net \ 33 | --allow-read \ 34 | --allow-run \ 35 | --allow-write \ 36 | --allow-env \ 37 | --target=x86_64-apple-darwin \ 38 | --output=${OUTDIR}/${OUTFILE} \ 39 | ./entry-point.ts 40 | 41 | build-macos-aarch64: deps 42 | $(eval OUTFILE ?= render-macos-aarch64) 43 | deno compile \ 44 | --unstable \ 45 | --allow-net \ 46 | --allow-read \ 47 | --allow-run \ 48 | --allow-write \ 49 | --allow-env \ 50 | --target=aarch64-apple-darwin \ 51 | --output=${OUTDIR}/${OUTFILE} \ 52 | ./entry-point.ts 53 | 54 | build-windows-x86_64: deps 55 | $(eval OUTFILE ?= render-windows-x86_64) 56 | deno compile \ 57 | --unstable \ 58 | --allow-net \ 59 | --allow-read \ 60 | --allow-run \ 61 | --allow-write \ 62 | --allow-env \ 63 | --target=x86_64-pc-windows-msvc \ 64 | --output=${OUTDIR}/${OUTFILE} \ 65 | ./entry-point.ts 66 | 67 | build-completions: build-local 68 | mkdir -p ./share/fish/vendor_completions.d ./share/bash/bash_completion.d ./share/zsh/site-functions 69 | ./bin/render completions fish > ./share/fish/vendor_completions.d/render.fish 70 | ./bin/render completions bash > ./share/bash/bash_completion.d/render.bash 71 | ./bin/render completions zsh > ./share/zsh/site-functions/render.zsh 72 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # render-cli (deprecated) # 2 | 3 | > [!WARNING] 4 | > **This CLI is deprecated and no longer under active development. We recommend using our newer [actively-maintained CLI](https://github.com/render-oss/cli) instead.** 5 | 6 | ## Getting render-cli ## 7 | ### The easy way: getting releases ### 8 | You can download a platform-specific build of `render-cli` from the [releases page](https://github.com/render-oss/render-cli/releases). 9 | 10 | ### The easy way for OSX users: homebrew ### 11 | ```bash 12 | brew tap render-oss/render 13 | brew install render 14 | ``` 15 | See [render-oss/homebrew-render](https://github.com/render-oss/homebrew-render) for more details. 16 | 17 | ### The less-easy-but-still-easy way: getting builds from `main` ### 18 | If you head to [GitHub Actions](https://github.com/render-oss/render-cli/actions) and click a passing build, you can download the latest artifacts at the bottom of the page. 19 | 20 | ### The moderately difficult way: pull the repo ### 21 | _This is necessary to run `render-cli` on platforms not supported by `deno compile`, such as Linux `arm64`._ 22 | 23 | You can also clone this repository (fork it first, if you want!) and run the application from the repo itself. Something like this will get you sorted: 24 | 25 | ```bash 26 | git clone git@github.com:render-oss/render-cli.git 27 | cd render-cli 28 | make deps 29 | 30 | # 'deno task run' replaces 'render' in executable invocations 31 | deno task run --help 32 | ``` 33 | 34 | To build a local binary, run `make build-local`. It will emit a platform-correct binary on supported platforms and write it 35 | to `./bin/render`. 36 | 37 | ## Using render-cli ## 38 | `render-cli` attempts to be a friendly and explorable command-line tool. For any command or subcommand under `render`, you can pass `--help` for detailed information on how it works. 39 | 40 | **You'll want to get started by building a config file.** The magic words for this are `render config init`, and it'll walk you through getting set up. 41 | 42 | Is something hard to use, unpleasant to use, or unclear? **Please let us know!** As the CLI is an open-source project, we try to work in the open as much as possible; please [check the issues](https://github.com/render-oss/render-cli/issues) and file a new one if appropriate! 43 | 44 | ### Shell completions ### 45 | `render-cli` supports completions for `bash`, `zsh`, and `fish`. Once installed, type `render completions --help` for details. 46 | 47 | ## Environment variables ## 48 | - `RENDERCLI_CONFIG_FILE`: the path to a render-cli configuration file. If this file does not exist, render-cli will _not_ halt (but the application may fail due to missing configuration). 49 | - `RENDERCLI_PROFILE`: selects a profile from the Render configuration. Is overridden by the `--profile` option. Defaults to `default` if neither is set. 50 | - `RENDERCLI_REGION`: selects a region. Is overridden by the `--region` option. Defaults to your selected profile's `defaultRegion` if neither is set, and `oregon` if your profile lacks a `defaultRegion`. 51 | - `RENDERCLI_APIKEY`: provides an API key for use with the Render CLI. Overrides the value in your selected profile. Has no default; operations that require API access will fail if this is unset and the current profile does not have an API key. 52 | -------------------------------------------------------------------------------- /_build/build-local.bash: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | # This script is a little easier than trying to do it 3 | # all in a Makefile. It handles Deno-specific build arguments 4 | # and dumps the compiled binary in `./out/render`. 5 | 6 | SCRIPT_DIR="$(dirname "$0")" 7 | ROOT_DIR="$(dirname "$SCRIPT_DIR")" 8 | 9 | OS_IMPL=$(uname -s) 10 | MACHINE=$(uname -m) 11 | 12 | dispatch_build() { 13 | cd "$(dirname "$(dirname "$0")")" || exit 1 14 | 15 | MAKE_TARGET="build-$1-$2" 16 | 17 | export OUTDIR="$ROOT_DIR/bin" 18 | mkdir -p "$OUTDIR" 19 | export OUTFILE="render" 20 | 21 | [ -f "${OUTDIR}"/"${OUTFILE}" ] && rm "${OUTDIR}"/"${OUTFILE}" 22 | make "${MAKE_TARGET}" 23 | } 24 | 25 | case "$OS_IMPL" in 26 | "Linux") 27 | case "$MACHINE" in 28 | "x86_64") 29 | dispatch_build "linux" "x86_64" 30 | ;; 31 | 32 | *) 33 | echo "Unsupported ${OS_IMPL} implementation: ${MACHINE}" 34 | exit 1 35 | ;; 36 | esac 37 | ;; 38 | "Darwin") 39 | case "$MACHINE" in 40 | "x86_64") 41 | dispatch_build "macos" "x86_64" 42 | ;; 43 | 44 | "arm64") 45 | dispatch_build "macos" "aarch64" 46 | ;; 47 | 48 | *) 49 | echo "Unsupported ${OS_IMPL} implementation: ${MACHINE}" 50 | exit 1 51 | ;; 52 | esac 53 | ;; 54 | 55 | *) 56 | echo "Unsupported OS implementation: ${OS_IMPL}" 57 | exit 1 58 | ;; 59 | esac 60 | -------------------------------------------------------------------------------- /_build/nupkg/ReadMe.md: -------------------------------------------------------------------------------- 1 | ## Summary 2 | How do I create packages? See https://chocolatey.org/docs/create-packages 3 | 4 | If you are submitting packages to the community feed (https://chocolatey.org) 5 | always try to ensure you have read, understood and adhere to the create 6 | packages wiki link above. 7 | 8 | ## Automatic Packaging Updates? 9 | Consider making this package an automatic package, for the best 10 | maintainability over time. Read up at https://chocolatey.org/docs/automatic-packages 11 | 12 | ## Shim Generation 13 | Any executables you include in the package or download (but don't call 14 | install against using the built-in functions) will be automatically shimmed. 15 | 16 | This means those executables will automatically be included on the path. 17 | Shim generation runs whether the package is self-contained or uses automation 18 | scripts. 19 | 20 | By default, these are considered console applications. 21 | 22 | If the application is a GUI, you should create an empty file next to the exe 23 | named 'name.exe.gui' e.g. 'bob.exe' would need a file named 'bob.exe.gui'. 24 | See https://chocolatey.org/docs/create-packages#how-do-i-set-up-shims-for-applications-that-have-a-gui 25 | 26 | If you want to ignore the executable, create an empty file next to the exe 27 | named 'name.exe.ignore' e.g. 'bob.exe' would need a file named 28 | 'bob.exe.ignore'. 29 | See https://chocolatey.org/docs/create-packages#how-do-i-exclude-executables-from-getting-shims 30 | 31 | ## Self-Contained? 32 | If you have a self-contained package, you can remove the automation scripts 33 | entirely and just include the executables, they will automatically get shimmed, 34 | which puts them on the path. Ensure you have the legal right to distribute 35 | the application though. See https://chocolatey.org/docs/legal. 36 | 37 | You should read up on the Shim Generation section to familiarize yourself 38 | on what to do with GUI applications and/or ignoring shims. 39 | 40 | ## Automation Scripts 41 | You have a powerful use of Chocolatey, as you are using PowerShell. So you 42 | can do just about anything you need. Choco has some very handy built-in 43 | functions that you can use, these are sometimes called the helpers. 44 | 45 | ### Built-In Functions 46 | https://chocolatey.org/docs/helpers-reference 47 | 48 | A note about a couple: 49 | * Get-BinRoot - this is a horribly named function that doesn't do what new folks think it does. It gets you the 'tools' root, which by default is set to 'c:\tools', not the chocolateyInstall bin folder - see https://chocolatey.org/docs/helpers-get-tools-location 50 | * Install-BinFile - used for non-exe files - executables are automatically shimmed... - see https://chocolatey.org/docs/helpers-install-bin-file 51 | * Uninstall-BinFile - used for non-exe files - executables are automatically shimmed - see https://chocolatey.org/docs/helpers-uninstall-bin-file 52 | 53 | ### Getting package specific information 54 | Use the package parameters pattern - see https://chocolatey.org/docs/how-to-parse-package-parameters-argument 55 | 56 | ### Need to mount an ISO? 57 | https://chocolatey.org/docs/how-to-mount-an-iso-in-chocolatey-package 58 | 59 | ### Environment Variables 60 | Chocolatey makes a number of environment variables available (You can access any of these with $env:TheVariableNameBelow): 61 | 62 | * TEMP/TMP - Overridden to the CacheLocation, but may be the same as the original TEMP folder 63 | * ChocolateyInstall - Top level folder where Chocolatey is installed 64 | * ChocolateyPackageName - The name of the package, equivalent to the `` field in the nuspec (0.9.9+) 65 | * ChocolateyPackageTitle - The title of the package, equivalent to the `` field in the nuspec (0.10.1+) 66 | * ChocolateyPackageVersion - The version of the package, equivalent to the `<version />` field in the nuspec (0.9.9+) 67 | * ChocolateyPackageFolder - The top level location of the package folder - the folder where Chocolatey has downloaded and extracted the NuGet package, typically `C:\ProgramData\chocolatey\lib\packageName`. 68 | 69 | #### Advanced Environment Variables 70 | The following are more advanced settings: 71 | 72 | * ChocolateyPackageParameters - Parameters to use with packaging, not the same as install arguments (which are passed directly to the native installer). Based on `--package-parameters`. (0.9.8.22+) 73 | * CHOCOLATEY_VERSION - The version of Choco you normally see. Use if you are 'lighting' things up based on choco version. (0.9.9+) - Otherwise take a dependency on the specific version you need. 74 | * ChocolateyForceX86 = If available and set to 'true', then user has requested 32bit version. (0.9.9+) - Automatically handled in built in Choco functions. 75 | * OS_PLATFORM - Like Windows, OSX, Linux. (0.9.9+) 76 | * OS_VERSION - The version of OS, like 6.1 something something for Windows. (0.9.9+) 77 | * OS_NAME - The reported name of the OS. (0.9.9+) 78 | * USER_NAME = The user name (0.10.6+) 79 | * USER_DOMAIN = The user domain name (could also be local computer name) (0.10.6+) 80 | * IS_PROCESSELEVATED = Is the process elevated? (0.9.9+) 81 | * IS_SYSTEM = Is the user the system account? (0.10.6+) 82 | * IS_REMOTEDESKTOP = Is the user in a terminal services session? (0.10.6+) 83 | * ChocolateyToolsLocation - formerly 'ChocolateyBinRoot' ('ChocolateyBinRoot' will be removed with Chocolatey v2.0.0), this is where tools being installed outside of Chocolatey packaging will go. (0.9.10+) 84 | 85 | #### Set By Options and Configuration 86 | Some environment variables are set based on options that are passed, configuration and/or features that are turned on: 87 | 88 | * ChocolateyEnvironmentDebug - Was `--debug` passed? If using the built-in PowerShell host, this is always true (but only logs debug messages to console if `--debug` was passed) (0.9.10+) 89 | * ChocolateyEnvironmentVerbose - Was `--verbose` passed? If using the built-in PowerShell host, this is always true (but only logs verbose messages to console if `--verbose` was passed). (0.9.10+) 90 | * ChocolateyForce - Was `--force` passed? (0.9.10+) 91 | * ChocolateyForceX86 - Was `-x86` passed? (CHECK) 92 | * ChocolateyRequestTimeout - How long before a web request will time out. Set by config `webRequestTimeoutSeconds` (CHECK) 93 | * ChocolateyResponseTimeout - How long to wait for a download to complete? Set by config `commandExecutionTimeoutSeconds` (CHECK) 94 | * ChocolateyPowerShellHost - Are we using the built-in PowerShell host? Set by `--use-system-powershell` or the feature `powershellHost` (0.9.10+) 95 | 96 | #### Business Edition Variables 97 | 98 | * ChocolateyInstallArgumentsSensitive - Encrypted arguments passed from command line `--install-arguments-sensitive` that are not logged anywhere. (0.10.1+ and licensed editions 1.6.0+) 99 | * ChocolateyPackageParametersSensitive - Package parameters passed from command line `--package-parameters-senstivite` that are not logged anywhere. (0.10.1+ and licensed editions 1.6.0+) 100 | * ChocolateyLicensedVersion - What version is the licensed edition on? 101 | * ChocolateyLicenseType - What edition / type of the licensed edition is installed? 102 | * USER_CONTEXT - The original user context - different when self-service is used (Licensed v1.10.0+) 103 | 104 | #### Experimental Environment Variables 105 | The following are experimental or use not recommended: 106 | 107 | * OS_IS64BIT = This may not return correctly - it may depend on the process the app is running under (0.9.9+) 108 | * CHOCOLATEY_VERSION_PRODUCT = the version of Choco that may match CHOCOLATEY_VERSION but may be different (0.9.9+) - based on git describe 109 | * IS_ADMIN = Is the user an administrator? But doesn't tell you if the process is elevated. (0.9.9+) 110 | * IS_REMOTE = Is the user in a remote session? (0.10.6+) 111 | 112 | #### Not Useful Or Anti-Pattern If Used 113 | 114 | * ChocolateyInstallOverride = Not for use in package automation scripts. Based on `--override-arguments` being passed. (0.9.9+) 115 | * ChocolateyInstallArguments = The installer arguments meant for the native installer. You should use chocolateyPackageParameters instead. Based on `--install-arguments` being passed. (0.9.9+) 116 | * ChocolateyIgnoreChecksums - Was `--ignore-checksums` passed or the feature `checksumFiles` turned off? (0.9.9.9+) 117 | * ChocolateyAllowEmptyChecksums - Was `--allow-empty-checksums` passed or the feature `allowEmptyChecksums` turned on? (0.10.0+) 118 | * ChocolateyAllowEmptyChecksumsSecure - Was `--allow-empty-checksums-secure` passed or the feature `allowEmptyChecksumsSecure` turned on? (0.10.0+) 119 | * ChocolateyCheckLastExitCode - Should Chocolatey check LASTEXITCODE? Is the feature `scriptsCheckLastExitCode` turned on? (0.10.3+) 120 | * ChocolateyChecksum32 - Was `--download-checksum` passed? (0.10.0+) 121 | * ChocolateyChecksumType32 - Was `--download-checksum-type` passed? (0.10.0+) 122 | * ChocolateyChecksum64 - Was `--download-checksum-x64` passed? (0.10.0)+ 123 | * ChocolateyChecksumType64 - Was `--download-checksum-type-x64` passed? (0.10.0)+ 124 | * ChocolateyPackageExitCode - The exit code of the script that just ran - usually set by `Set-PowerShellExitCode` (CHECK) 125 | * ChocolateyLastPathUpdate - Set by Chocolatey as part of install, but not used for anything in particular in packaging. 126 | * ChocolateyProxyLocation - The explicit proxy location as set in the configuration `proxy` (0.9.9.9+) 127 | * ChocolateyDownloadCache - Use available download cache? Set by `--skip-download-cache`, `--use-download-cache`, or feature `downloadCache` (0.9.10+ and licensed editions 1.1.0+) 128 | * ChocolateyProxyBypassList - Explicitly set locations to ignore in configuration `proxyBypassList` (0.10.4+) 129 | * ChocolateyProxyBypassOnLocal - Should the proxy bypass on local connections? Set based on configuration `proxyBypassOnLocal` (0.10.4+) 130 | * http_proxy - Set by original `http_proxy` passthrough, or same as `ChocolateyProxyLocation` if explicitly set. (0.10.4+) 131 | * https_proxy - Set by original `https_proxy` passthrough, or same as `ChocolateyProxyLocation` if explicitly set. (0.10.4+) 132 | * no_proxy- Set by original `no_proxy` passthrough, or same as `ChocolateyProxyBypassList` if explicitly set. (0.10.4+) 133 | 134 | -------------------------------------------------------------------------------- /_build/nupkg/_TODO.txt: -------------------------------------------------------------------------------- 1 | TODO 2 | 3 | 1. Determine Package Use: 4 | 5 | Organization? Internal Use? - You are not subject to distribution 6 | rights when you keep everything internal. Put the binaries directly 7 | into the tools directory (as long as total nupkg size is under 1GB). 8 | When bigger, look to use from a share or download binaries from an 9 | internal location. Embedded binaries makes for the most reliable use 10 | of Chocolatey. Use `$fileLocation` (`$file`/`$file64`) and 11 | `Install-ChocolateyInstallPackage`/`Get-ChocolateyUnzip` in 12 | tools\chocolateyInstall.ps1. 13 | 14 | You can also choose to download from internal urls, see the next 15 | section, but ignore whether you have distribution rights or not, it 16 | doesn't apply. Under no circumstances should download from the 17 | internet, it is completely unreliable. See 18 | https://chocolatey.org/docs/community-packages-disclaimer#organizations 19 | to understand the limitations of a publicly available repository. 20 | 21 | Community Repository? 22 | Have Distribution Rights? 23 | If you are the software vendor OR the software EXPLICITLY allows 24 | redistribution and the total nupkg size will be under 200MB, you 25 | have the option to embed the binaries directly into the package to 26 | provide the most reliable install experience. Put the binaries 27 | directly into the tools folder, use `$fileLocation` (`$file`/ 28 | `$file64`) and `Install-ChocolateyInstallPackage`/ 29 | `Get-ChocolateyUnzip` in tools\chocolateyInstall.ps1. Additionally, 30 | fill out the LICENSE and VERIFICATION file (see 3 below and those 31 | files for specifics). 32 | 33 | NOTE: You can choose to download binaries at runtime, but be sure 34 | the download location will remain stable. See the next section. 35 | 36 | Do Not Have Distribution Rights? 37 | - Note: Packages built this way cannot be 100% reliable, but it's a 38 | constraint of publicly available packages and there is little 39 | that can be done to change that. See 40 | https://chocolatey.org/docs/community-packages-disclaimer#organizations 41 | to better understand the limitations of a publicly available 42 | repository. 43 | Download Location is Publicly Available? 44 | You will need to download the runtime files from their official 45 | location at runtime. Use `$url`/`$url64` and 46 | `Install-ChocolateyPackage`/`Install-ChocolateyZipPackage` in 47 | tools\chocolateyInstall.ps1. 48 | Download Location is Not Publicly Available? 49 | Stop here, you can't push this to the community repository. You 50 | can ask the vendor for permission to embed, then include a PDF of 51 | that signed permission directly in the package. Otherwise you 52 | will need to seek alternate locations to non-publicly host the 53 | package. 54 | Download Location Is Same For All Versions? 55 | You still need to point to those urls, but you may wish to set up 56 | something like Automatic Updater (AU) so that when a new version 57 | of the software becomes available, the new package version 58 | automatically gets pushed up to the community repository. See 59 | https://chocolatey.org/docs/automatic-packages#automatic-updater-au 60 | 61 | 2. Determine Package Type: 62 | 63 | - Installer Package - contains an installer (everything in template is 64 | geared towards this type of package) 65 | - Zip Package - downloads or embeds and unpacks archives, may unpack 66 | and run an installer using `Install-ChocolateyInstallPackage` as a 67 | secondary step. 68 | - Portable Package - Contains runtime binaries (or unpacks them as a 69 | zip package) - cannot require administrative permissions to install 70 | or use 71 | - Config Package - sets config like files, registry keys, etc 72 | - Extension Package - Packages that add PowerShell functions to 73 | Chocolatey - https://chocolatey.org/docs/how-to-create-extensions 74 | - Template Package - Packages that add templates like this for `choco 75 | new -t=name` - https://chocolatey.org/docs/how-to-create-custom-package-templates 76 | - Other - there are other types of packages as well, these are the main 77 | package types seen in the wild 78 | 79 | 3. Fill out the package contents: 80 | 81 | - tools\chocolateyBeforeModify.ps1 - remove if you have no processes 82 | or services to shut down before upgrade/uninstall 83 | - tools\LICENSE.txt / tools\VERIFICATION.txt - Remove if you are not 84 | embedding binaries. Keep and fill out if you are embedding binaries 85 | in the package AND pushing to the community repository, even if you 86 | are the author of software. The file becomes easier to fill out 87 | (does not require changes each version) if you are the software 88 | vendor. If you are building packages for internal use (organization, 89 | etc), you don't need these files as you are not subject to 90 | distribution rights internally. 91 | - tools\chocolateyUninstall.ps1 - remove if autouninstaller can 92 | automatically uninstall and you have nothing additional to do during 93 | uninstall 94 | - Readme.txt - delete this file once you have read over and used 95 | anything you've needed from here 96 | - nuspec - fill this out, then clean out all the comments (you may wish 97 | to leave the headers for the package vs software metadata) 98 | - tools\chocolateyInstall.ps1 - instructions in next section. 99 | 100 | 4. ChocolateyInstall.ps1: 101 | 102 | - For embedded binaries - use `$fileLocation` (`$file`/`$file64`) and 103 | `Install-ChocolateyInstallPackage`/ `Get-ChocolateyUnzip`. 104 | - Downloading binaries at runtime - use `$url`/`$url64` and 105 | `Install-ChocolateyPackage` / `Install-ChocolateyZipPackage`. 106 | - Other needs (creating files, setting registry keys), use regular 107 | PowerShell to do so or see if there is a function already defined: 108 | https://chocolatey.org/docs/helpers-reference 109 | - There may also be functions available in extension packages, see 110 | https://chocolatey.org/packages?q=id%3A.extension for examples and 111 | availability. 112 | - Clean out the comments and sections you are not using. 113 | 114 | 5. Test the package to ensure install/uninstall work appropriately. 115 | There is a test environment you can use for this - 116 | https://github.com/chocolatey/chocolatey-test-environment 117 | 118 | 6. Learn more about Chocolatey packaging - go through the workshop at 119 | https://github.com/ferventcoder/chocolatey-workshop 120 | You will learn about 121 | - General packaging 122 | - Customizing package behavior at runtime (package parameters) 123 | - Extension packages 124 | - Custom packaging templates 125 | - Setting up an internal Chocolatey.Server repository 126 | - Adding and using internal repositories 127 | - Reporting 128 | - Advanced packaging techniques when installers are not friendly to 129 | automation 130 | 131 | 7. Delete this file. 132 | -------------------------------------------------------------------------------- /_build/nupkg/rendercli.nuspec.template: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" encoding="utf-8"?> 2 | <!-- Do not remove this test for UTF-8: if “Ω” doesn’t appear as greek uppercase omega letter enclosed in quotation marks, you should use an editor that supports UTF-8, not this one. --> 3 | <package xmlns="http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd"> 4 | <metadata> 5 | <id>rendercli</id> 6 | <version>NUSPEC_PACKAGE_VERSION</version> 7 | <packageSourceUrl>https://github.com/render-oss/render-cli</packageSourceUrl> 8 | <owners>Render Inc.</owners> 9 | 10 | <title>Render CLI 11 | Render Inc. 12 | https://github.com/render-oss/render-cli 13 | https://github.com/render-oss/render-cli/blob/main/LICENSE 14 | false 15 | https://github.com/render-oss/render-cli 16 | https://render.com/docs 17 | https://github.com/render-oss/render-cli 18 | render cloud paas 19 | Launch your next project into the cloud with Render 20 | 21 | Render is a unified cloud to build and run all your apps and websites with free TLS certificates, a global CDN, DDoS protection, private networks, and auto deploys from Git. 22 | 23 | By using our command-line tools you'll be able to quickly kick off new projects from scratch or from our library of examples. Once you're live, you can also use the Render CLI to get information about the state of your infrastructure and to make changes as you see fit. 24 | 25 | 26 | https://github.com/render-oss/render-cli/releases 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /_build/nupkg/tools/LICENSE.txt: -------------------------------------------------------------------------------- 1 |  2 | Note: Include this file if including binaries you have the right to distribute. 3 | Otherwise delete. this file. 4 | 5 | ===DELETE ABOVE THIS LINE AND THIS LINE=== 6 | 7 | From: 8 | 9 | LICENSE 10 | 11 | 12 | -------------------------------------------------------------------------------- /_build/nupkg/tools/VERIFICATION.txt: -------------------------------------------------------------------------------- 1 | VERIFICATION 2 | Verification is intended to assist the Chocolatey moderators and community 3 | in verifying that this package's contents are trustworthy. 4 | 5 | Render, Inc. manages the render-cli package as well as its source code, and 6 | ships binaries herein. -------------------------------------------------------------------------------- /_build/write-version.bash: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | ROOT=$(dirname "$0") 4 | cd "$ROOT" || exit 6 5 | cd .. || exit 7 6 | 7 | SUFFIX="${1:-default}" 8 | 9 | if [[ -d './.git' ]]; then 10 | VERSION="$(git describe --tags)" 11 | else 12 | VERSION="nogit" 13 | fi 14 | 15 | echo "export const VERSION = '$VERSION-$SUFFIX' as const;" 16 | -------------------------------------------------------------------------------- /_data/render.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$comment": "DO NOT EDIT THIS FILE MANUALLY. IT IS GENERATED BY RUNNING `npm run generate`.", 3 | "$schema": "http://json-schema.org/draft-07/schema", 4 | "$id": "http://render.com/schemas/renderSchema.json", 5 | "type": "object", 6 | "title": "render.yaml schema", 7 | "description": "The root schema comprises the entire JSON document.", 8 | "default": {}, 9 | "additionalProperties": false, 10 | "properties": { 11 | "previewsEnabled": { 12 | "$comment": "Is this deprecated? It is not mentioned in docs.", 13 | "type": "boolean" 14 | }, 15 | "previewsExpireAfterDays": { 16 | "$comment": "Is this deprecated? It is not mentioned in docs.", 17 | "type": "number" 18 | }, 19 | "services": { 20 | "$schema": "http://json-schema.org/draft-07/schema", 21 | "$id": "subschemas/services.json", 22 | "type": "array", 23 | "title": "The services schema", 24 | "description": "An explanation about the purpose of this instance.", 25 | "default": [], 26 | "items": { 27 | "$id": "#/properties/services/items", 28 | "type": "object", 29 | "required": [ 30 | "type", 31 | "name" 32 | ], 33 | "additionalProperties": false, 34 | "properties": { 35 | "type": { 36 | "type": "string", 37 | "enum": [ 38 | "web", 39 | "worker", 40 | "pserv", 41 | "cron", 42 | "redis" 43 | ] 44 | }, 45 | "name": { 46 | "type": "string" 47 | }, 48 | "env": { 49 | "type": "string", 50 | "enum": [ 51 | "docker", 52 | "elixir", 53 | "go", 54 | "node", 55 | "python", 56 | "ruby", 57 | "rust", 58 | "static" 59 | ] 60 | }, 61 | "repo": { 62 | "type": "string" 63 | }, 64 | "autoDeploy": { 65 | "type": "boolean" 66 | }, 67 | "branch": { 68 | "type": "string" 69 | }, 70 | "buildCommand": { 71 | "type": "string" 72 | }, 73 | "initialDeployHook": { 74 | "type": "string" 75 | }, 76 | "region": { 77 | "type": "string", 78 | "default": "oregon", 79 | "enum": [ 80 | "frankfurt", 81 | "oregon", 82 | "ohio", 83 | "singapore" 84 | ] 85 | }, 86 | "envVars": { 87 | "$schema": "http://json-schema.org/draft-07/schema", 88 | "$id": "subschemas/envVars.json", 89 | "type": "array", 90 | "title": "The envVars schema", 91 | "description": "An explanation about the purpose of this instance.", 92 | "default": [], 93 | "items": { 94 | "$id": "#/properties/envVars/items", 95 | "anyOf": [ 96 | { 97 | "$id": "#/properties/envVars/items/anyOf/0", 98 | "type": "object", 99 | "examples": [ 100 | { 101 | "key": "A", 102 | "value": "B" 103 | } 104 | ], 105 | "required": [ 106 | "key", 107 | "value" 108 | ], 109 | "additionalProperties": false, 110 | "properties": { 111 | "key": { 112 | "$id": "#/properties/envVars/items/anyOf/0/properties/key", 113 | "type": "string" 114 | }, 115 | "value": { 116 | "$id": "#/properties/envVars/items/anyOf/0/properties/value" 117 | }, 118 | "previewValue": { 119 | "$id": "#/properties/envVars/items/anyOf/0/properties/previewValue" 120 | } 121 | } 122 | }, 123 | { 124 | "$id": "#/properties/envVars/items/anyOf/1", 125 | "type": "object", 126 | "examples": [ 127 | { 128 | "key": "DATABASE_URL", 129 | "fromDatabase": { 130 | "name": "prod", 131 | "property": "connectionString" 132 | } 133 | } 134 | ], 135 | "required": [ 136 | "key", 137 | "fromDatabase" 138 | ], 139 | "additionalProperties": false, 140 | "properties": { 141 | "key": { 142 | "$id": "#/properties/envVars/items/anyOf/1/properties/key", 143 | "type": "string" 144 | }, 145 | "fromDatabase": { 146 | "$id": "#/properties/envVars/items/anyOf/1/properties/fromDatabase", 147 | "type": "object", 148 | "required": [ 149 | "name", 150 | "property" 151 | ], 152 | "additionalProperties": false, 153 | "properties": { 154 | "name": { 155 | "$id": "#/properties/envVars/items/anyOf/1/properties/fromDatabase/properties/name", 156 | "type": "string" 157 | }, 158 | "property": { 159 | "$id": "#/properties/envVars/items/anyOf/1/properties/fromDatabase/properties/property", 160 | "type": "string", 161 | "enum": [ 162 | "host", 163 | "port", 164 | "database", 165 | "user", 166 | "password", 167 | "connectionString" 168 | ] 169 | } 170 | } 171 | }, 172 | "previewValue": { 173 | "$id": "#/properties/envVars/items/anyOf/1/properties/previewValue" 174 | } 175 | } 176 | }, 177 | { 178 | "$id": "#/properties/envVars/items/anyOf/2", 179 | "type": "object", 180 | "examples": [ 181 | { 182 | "key": "REDIS_HOST", 183 | "fromService": { 184 | "name": "redis", 185 | "type": "pserv", 186 | "property": "host" 187 | } 188 | } 189 | ], 190 | "required": [ 191 | "key", 192 | "fromService" 193 | ], 194 | "additionalProperties": false, 195 | "properties": { 196 | "key": { 197 | "$id": "#/properties/envVars/items/anyOf/2/properties/key", 198 | "type": "string" 199 | }, 200 | "fromService": { 201 | "$id": "#/properties/envVars/items/anyOf/2/properties/fromService", 202 | "oneOf": [ 203 | { 204 | "type": "object", 205 | "required": [ 206 | "name", 207 | "type", 208 | "property" 209 | ], 210 | "additionalProperties": false, 211 | "properties": { 212 | "name": { 213 | "type": "string" 214 | }, 215 | "type": { 216 | "type": "string", 217 | "enum": [ 218 | "web", 219 | "worker", 220 | "pserv", 221 | "cron" 222 | ] 223 | }, 224 | "property": { 225 | "type": "string", 226 | "enum": [ 227 | "host", 228 | "port", 229 | "hostport" 230 | ] 231 | } 232 | } 233 | }, 234 | { 235 | "type": "object", 236 | "required": [ 237 | "name", 238 | "type", 239 | "envVarKey" 240 | ], 241 | "additionalProperties": false, 242 | "properties": { 243 | "name": { 244 | "type": "string" 245 | }, 246 | "type": { 247 | "type": "string", 248 | "enum": [ 249 | "web", 250 | "worker", 251 | "pserv", 252 | "cron" 253 | ] 254 | }, 255 | "envVarKey": { 256 | "type": "string" 257 | } 258 | } 259 | } 260 | ] 261 | }, 262 | "previewValue": { 263 | "$id": "#/properties/envVars/items/anyOf/2/properties/previewValue" 264 | } 265 | } 266 | }, 267 | { 268 | "$id": "#/properties/envVars/items/anyOf/3", 269 | "type": "object", 270 | "examples": [ 271 | { 272 | "key": "APP_SECRET", 273 | "generateValue": true 274 | } 275 | ], 276 | "required": [ 277 | "key", 278 | "generateValue" 279 | ], 280 | "additionalProperties": false, 281 | "properties": { 282 | "key": { 283 | "$id": "#/properties/envVars/items/anyOf/3/properties/key", 284 | "type": "string" 285 | }, 286 | "generateValue": { 287 | "$id": "#/properties/envVars/items/anyOf/3/properties/generateValue", 288 | "type": "boolean" 289 | }, 290 | "previewValue": { 291 | "$id": "#/properties/envVars/items/anyOf/3/properties/previewValue" 292 | } 293 | } 294 | }, 295 | { 296 | "$id": "#/properties/envVars/items/anyOf/4", 297 | "type": "object", 298 | "examples": [ 299 | { 300 | "key": "SOME_SECRET", 301 | "sync": false 302 | } 303 | ], 304 | "required": [ 305 | "key", 306 | "sync" 307 | ], 308 | "additionalProperties": false, 309 | "properties": { 310 | "key": { 311 | "$id": "#/properties/envVars/items/anyOf/4/properties/key", 312 | "type": "string" 313 | }, 314 | "sync": { 315 | "$id": "#/properties/envVars/items/anyOf/4/properties/sync", 316 | "type": "boolean", 317 | "default": false 318 | }, 319 | "previewValue": { 320 | "$id": "#/properties/envVars/items/anyOf/4/properties/previewValue" 321 | } 322 | } 323 | }, 324 | { 325 | "$id": "#/properties/envVars/items/anyOf/5", 326 | "type": "object", 327 | "title": "The sixth anyOf schema", 328 | "description": "An explanation about the purpose of this instance.", 329 | "default": {}, 330 | "examples": [ 331 | { 332 | "fromGroup": "my-env-group" 333 | } 334 | ], 335 | "required": [ 336 | "fromGroup" 337 | ], 338 | "properties": { 339 | "fromGroup": { 340 | "$id": "#/properties/envVars/items/anyOf/5/properties/fromGroup", 341 | "type": "string", 342 | "title": "The fromGroup schema", 343 | "description": "An explanation about the purpose of this instance.", 344 | "default": "", 345 | "examples": [ 346 | "my-env-group" 347 | ] 348 | } 349 | }, 350 | "additionalProperties": true 351 | } 352 | ] 353 | } 354 | }, 355 | "disk": { 356 | "$comment": "not valid for static env", 357 | "type": "object", 358 | "required": [ 359 | "name", 360 | "mountPath" 361 | ], 362 | "properties": { 363 | "name": { 364 | "type": "string" 365 | }, 366 | "mountPath": { 367 | "type": "string" 368 | }, 369 | "sizeGB": { 370 | "type": "integer" 371 | } 372 | } 373 | }, 374 | "dockerCommand": { 375 | "$comment": "for docker env only", 376 | "type": "string" 377 | }, 378 | "dockerContext": { 379 | "$comment": "for docker env only", 380 | "type": "string" 381 | }, 382 | "dockerfilePath": { 383 | "$comment": "for docker env only", 384 | "type": "string" 385 | }, 386 | "domains": { 387 | "$comment": "for web type only", 388 | "type": "array", 389 | "items": { 390 | "type": "string" 391 | } 392 | }, 393 | "headers": { 394 | "$comment": "only for static env", 395 | "type": "array", 396 | "items": { 397 | "type": "object", 398 | "required": [ 399 | "path", 400 | "name", 401 | "value" 402 | ], 403 | "properties": { 404 | "path": { 405 | "type": "string" 406 | }, 407 | "name": { 408 | "type": "string" 409 | }, 410 | "value": { 411 | "type": "string" 412 | } 413 | } 414 | } 415 | }, 416 | "maxmemoryPolicy": { 417 | "$comment": "for redis type only", 418 | "type": "string", 419 | "default": "allkeys-lru", 420 | "enum": [ 421 | "noeviction", 422 | "volatile-lru", 423 | "volatile-lfu", 424 | "allkeys-lfu", 425 | "volatile-random", 426 | "allkeys-random", 427 | "volatile-ttl" 428 | ] 429 | }, 430 | "healthCheckPath": { 431 | "type": "string" 432 | }, 433 | "numInstances": { 434 | "type": "integer", 435 | "default": 1 436 | }, 437 | "plan": { 438 | "type": "string", 439 | "enum": [ 440 | "free", 441 | "starter", 442 | "starter plus", 443 | "standard", 444 | "standard plus", 445 | "pro", 446 | "pro plus" 447 | ] 448 | }, 449 | "previewPlan": { 450 | "$comment": "Is this deprecated? It is not mentioned in docs.", 451 | "type": "string", 452 | "enum": [ 453 | "starter", 454 | "starter plus", 455 | "standard", 456 | "standard plus", 457 | "pro", 458 | "pro plus" 459 | ] 460 | }, 461 | "pullRequestPreviewsEnabled": { 462 | "$comment": "only for web type", 463 | "type": "boolean" 464 | }, 465 | "routes": { 466 | "$comment": "only for static env", 467 | "type": "array", 468 | "items": { 469 | "type": "object", 470 | "required": [ 471 | "type", 472 | "source", 473 | "destination" 474 | ], 475 | "properties": { 476 | "type": { 477 | "type": "string", 478 | "enum": [ 479 | "redirect", 480 | "rewrite" 481 | ] 482 | }, 483 | "source": { 484 | "type": "string" 485 | }, 486 | "destination": { 487 | "type": "string" 488 | } 489 | } 490 | } 491 | }, 492 | "scaling": { 493 | "type": "object", 494 | "properties": { 495 | "minInstances": { 496 | "type": "integer" 497 | }, 498 | "maxInstances": { 499 | "type": "integer" 500 | }, 501 | "targetMemoryPercent": { 502 | "type": "integer" 503 | }, 504 | "targetCPUPercent": { 505 | "type": "integer" 506 | } 507 | } 508 | }, 509 | "schedule": { 510 | "$comment": "only for cron type", 511 | "type": "string" 512 | }, 513 | "startCommand": { 514 | "type": "string" 515 | }, 516 | "staticPublishPath": { 517 | "type": "string" 518 | }, 519 | "ipAllowList": { 520 | "$comment": "only for Redis", 521 | "type": "array", 522 | "items": { 523 | "type": "object", 524 | "required": [ 525 | "source", 526 | "description" 527 | ], 528 | "additionalProperties": false, 529 | "properties": { 530 | "source": { 531 | "type": "string" 532 | }, 533 | "description": { 534 | "type": "string" 535 | } 536 | } 537 | } 538 | } 539 | }, 540 | "allOf": [ 541 | { 542 | "$comment": "Conditionally disallow certain properties" 543 | }, 544 | { 545 | "$comment": "Disallow docker-related commands unless `env` is `docker`.", 546 | "if": { 547 | "not": { 548 | "properties": { 549 | "env": { 550 | "const": "docker" 551 | } 552 | } 553 | } 554 | }, 555 | "then": { 556 | "not": { 557 | "anyOf": [ 558 | { 559 | "required": [ 560 | "dockerCommand" 561 | ] 562 | }, 563 | { 564 | "required": [ 565 | "dockerContext" 566 | ] 567 | }, 568 | { 569 | "required": [ 570 | "dockerfilePath" 571 | ] 572 | } 573 | ] 574 | } 575 | } 576 | }, 577 | { 578 | "$comment": "Disallow `domains` unless `type` is `web`.", 579 | "if": { 580 | "not": { 581 | "properties": { 582 | "type": { 583 | "const": "web" 584 | } 585 | } 586 | } 587 | }, 588 | "then": { 589 | "not": { 590 | "required": [ 591 | "domains" 592 | ] 593 | } 594 | } 595 | }, 596 | { 597 | "if": { 598 | "not": { 599 | "properties": { 600 | "env": { 601 | "const": "static" 602 | } 603 | } 604 | } 605 | }, 606 | "then": { 607 | "$comment": "Disallow `routes` and `headers` unless `env` is `static`.", 608 | "not": { 609 | "anyOf": [ 610 | { 611 | "required": [ 612 | "routes" 613 | ] 614 | }, 615 | { 616 | "required": [ 617 | "headers" 618 | ] 619 | }, 620 | { 621 | "required": [ 622 | "staticPublishPath" 623 | ] 624 | } 625 | ] 626 | } 627 | } 628 | }, 629 | { 630 | "$comment": "Disallow `disk` if `env` is `static`.", 631 | "if": { 632 | "properties": { 633 | "env": { 634 | "const": "static" 635 | } 636 | } 637 | }, 638 | "then": { 639 | "not": { 640 | "anyOf": [ 641 | { 642 | "required": [ 643 | "disk" 644 | ] 645 | }, 646 | { 647 | "required": [ 648 | "region" 649 | ] 650 | } 651 | ] 652 | } 653 | } 654 | }, 655 | { 656 | "$comment": "Disallow `maxmemoryPolicy` unless `type` is `redis`.", 657 | "if": { 658 | "not": { 659 | "properties": { 660 | "type": { 661 | "const": "redis" 662 | } 663 | } 664 | } 665 | }, 666 | "then": { 667 | "not": { 668 | "required": [ 669 | "maxmemoryPolicy" 670 | ] 671 | } 672 | } 673 | }, 674 | { 675 | "$comment": "Require `env` property if `type` is not `redis`.", 676 | "if": { 677 | "not": { 678 | "properties": { 679 | "type": { 680 | "const": "redis" 681 | } 682 | } 683 | } 684 | }, 685 | "then": { 686 | "required": [ 687 | "env" 688 | ] 689 | } 690 | }, 691 | { 692 | "$comment": "Disallow `schedule` unless `type` is `cron`.", 693 | "if": { 694 | "not": { 695 | "properties": { 696 | "type": { 697 | "const": "cron" 698 | } 699 | } 700 | } 701 | }, 702 | "then": { 703 | "not": { 704 | "required": [ 705 | "schedule" 706 | ] 707 | } 708 | } 709 | }, 710 | { 711 | "$comment": "Disallow `ipAllowList` unless `type` is `redis`.", 712 | "if": { 713 | "not": { 714 | "properties": { 715 | "type": { 716 | "const": "redis" 717 | } 718 | } 719 | } 720 | }, 721 | "then": { 722 | "not": { 723 | "required": [ 724 | "ipAllowList" 725 | ] 726 | } 727 | } 728 | } 729 | ] 730 | }, 731 | "definitions": { 732 | "docker": { 733 | "$comment": "Disallow docker-related commands unless `env` is `docker`.", 734 | "if": { 735 | "not": { 736 | "properties": { 737 | "env": { 738 | "const": "docker" 739 | } 740 | } 741 | } 742 | }, 743 | "then": { 744 | "not": { 745 | "anyOf": [ 746 | { 747 | "required": [ 748 | "dockerCommand" 749 | ] 750 | }, 751 | { 752 | "required": [ 753 | "dockerContext" 754 | ] 755 | }, 756 | { 757 | "required": [ 758 | "dockerfilePath" 759 | ] 760 | } 761 | ] 762 | } 763 | } 764 | }, 765 | "domains": { 766 | "$comment": "Disallow `domains` unless `type` is `web`.", 767 | "if": { 768 | "not": { 769 | "properties": { 770 | "type": { 771 | "const": "web" 772 | } 773 | } 774 | } 775 | }, 776 | "then": { 777 | "not": { 778 | "required": [ 779 | "domains" 780 | ] 781 | } 782 | } 783 | }, 784 | "not-static": { 785 | "if": { 786 | "not": { 787 | "properties": { 788 | "env": { 789 | "const": "static" 790 | } 791 | } 792 | } 793 | }, 794 | "then": { 795 | "$comment": "Disallow `routes` and `headers` unless `env` is `static`.", 796 | "not": { 797 | "anyOf": [ 798 | { 799 | "required": [ 800 | "routes" 801 | ] 802 | }, 803 | { 804 | "required": [ 805 | "headers" 806 | ] 807 | }, 808 | { 809 | "required": [ 810 | "staticPublishPath" 811 | ] 812 | } 813 | ] 814 | } 815 | } 816 | }, 817 | "static": { 818 | "$comment": "Disallow `disk` if `env` is `static`.", 819 | "if": { 820 | "properties": { 821 | "env": { 822 | "const": "static" 823 | } 824 | } 825 | }, 826 | "then": { 827 | "not": { 828 | "anyOf": [ 829 | { 830 | "required": [ 831 | "disk" 832 | ] 833 | }, 834 | { 835 | "required": [ 836 | "region" 837 | ] 838 | } 839 | ] 840 | } 841 | } 842 | }, 843 | "redis-maxmem": { 844 | "$comment": "Disallow `maxmemoryPolicy` unless `type` is `redis`.", 845 | "if": { 846 | "not": { 847 | "properties": { 848 | "type": { 849 | "const": "redis" 850 | } 851 | } 852 | } 853 | }, 854 | "then": { 855 | "not": { 856 | "required": [ 857 | "maxmemoryPolicy" 858 | ] 859 | } 860 | } 861 | }, 862 | "redis-no-env": { 863 | "$comment": "Require `env` property if `type` is not `redis`.", 864 | "if": { 865 | "not": { 866 | "properties": { 867 | "type": { 868 | "const": "redis" 869 | } 870 | } 871 | } 872 | }, 873 | "then": { 874 | "required": [ 875 | "env" 876 | ] 877 | } 878 | }, 879 | "cron": { 880 | "$comment": "Disallow `schedule` unless `type` is `cron`.", 881 | "if": { 882 | "not": { 883 | "properties": { 884 | "type": { 885 | "const": "cron" 886 | } 887 | } 888 | } 889 | }, 890 | "then": { 891 | "not": { 892 | "required": [ 893 | "schedule" 894 | ] 895 | } 896 | } 897 | }, 898 | "ipallowlist": { 899 | "$comment": "Disallow `ipAllowList` unless `type` is `redis`.", 900 | "if": { 901 | "not": { 902 | "properties": { 903 | "type": { 904 | "const": "redis" 905 | } 906 | } 907 | } 908 | }, 909 | "then": { 910 | "not": { 911 | "required": [ 912 | "ipAllowList" 913 | ] 914 | } 915 | } 916 | } 917 | } 918 | }, 919 | "databases": { 920 | "$schema": "http://json-schema.org/draft-07/schema", 921 | "$id": "subschemas/databases.json", 922 | "type": "array", 923 | "title": "The databases schema", 924 | "description": "An explanation about the purpose of this instance.", 925 | "default": [], 926 | "items": { 927 | "$id": "#/properties/databases/items", 928 | "type": "object", 929 | "required": [ 930 | "name" 931 | ], 932 | "additionalProperties": false, 933 | "properties": { 934 | "name": { 935 | "type": "string" 936 | }, 937 | "region": { 938 | "type": "string", 939 | "default": "oregon", 940 | "enum": [ 941 | "frankfurt", 942 | "oregon", 943 | "ohio", 944 | "singapore" 945 | ] 946 | }, 947 | "plan": { 948 | "type": "string", 949 | "default": "starter", 950 | "enum": [ 951 | "starter", 952 | "standard", 953 | "standard plus", 954 | "pro", 955 | "pro plus" 956 | ] 957 | }, 958 | "previewPlan": { 959 | "$comment": "Is this deprecated? It is not mentioned in docs.", 960 | "type": "string", 961 | "enum": [ 962 | "starter", 963 | "standard", 964 | "standard plus", 965 | "pro", 966 | "pro plus" 967 | ] 968 | }, 969 | "databaseName": { 970 | "type": "string" 971 | }, 972 | "user": { 973 | "type": "string" 974 | }, 975 | "ipAllowList": { 976 | "$comment": "What should the `default` value be? `null`? `[]` disallows all external IPs according to docs.", 977 | "type": "array", 978 | "items": { 979 | "type": "object", 980 | "required": [ 981 | "source", 982 | "description" 983 | ], 984 | "additionalProperties": false, 985 | "properties": { 986 | "source": { 987 | "type": "string" 988 | }, 989 | "description": { 990 | "type": "string" 991 | } 992 | } 993 | } 994 | }, 995 | "postgresMajorVersion": { 996 | "type": "integer", 997 | "enum": [ 998 | 11, 999 | 12, 1000 | 13 1001 | ] 1002 | } 1003 | } 1004 | } 1005 | }, 1006 | "envVarGroups": { 1007 | "$schema": "http://json-schema.org/draft-07/schema", 1008 | "$id": "subschemas/envVarGroups.json", 1009 | "type": "array", 1010 | "title": "The envVarGroups schema", 1011 | "description": "An explanation about the purpose of this instance.", 1012 | "default": [], 1013 | "items": { 1014 | "$id": "#/properties/envVarGroups/items", 1015 | "type": "object", 1016 | "required": [ 1017 | "name" 1018 | ], 1019 | "properties": { 1020 | "name": { 1021 | "type": "string" 1022 | }, 1023 | "envVars": { 1024 | "type": "array", 1025 | "items": { 1026 | "anyOf": [ 1027 | { 1028 | "type": "object", 1029 | "examples": [ 1030 | { 1031 | "key": "A", 1032 | "value": "B" 1033 | } 1034 | ], 1035 | "required": [ 1036 | "key", 1037 | "value" 1038 | ], 1039 | "additionalProperties": false, 1040 | "properties": { 1041 | "key": { 1042 | "type": "string" 1043 | }, 1044 | "value": {}, 1045 | "previewValue": {} 1046 | } 1047 | }, 1048 | { 1049 | "type": "object", 1050 | "examples": [ 1051 | { 1052 | "key": "APP_SECRET", 1053 | "generateValue": true 1054 | } 1055 | ], 1056 | "required": [ 1057 | "key", 1058 | "generateValue" 1059 | ], 1060 | "additionalProperties": false, 1061 | "properties": { 1062 | "key": { 1063 | "type": "string" 1064 | }, 1065 | "generateValue": { 1066 | "type": "boolean" 1067 | }, 1068 | "previewValue": {} 1069 | } 1070 | }, 1071 | { 1072 | "type": "object", 1073 | "examples": [ 1074 | { 1075 | "key": "SOME_SECRET", 1076 | "sync": false 1077 | } 1078 | ], 1079 | "required": [ 1080 | "key", 1081 | "sync" 1082 | ], 1083 | "additionalProperties": false, 1084 | "properties": { 1085 | "key": { 1086 | "type": "string" 1087 | }, 1088 | "sync": { 1089 | "type": "boolean", 1090 | "default": false 1091 | }, 1092 | "previewValue": {} 1093 | } 1094 | } 1095 | ] 1096 | } 1097 | } 1098 | } 1099 | } 1100 | } 1101 | } 1102 | } -------------------------------------------------------------------------------- /_share/bash/bash_completion.d/render.bash: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # bash completion support for render 3 | 4 | _render() { 5 | local word cur prev listFiles 6 | local -a opts 7 | COMPREPLY=() 8 | cur="${COMP_WORDS[COMP_CWORD]}" 9 | prev="${COMP_WORDS[COMP_CWORD-1]}" 10 | cmd="_" 11 | opts=() 12 | listFiles=0 13 | 14 | _render_complete() { 15 | local action="$1"; shift 16 | mapfile -t values < <( render completions complete "${action}" "${@}" ) 17 | for i in "${values[@]}"; do 18 | opts+=("$i") 19 | done 20 | } 21 | 22 | _render_expand() { 23 | [ "$cur" != "${cur%\\}" ] && cur="$cur\\" 24 | 25 | # expand ~username type directory specifications 26 | if [[ "$cur" == \~*/* ]]; then 27 | # shellcheck disable=SC2086 28 | eval cur=$cur 29 | 30 | elif [[ "$cur" == \~* ]]; then 31 | cur=${cur#\~} 32 | # shellcheck disable=SC2086,SC2207 33 | COMPREPLY=( $( compgen -P '~' -u $cur ) ) 34 | return ${#COMPREPLY[@]} 35 | fi 36 | } 37 | 38 | # shellcheck disable=SC2120 39 | _render_file_dir() { 40 | listFiles=1 41 | local IFS=$'\t\n' xspec #glob 42 | _render_expand || return 0 43 | 44 | if [ "${1:-}" = -d ]; then 45 | # shellcheck disable=SC2206,SC2207,SC2086 46 | COMPREPLY=( ${COMPREPLY[@]:-} $( compgen -d -- $cur ) ) 47 | #eval "$glob" # restore glob setting. 48 | return 0 49 | fi 50 | 51 | xspec=${1:+"!*.$1"} # set only if glob passed in as $1 52 | # shellcheck disable=SC2206,SC2207 53 | COMPREPLY=( ${COMPREPLY[@]:-} $( compgen -f -X "$xspec" -- "$cur" ) $( compgen -d -- "$cur" ) ) 54 | } 55 | 56 | __render() { 57 | opts=(-h --help -v --verbose --non-interactive --pretty-json --json-record-per-line -p --profile -r --region version commands config regions repo blueprint buildpack services deploys jobs custom-domains completions) 58 | 59 | if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then 60 | return 0 61 | fi 62 | case "${prev}" in 63 | -h|--help) opts=() ;; 64 | -v|--verbose) ;; 65 | --non-interactive) ;; 66 | --pretty-json) ;; 67 | --json-record-per-line) ;; 68 | -p|--profile) opts=(); _render_complete string ;; 69 | -r|--region) opts=(); _render_complete string ;; 70 | esac 71 | } 72 | 73 | __render_version() { 74 | opts=(-h --help -v --verbose --non-interactive --pretty-json --json-record-per-line -p --profile -r --region) 75 | 76 | if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then 77 | return 0 78 | fi 79 | case "${prev}" in 80 | -h|--help) opts=() ;; 81 | -v|--verbose) ;; 82 | --non-interactive) ;; 83 | --pretty-json) ;; 84 | --json-record-per-line) ;; 85 | -p|--profile) opts=(); _render_complete string version ;; 86 | -r|--region) opts=(); _render_complete string version ;; 87 | esac 88 | } 89 | 90 | __render_commands() { 91 | opts=(-h --help -v --verbose --non-interactive --pretty-json --json-record-per-line -p --profile -r --region) 92 | 93 | if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then 94 | return 0 95 | fi 96 | case "${prev}" in 97 | -h|--help) opts=() ;; 98 | -v|--verbose) ;; 99 | --non-interactive) ;; 100 | --pretty-json) ;; 101 | --json-record-per-line) ;; 102 | -p|--profile) opts=(); _render_complete string commands ;; 103 | -r|--region) opts=(); _render_complete string commands ;; 104 | esac 105 | } 106 | 107 | __render_config() { 108 | opts=(-h --help -v --verbose --non-interactive --pretty-json --json-record-per-line -p --profile -r --region schema init) 109 | 110 | if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then 111 | return 0 112 | fi 113 | case "${prev}" in 114 | -h|--help) opts=() ;; 115 | -v|--verbose) ;; 116 | --non-interactive) ;; 117 | --pretty-json) ;; 118 | --json-record-per-line) ;; 119 | -p|--profile) opts=(); _render_complete string config ;; 120 | -r|--region) opts=(); _render_complete string config ;; 121 | esac 122 | } 123 | 124 | __render_config_schema() { 125 | opts=(-h --help -v --verbose --non-interactive --pretty-json --json-record-per-line -p --profile -r --region) 126 | 127 | if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then 128 | return 0 129 | fi 130 | case "${prev}" in 131 | -h|--help) opts=() ;; 132 | -v|--verbose) ;; 133 | --non-interactive) ;; 134 | --pretty-json) ;; 135 | --json-record-per-line) ;; 136 | -p|--profile) opts=(); _render_complete string config schema ;; 137 | -r|--region) opts=(); _render_complete string config schema ;; 138 | esac 139 | } 140 | 141 | __render_config_init() { 142 | opts=(-h --help -v --verbose --non-interactive --pretty-json --json-record-per-line -p --profile -r --region -f --force) 143 | 144 | if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then 145 | return 0 146 | fi 147 | case "${prev}" in 148 | -h|--help) opts=() ;; 149 | -v|--verbose) ;; 150 | --non-interactive) ;; 151 | --pretty-json) ;; 152 | --json-record-per-line) ;; 153 | -p|--profile) opts=(); _render_complete string config init ;; 154 | -r|--region) opts=(); _render_complete string config init ;; 155 | -f|--force) ;; 156 | esac 157 | } 158 | 159 | __render_regions() { 160 | opts=(-h --help -v --verbose --non-interactive --pretty-json --json-record-per-line -p --profile -r --region) 161 | 162 | if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then 163 | return 0 164 | fi 165 | case "${prev}" in 166 | -h|--help) opts=() ;; 167 | -v|--verbose) ;; 168 | --non-interactive) ;; 169 | --pretty-json) ;; 170 | --json-record-per-line) ;; 171 | -p|--profile) opts=(); _render_complete string regions ;; 172 | -r|--region) opts=(); _render_complete string regions ;; 173 | esac 174 | } 175 | 176 | __render_repo() { 177 | opts=(-h --help -v --verbose --non-interactive --pretty-json --json-record-per-line -p --profile -r --region from-template) 178 | 179 | if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then 180 | return 0 181 | fi 182 | case "${prev}" in 183 | -h|--help) opts=() ;; 184 | -v|--verbose) ;; 185 | --non-interactive) ;; 186 | --pretty-json) ;; 187 | --json-record-per-line) ;; 188 | -p|--profile) opts=(); _render_complete string repo ;; 189 | -r|--region) opts=(); _render_complete string repo ;; 190 | esac 191 | } 192 | 193 | __render_repo_from_template() { 194 | opts=(-h --help -v --verbose --non-interactive --pretty-json --json-record-per-line -p --profile -r --region -o --output-directory -f --force --skip-cleanup) 195 | _render_complete string repo from-template 196 | if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then 197 | return 0 198 | fi 199 | case "${prev}" in 200 | -h|--help) opts=() ;; 201 | -v|--verbose) ;; 202 | --non-interactive) ;; 203 | --pretty-json) ;; 204 | --json-record-per-line) ;; 205 | -p|--profile) opts=(); _render_complete string repo from-template ;; 206 | -r|--region) opts=(); _render_complete string repo from-template ;; 207 | -o|--output-directory) opts=(); _render_complete string repo from-template ;; 208 | -f|--force) ;; 209 | --skip-cleanup) ;; 210 | esac 211 | } 212 | 213 | __render_blueprint() { 214 | opts=(-h --help -v --verbose --non-interactive --pretty-json --json-record-per-line -p --profile -r --region launch) 215 | 216 | if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then 217 | return 0 218 | fi 219 | case "${prev}" in 220 | -h|--help) opts=() ;; 221 | -v|--verbose) ;; 222 | --non-interactive) ;; 223 | --pretty-json) ;; 224 | --json-record-per-line) ;; 225 | -p|--profile) opts=(); _render_complete string blueprint ;; 226 | -r|--region) opts=(); _render_complete string blueprint ;; 227 | esac 228 | } 229 | 230 | __render_blueprint_launch() { 231 | opts=(-h --help -v --verbose --non-interactive --pretty-json --json-record-per-line -p --profile -r --region -l --link -r --remote) 232 | _render_complete string blueprint launch 233 | if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then 234 | return 0 235 | fi 236 | case "${prev}" in 237 | -h|--help) opts=() ;; 238 | -v|--verbose) ;; 239 | --non-interactive) ;; 240 | --pretty-json) ;; 241 | --json-record-per-line) ;; 242 | -p|--profile) opts=(); _render_complete string blueprint launch ;; 243 | -r|--region) opts=(); _render_complete string blueprint launch ;; 244 | -l|--link) ;; 245 | -r|--remote) opts=(); _render_complete string blueprint launch ;; 246 | esac 247 | } 248 | 249 | __render_buildpack() { 250 | opts=(-h --help -v --verbose --non-interactive --pretty-json --json-record-per-line -p --profile -r --region init remove add) 251 | 252 | if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then 253 | return 0 254 | fi 255 | case "${prev}" in 256 | -h|--help) opts=() ;; 257 | -v|--verbose) ;; 258 | --non-interactive) ;; 259 | --pretty-json) ;; 260 | --json-record-per-line) ;; 261 | -p|--profile) opts=(); _render_complete string buildpack ;; 262 | -r|--region) opts=(); _render_complete string buildpack ;; 263 | esac 264 | } 265 | 266 | __render_buildpack_init() { 267 | opts=(-h --help -v --verbose --non-interactive --pretty-json --json-record-per-line -p --profile -r --region -f --force --dir --skip-dockerfile) 268 | _render_complete string buildpack init 269 | if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then 270 | return 0 271 | fi 272 | case "${prev}" in 273 | -h|--help) opts=() ;; 274 | -v|--verbose) ;; 275 | --non-interactive) ;; 276 | --pretty-json) ;; 277 | --json-record-per-line) ;; 278 | -p|--profile) opts=(); _render_complete string buildpack init ;; 279 | -r|--region) opts=(); _render_complete string buildpack init ;; 280 | -f|--force) ;; 281 | --dir) opts=(); _render_complete string buildpack init ;; 282 | --skip-dockerfile) ;; 283 | esac 284 | } 285 | 286 | __render_buildpack_remove() { 287 | opts=(-h --help -v --verbose --non-interactive --pretty-json --json-record-per-line -p --profile -r --region --dir) 288 | _render_complete string buildpack remove 289 | if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then 290 | return 0 291 | fi 292 | case "${prev}" in 293 | -h|--help) opts=() ;; 294 | -v|--verbose) ;; 295 | --non-interactive) ;; 296 | --pretty-json) ;; 297 | --json-record-per-line) ;; 298 | -p|--profile) opts=(); _render_complete string buildpack remove ;; 299 | -r|--region) opts=(); _render_complete string buildpack remove ;; 300 | --dir) opts=(); _render_complete string buildpack remove ;; 301 | esac 302 | } 303 | 304 | __render_buildpack_add() { 305 | opts=(-h --help -v --verbose --non-interactive --pretty-json --json-record-per-line -p --profile -r --region --dir) 306 | _render_complete string buildpack add 307 | if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then 308 | return 0 309 | fi 310 | case "${prev}" in 311 | -h|--help) opts=() ;; 312 | -v|--verbose) ;; 313 | --non-interactive) ;; 314 | --pretty-json) ;; 315 | --json-record-per-line) ;; 316 | -p|--profile) opts=(); _render_complete string buildpack add ;; 317 | -r|--region) opts=(); _render_complete string buildpack add ;; 318 | --dir) opts=(); _render_complete string buildpack add ;; 319 | esac 320 | } 321 | 322 | __render_services() { 323 | opts=(-h --help -v --verbose --non-interactive --pretty-json --json-record-per-line -p --profile -r --region show list tail ssh) 324 | 325 | if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then 326 | return 0 327 | fi 328 | case "${prev}" in 329 | -h|--help) opts=() ;; 330 | -v|--verbose) ;; 331 | --non-interactive) ;; 332 | --pretty-json) ;; 333 | --json-record-per-line) ;; 334 | -p|--profile) opts=(); _render_complete string services ;; 335 | -r|--region) opts=(); _render_complete string services ;; 336 | esac 337 | } 338 | 339 | __render_services_show() { 340 | opts=(-h --help -v --verbose --non-interactive --pretty-json --json-record-per-line -p --profile -r --region --id) 341 | 342 | if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then 343 | return 0 344 | fi 345 | case "${prev}" in 346 | -h|--help) opts=() ;; 347 | -v|--verbose) ;; 348 | --non-interactive) ;; 349 | --pretty-json) ;; 350 | --json-record-per-line) ;; 351 | -p|--profile) opts=(); _render_complete string services show ;; 352 | -r|--region) opts=(); _render_complete string services show ;; 353 | --id) opts=(); _render_complete string services show ;; 354 | esac 355 | } 356 | 357 | __render_services_list() { 358 | opts=(-h --help -v --verbose --non-interactive --pretty-json --json-record-per-line -p --profile -r --region --format --columns --name --type --env --service-region --ownerid --created-before --created-after --updated-before --updated-after) 359 | 360 | if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then 361 | return 0 362 | fi 363 | case "${prev}" in 364 | -h|--help) opts=() ;; 365 | -v|--verbose) ;; 366 | --non-interactive) ;; 367 | --pretty-json) ;; 368 | --json-record-per-line) ;; 369 | -p|--profile) opts=(); _render_complete string services list ;; 370 | -r|--region) opts=(); _render_complete string services list ;; 371 | --format) opts=(); _render_complete string services list ;; 372 | --columns) opts=(); _render_complete string services list ;; 373 | --name) opts=(); _render_complete string services list ;; 374 | --type) opts=(); _render_complete string services list ;; 375 | --env) opts=(); _render_complete string services list ;; 376 | --service-region) opts=(); _render_complete string services list ;; 377 | --ownerid) opts=(); _render_complete string services list ;; 378 | --created-before) opts=(); _render_complete string services list ;; 379 | --created-after) opts=(); _render_complete string services list ;; 380 | --updated-before) opts=(); _render_complete string services list ;; 381 | --updated-after) opts=(); _render_complete string services list ;; 382 | esac 383 | } 384 | 385 | __render_services_tail() { 386 | opts=(-h --help -v --verbose --non-interactive --pretty-json --json-record-per-line -p --profile -r --region --raw --json --deploy-id --id) 387 | 388 | if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then 389 | return 0 390 | fi 391 | case "${prev}" in 392 | -h|--help) opts=() ;; 393 | -v|--verbose) ;; 394 | --non-interactive) ;; 395 | --pretty-json) ;; 396 | --json-record-per-line) ;; 397 | -p|--profile) opts=(); _render_complete string services tail ;; 398 | -r|--region) opts=(); _render_complete string services tail ;; 399 | --raw) ;; 400 | --json) ;; 401 | --deploy-id) opts=(); _render_complete string services tail ;; 402 | --id) opts=(); _render_complete string services tail ;; 403 | esac 404 | } 405 | 406 | __render_services_ssh() { 407 | opts=(-h --help -v --verbose --non-interactive --pretty-json --json-record-per-line -p --profile -r --region --preserve-hosts --id) 408 | _render_complete string services ssh 409 | if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then 410 | return 0 411 | fi 412 | case "${prev}" in 413 | -h|--help) opts=() ;; 414 | -v|--verbose) ;; 415 | --non-interactive) ;; 416 | --pretty-json) ;; 417 | --json-record-per-line) ;; 418 | -p|--profile) opts=(); _render_complete string services ssh ;; 419 | -r|--region) opts=(); _render_complete string services ssh ;; 420 | --preserve-hosts) ;; 421 | --id) opts=(); _render_complete string services ssh ;; 422 | esac 423 | } 424 | 425 | __render_deploys() { 426 | opts=(-h --help -v --verbose --non-interactive --pretty-json --json-record-per-line -p --profile -r --region list) 427 | 428 | if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then 429 | return 0 430 | fi 431 | case "${prev}" in 432 | -h|--help) opts=() ;; 433 | -v|--verbose) ;; 434 | --non-interactive) ;; 435 | --pretty-json) ;; 436 | --json-record-per-line) ;; 437 | -p|--profile) opts=(); _render_complete string deploys ;; 438 | -r|--region) opts=(); _render_complete string deploys ;; 439 | esac 440 | } 441 | 442 | __render_deploys_list() { 443 | opts=(-h --help -v --verbose --non-interactive --pretty-json --json-record-per-line -p --profile -r --region --format --columns --service-id --start-time --end-time) 444 | 445 | if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then 446 | return 0 447 | fi 448 | case "${prev}" in 449 | -h|--help) opts=() ;; 450 | -v|--verbose) ;; 451 | --non-interactive) ;; 452 | --pretty-json) ;; 453 | --json-record-per-line) ;; 454 | -p|--profile) opts=(); _render_complete string deploys list ;; 455 | -r|--region) opts=(); _render_complete string deploys list ;; 456 | --format) opts=(); _render_complete string deploys list ;; 457 | --columns) opts=(); _render_complete string deploys list ;; 458 | --service-id) opts=(); _render_complete string deploys list ;; 459 | --start-time) opts=(); _render_complete number deploys list ;; 460 | --end-time) opts=(); _render_complete number deploys list ;; 461 | esac 462 | } 463 | 464 | __render_jobs() { 465 | opts=(-h --help -v --verbose --non-interactive --pretty-json --json-record-per-line -p --profile -r --region list) 466 | 467 | if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then 468 | return 0 469 | fi 470 | case "${prev}" in 471 | -h|--help) opts=() ;; 472 | -v|--verbose) ;; 473 | --non-interactive) ;; 474 | --pretty-json) ;; 475 | --json-record-per-line) ;; 476 | -p|--profile) opts=(); _render_complete string jobs ;; 477 | -r|--region) opts=(); _render_complete string jobs ;; 478 | esac 479 | } 480 | 481 | __render_jobs_list() { 482 | opts=(-h --help -v --verbose --non-interactive --pretty-json --json-record-per-line -p --profile -r --region --format --columns --service-id --status --created-before --created-after --started-before --started-after --finished-before --finished-after) 483 | 484 | if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then 485 | return 0 486 | fi 487 | case "${prev}" in 488 | -h|--help) opts=() ;; 489 | -v|--verbose) ;; 490 | --non-interactive) ;; 491 | --pretty-json) ;; 492 | --json-record-per-line) ;; 493 | -p|--profile) opts=(); _render_complete string jobs list ;; 494 | -r|--region) opts=(); _render_complete string jobs list ;; 495 | --format) opts=(); _render_complete string jobs list ;; 496 | --columns) opts=(); _render_complete string jobs list ;; 497 | --service-id) opts=(); _render_complete string jobs list ;; 498 | --status) opts=(); _render_complete string jobs list ;; 499 | --created-before) opts=(); _render_complete string jobs list ;; 500 | --created-after) opts=(); _render_complete string jobs list ;; 501 | --started-before) opts=(); _render_complete string jobs list ;; 502 | --started-after) opts=(); _render_complete string jobs list ;; 503 | --finished-before) opts=(); _render_complete string jobs list ;; 504 | --finished-after) opts=(); _render_complete string jobs list ;; 505 | esac 506 | } 507 | 508 | __render_custom_domains() { 509 | opts=(-h --help -v --verbose --non-interactive --pretty-json --json-record-per-line -p --profile -r --region list) 510 | 511 | if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then 512 | return 0 513 | fi 514 | case "${prev}" in 515 | -h|--help) opts=() ;; 516 | -v|--verbose) ;; 517 | --non-interactive) ;; 518 | --pretty-json) ;; 519 | --json-record-per-line) ;; 520 | -p|--profile) opts=(); _render_complete string custom-domains ;; 521 | -r|--region) opts=(); _render_complete string custom-domains ;; 522 | esac 523 | } 524 | 525 | __render_custom_domains_list() { 526 | opts=(-h --help -v --verbose --non-interactive --pretty-json --json-record-per-line -p --profile -r --region --format --columns --service-id --name --domain-type --verification-status --created-before --created-after) 527 | 528 | if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then 529 | return 0 530 | fi 531 | case "${prev}" in 532 | -h|--help) opts=() ;; 533 | -v|--verbose) ;; 534 | --non-interactive) ;; 535 | --pretty-json) ;; 536 | --json-record-per-line) ;; 537 | -p|--profile) opts=(); _render_complete string custom-domains list ;; 538 | -r|--region) opts=(); _render_complete string custom-domains list ;; 539 | --format) opts=(); _render_complete string custom-domains list ;; 540 | --columns) opts=(); _render_complete string custom-domains list ;; 541 | --service-id) opts=(); _render_complete string custom-domains list ;; 542 | --name) opts=(); _render_complete string custom-domains list ;; 543 | --domain-type) opts=(); _render_complete string custom-domains list ;; 544 | --verification-status) opts=(); _render_complete string custom-domains list ;; 545 | --created-before) opts=(); _render_complete string custom-domains list ;; 546 | --created-after) opts=(); _render_complete string custom-domains list ;; 547 | esac 548 | } 549 | 550 | __render_completions() { 551 | opts=(-h --help bash fish zsh) 552 | 553 | if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then 554 | return 0 555 | fi 556 | case "${prev}" in 557 | -h|--help) opts=() ;; 558 | esac 559 | } 560 | 561 | __render_completions_bash() { 562 | opts=(-h --help) 563 | 564 | if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then 565 | return 0 566 | fi 567 | case "${prev}" in 568 | -h|--help) opts=() ;; 569 | esac 570 | } 571 | 572 | __render_completions_fish() { 573 | opts=(-h --help) 574 | 575 | if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then 576 | return 0 577 | fi 578 | case "${prev}" in 579 | -h|--help) opts=() ;; 580 | esac 581 | } 582 | 583 | __render_completions_zsh() { 584 | opts=(-h --help) 585 | 586 | if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then 587 | return 0 588 | fi 589 | case "${prev}" in 590 | -h|--help) opts=() ;; 591 | esac 592 | } 593 | 594 | for word in "${COMP_WORDS[@]}"; do 595 | case "${word}" in 596 | -*) ;; 597 | *) 598 | cmd_tmp="${cmd}_${word//[^[:alnum:]]/_}" 599 | if type "${cmd_tmp}" &>/dev/null; then 600 | cmd="${cmd_tmp}" 601 | fi 602 | esac 603 | done 604 | 605 | ${cmd} 606 | 607 | if [[ listFiles -eq 1 ]]; then 608 | return 0 609 | fi 610 | 611 | if [[ ${#opts[@]} -eq 0 ]]; then 612 | # shellcheck disable=SC2207 613 | COMPREPLY=($(compgen -f "${cur}")) 614 | return 0 615 | fi 616 | 617 | local values 618 | values="$( printf "\n%s" "${opts[@]}" )" 619 | local IFS=$'\n' 620 | # shellcheck disable=SC2207 621 | local result=($(compgen -W "${values[@]}" -- "${cur}")) 622 | if [[ ${#result[@]} -eq 0 ]]; then 623 | # shellcheck disable=SC2207 624 | COMPREPLY=($(compgen -f "${cur}")) 625 | else 626 | # shellcheck disable=SC2207 627 | COMPREPLY=($(printf '%q\n' "${result[@]}")) 628 | fi 629 | 630 | return 0 631 | } 632 | 633 | complete -F _render -o bashdefault -o default render 634 | -------------------------------------------------------------------------------- /_test/invalid-render.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | - type: web 3 | name: sveltekit 4 | env: nerd 5 | buildCommand: 42 6 | startCommand: 106 7 | -------------------------------------------------------------------------------- /_test/sveltekit-render.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | - type: web 3 | name: sveltekit 4 | env: node 5 | buildCommand: npm install && npm run build 6 | startCommand: node build/index.js 7 | -------------------------------------------------------------------------------- /api/config.ts: -------------------------------------------------------------------------------- 1 | import { RuntimeConfiguration } from "../config/types/index.ts"; 2 | import { APIKeyRequired } from "../errors.ts"; 3 | 4 | export function apiKeyOrThrow(cfg: RuntimeConfiguration): string { 5 | if (!cfg.profile.apiKey) { 6 | throw new APIKeyRequired(); 7 | } 8 | 9 | return cfg.profile.apiKey; 10 | } 11 | 12 | export function apiHost(cfg: RuntimeConfiguration) { 13 | return cfg.profile.apiHost ?? "api.render.com"; 14 | } 15 | -------------------------------------------------------------------------------- /api/error-handling.ts: -------------------------------------------------------------------------------- 1 | import { Log } from "../deps.ts"; 2 | import { getPaths } from "../util/paths.ts"; 3 | 4 | export async function handleApiErrors(logger: Log.Logger, fn: () => Promise): Promise { 5 | try { 6 | // for clarity: you almost never do `return await` but if we don't, the try-catch won't fire 7 | return await fn(); 8 | } catch (err) { 9 | const configFile = (await getPaths()).configFile; 10 | 11 | if (err instanceof DOMException) { 12 | // TODO: can this be better? 13 | // DOMException.code is deprecated and not very useful. I assume these are relatively safe. 14 | if (err.message.includes("404")) { 15 | logger.error('Command received a 404 Not Found. This usually means that') 16 | logger.error('no resource could be found with a given name or ID. If you') 17 | logger.error('used a resource ID directly, e.g. `srv-12345678`, please check') 18 | logger.error('it for correctness. If you used a resource name or slug, it\'s') 19 | logger.error('likely that we couldn\'t find a resource so named.'); 20 | } else if (err.message.includes("401")) { 21 | logger.error('Command received a 401 Unauthorized. This usually means that') 22 | logger.error('you haven\'t provided credentials to the Render API or the credentials') 23 | logger.error('are incorrect. Please check your API key in your config file,') 24 | logger.error(`stored at '${configFile}', and refresh it or add it if need be.`) 25 | } else if (err.message.includes("403")) { 26 | logger.error('Command received a 403 Forbidden. This usually means that while') 27 | logger.error('you have made a request with valid Render credentials, the API') 28 | logger.error('doesn\'t think you have access to that resource. Check to make sure') 29 | logger.error('you\'re using the right profile and that your user account is a member') 30 | logger.error('of the correct team.'); 31 | } 32 | } else { 33 | logger.error("Unrecognized error: " + JSON.stringify(err, null, 2)); 34 | } 35 | 36 | throw err; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /api/index.ts: -------------------------------------------------------------------------------- 1 | export { handleApiErrors } from './error-handling.ts'; 2 | export * from './config.ts'; 3 | export * from './requests.ts'; 4 | -------------------------------------------------------------------------------- /api/requests.ts: -------------------------------------------------------------------------------- 1 | // deno-lint-ignore-file no-explicit-any 2 | import { Log, QueryString } from "../deps.ts"; 3 | 4 | import { RuntimeConfiguration } from "../config/types/index.ts"; 5 | import { VERSION } from "../version.ts"; 6 | import { apiHost, apiKeyOrThrow } from "./config.ts"; 7 | import { handleApiErrors } from "./error-handling.ts"; 8 | 9 | function queryStringify(query: Record) { 10 | return QueryString.stringify(query, { 11 | arrayFormat: 'comma', 12 | }); 13 | } 14 | 15 | type httpMethod = 16 | | "GET" 17 | | "HEAD" 18 | | "POST" 19 | | "PUT" 20 | | "DELETE" 21 | | "CONNECT" 22 | | "OPTIONS" 23 | | "TRACE" 24 | | "PATCH"; 25 | 26 | let apiReqCount = 0; 27 | 28 | export function getRequestRaw( 29 | logger: Log.Logger, 30 | cfg: RuntimeConfiguration, 31 | path: string, 32 | query: Record = {} 33 | ): Promise { 34 | return requestRaw(logger, cfg, path, query, "GET"); 35 | } 36 | 37 | export function deleteRequestRaw( 38 | logger: Log.Logger, 39 | cfg: RuntimeConfiguration, 40 | path: string, 41 | query: Record = {} 42 | ): Promise { 43 | return requestRaw(logger, cfg, path, query, "DELETE"); 44 | } 45 | 46 | function requestRaw( 47 | logger: Log.Logger, 48 | cfg: RuntimeConfiguration, 49 | path: string, 50 | query: Record = {}, 51 | method: httpMethod = "GET" 52 | ): Promise { 53 | return handleApiErrors(logger, async () => { 54 | const reqNumber = apiReqCount++; 55 | const authorization = `Bearer ${apiKeyOrThrow(cfg)}`; 56 | 57 | if (!path.startsWith("/")) { 58 | path = "/" + path; 59 | } 60 | 61 | const url = `https://${apiHost(cfg)}/v1${path}?${queryStringify(query)}`; 62 | 63 | logger.debug(`api dispatch: ${reqNumber}: method: ${method} url ${url} (query: ${JSON.stringify(query)})`); 64 | 65 | const response = await fetch(url, { 66 | headers: { 67 | authorization, 68 | 'user-agent': `Render CLI/${VERSION}`, 69 | accept: 'application/json', 70 | }, 71 | method: method, 72 | }); 73 | if (!response.ok) { 74 | // this kind of has to be a DOMException because it encapsulates the notion of NetworkError 75 | // and we don't want to special-case WebSocketStream errors separately. Deno "is a browser", 76 | // in its mind, so this makes some sense. 77 | logger.error(`api error ${reqNumber}: ${JSON.stringify(response.json())}`); 78 | throw new DOMException(`Error calling ${url}: ${response.status} ${response.statusText}`); 79 | } 80 | 81 | return response; 82 | }); 83 | } 84 | 85 | export async function getRequestJSON( 86 | logger: Log.Logger, 87 | cfg: RuntimeConfiguration, 88 | path: string, 89 | query: Record = {}, 90 | ): Promise { 91 | const resp = await getRequestRaw(logger, cfg, path, query); 92 | 93 | return resp.json(); 94 | } 95 | 96 | export async function getRequestJSONList( 97 | logger: Log.Logger, 98 | cfg: RuntimeConfiguration, 99 | dataKey: string, 100 | path: string, 101 | query: Record = {}, 102 | ): Promise> { 103 | let cursor: string | null = null; 104 | const limit = Deno.env.get("RENDERCLI_LIST_LIMIT") ?? 100; 105 | 106 | let acc: Array = []; 107 | 108 | let iter = 0; 109 | while (true) { 110 | iter += 1; 111 | const q: typeof query = { 112 | limit, 113 | ...query, 114 | }; 115 | 116 | logger.debug(`Query #${iter} with cursor: ${cursor}`); 117 | 118 | if (cursor) { 119 | q.cursor = cursor; 120 | } 121 | 122 | // without using `dataKey` as a unique symbol (not reasonable here) we're kind of 123 | // stuck with an 'any', since we're pretty far out of type-safety land right now. 124 | const list = await getRequestJSON>(logger, cfg, path, q); 125 | const items = list.map(i => i[dataKey]); 126 | 127 | acc = [ 128 | ...acc, 129 | ...items, 130 | ]; 131 | logger.debug(`Query #${iter} returned ${list.length} items. Acc now ${acc.length} items.`); 132 | 133 | if (list.length === 0) { 134 | break; 135 | } 136 | 137 | cursor = list[list.length - 1].cursor; 138 | } 139 | 140 | return acc; 141 | } 142 | -------------------------------------------------------------------------------- /blueprints/validator.ts: -------------------------------------------------------------------------------- 1 | import { ajv } from "../util/ajv.ts"; 2 | 3 | import RenderYAMLSchema from '../_data/render.schema.json' assert { type: 'json' }; 4 | import { AjvErrorObject, YAML } from "../deps.ts"; 5 | 6 | 7 | export type ValidateSchemaArgs = 8 | | { path: string } 9 | | { content: string } 10 | | { object: unknown } 11 | ; 12 | 13 | export type ValidateSchemaRetDetails = { 14 | jsonSchema?: Array, 15 | } 16 | 17 | export type ValidateSchemaRet = 18 | | [false, ValidateSchemaRetDetails] 19 | | [true, null] 20 | ; 21 | 22 | export async function validateSchema(args: ValidateSchemaArgs): Promise { 23 | if ('path' in args) { 24 | const decoder = new TextDecoder(); 25 | const content = decoder.decode(await Deno.readFile(args.path)); 26 | return validateSchema({ content }); 27 | } 28 | 29 | if ('content' in args) { 30 | const object = YAML.load(args.content); 31 | return validateSchema({ object }); 32 | } 33 | 34 | const isValid = ajv.validate(RenderYAMLSchema, args.object); 35 | if (isValid) { 36 | return [true, null]; 37 | } 38 | 39 | return [false, { jsonSchema: ajv.errors ?? [] }]; 40 | } 41 | -------------------------------------------------------------------------------- /buildpack/constants.ts: -------------------------------------------------------------------------------- 1 | export const HEROKU_BUILDPACKS = [ 2 | "heroku/ruby", 3 | "heroku/nodejs", 4 | "heroku/clojure", 5 | "heroku/python", 6 | "heroku/java", 7 | "heroku/scala", 8 | "heroku/php", 9 | "heroku/go", 10 | ]; 11 | -------------------------------------------------------------------------------- /buildpack/crud.ts: -------------------------------------------------------------------------------- 1 | import { ForceRequiredError } from "../errors.ts"; 2 | import { ajv, assertType } from "../util/ajv.ts"; 3 | import { pathExists } from "../util/paths.ts"; 4 | import { DOCKERFILE_TEMPLATE } from "./templates/dockerfile_template.ts"; 5 | import { RenderBuildpackFile } from "./types.ts"; 6 | 7 | const BUILDPACKS_VALIDATOR = ajv.compile(RenderBuildpackFile); 8 | 9 | export async function initDockerfile(dir: string, force: boolean) { 10 | const f = `${dir}/Dockerfile.render`; 11 | if (await pathExists(f) && !force) { 12 | throw new ForceRequiredError('Dockerfile.render already exists and no --skip-dockerfile'); 13 | } 14 | 15 | await Deno.writeTextFile(f, DOCKERFILE_TEMPLATE); 16 | } 17 | 18 | export async function initBuildpacksFile(dir: string, buildpacks: Array, force: boolean) { 19 | const f = `${dir}/.render-buildpacks.json`; 20 | if (await pathExists(f) && !force) { 21 | throw new ForceRequiredError('.render-buildpacks.json already exists'); 22 | } 23 | 24 | return writeBuildpacksFile(dir, { 25 | version: 'v4', 26 | buildpacks, 27 | }); 28 | } 29 | 30 | export async function readBuildpacksFile(dir: string): Promise { 31 | const f = `${dir}/.render-buildpacks.json`; 32 | 33 | const content = JSON.parse(await Deno.readTextFile(f)); 34 | assertType(RenderBuildpackFile, content); 35 | 36 | return content; 37 | } 38 | 39 | export async function writeBuildpacksFile(dir: string, content: RenderBuildpackFile) { 40 | assertType(RenderBuildpackFile, content); 41 | 42 | const f = `${dir}/.render-buildpacks.json`; 43 | 44 | const json = JSON.stringify(content, null, 2); 45 | await Deno.writeTextFile(f, json); 46 | } 47 | -------------------------------------------------------------------------------- /buildpack/templates/dockerfile_template.ts: -------------------------------------------------------------------------------- 1 | export const DOCKERFILE_TEMPLATE = ` 2 | # "v4-" stacks use our new, more rigorous buildpacks management system. They 3 | # allow you to use multiple buildpacks in a single application, as well as to 4 | # use custom buildpacks. 5 | # 6 | # - \`v2-\` images work with heroku-import v3.x. 7 | # - \`v4-\` images work with heroku-import v4.x. (We synced the tags.) 8 | 9 | ARG IMPORT_VERSION=v4 10 | ARG HEROKU_STACK=\${IMPORT_VERSION}-heroku-22 11 | FROM ghcr.io/renderinc/heroku-app-builder:\${HEROKU_STACK} AS builder 12 | 13 | 14 | # Below, please specify any build-time environment variables that you need to 15 | # reference in your build (as called by your buildpacks). If you don't specify 16 | # the arg below, you won't be able to access it in your build. You can also 17 | # specify a default value, as with any Docker \`ARG\`, if appropriate for your 18 | # use case. 19 | 20 | # ARG MY_BUILD_TIME_ENV_VAR 21 | # ARG DATABASE_URL 22 | 23 | # The FROM statement above refers to an image with the base buildpacks already 24 | # in place. We then run the apply-buildpacks.py script here because, unlike our 25 | # \`v2\` image, this allows us to expose build-time env vars to your app. 26 | RUN /render/build-scripts/apply-buildpacks.py \${HEROKU_STACK} 27 | 28 | # We strongly recommend that you package a Procfile with your application, but 29 | # if you don't, we'll try to guess one for you. If this is incorrect, please 30 | # add a Procfile that tells us what you need us to run. 31 | RUN if [[ -f /app/Procfile ]]; then \ 32 | /render/build-scripts/create-process-types "/app/Procfile"; \ 33 | fi; 34 | 35 | # For running the app, we use a clean base image and also one without Ubuntu development packages 36 | # https://devcenter.heroku.com/articles/heroku-20-stack#heroku-20-docker-image 37 | FROM ghcr.io/renderinc/heroku-app-runner:\${HEROKU_STACK} AS runner 38 | 39 | # Here we copy your build artifacts from the build image to the runner so that 40 | # the image that we deploy to Render is smaller and, therefore, can start up 41 | # faster. 42 | COPY --from=builder --chown=1000:1000 /render /render/ 43 | COPY --from=builder --chown=1000:1000 /app /app/ 44 | 45 | # Here we're switching to a non-root user in the container to remove some categories 46 | # of container-escape attack. 47 | USER 1000:1000 48 | WORKDIR /app 49 | 50 | # This sources all /app/.profile.d/*.sh files before process start. 51 | # These are created by buildpacks, and you probably don't have to worry about this. 52 | # https://devcenter.heroku.com/articles/buildpack-api#profile-d-scripts 53 | ENTRYPOINT [ "/render/setup-env" ] 54 | 55 | # 3. By default, we run the 'web' process type defined in the app's Procfile 56 | # You may override the process type that is run by replacing 'web' with another 57 | # process type name in the CMD line below. That process type must have been 58 | # defined in the app's Procfile during build. 59 | CMD [ "/render/process/web" ] 60 | `; 61 | -------------------------------------------------------------------------------- /buildpack/types.ts: -------------------------------------------------------------------------------- 1 | import { type Static, Type } from "../deps.ts"; 2 | 3 | // `v4` is the assumed value of any buildpack until we change it. 4 | export const RenderBuildpackFile = Type.Object({ 5 | version: Type.Optional(Type.Literal('v4')), 6 | buildpacks: Type.Array(Type.String(), { minItems: 1 }), 7 | }); 8 | export type RenderBuildpackFile = Static; 9 | -------------------------------------------------------------------------------- /buildpack/validate.ts: -------------------------------------------------------------------------------- 1 | import { HEROKU_BUILDPACKS } from "./constants.ts"; 2 | 3 | export const isHerokuBuildpack = (bp: string) => HEROKU_BUILDPACKS.includes(bp); 4 | export const isGitUrl = (bp: string) => bp.startsWith("https://") && bp.endsWith(".git"); 5 | export const isTarball = (bp: string) => bp.startsWith("https://") && (bp.endsWith(".tgz") || bp.endsWith(".tar.gz")); 6 | 7 | export function isValidBuildpackEntry(bp: string) { 8 | return ( 9 | isHerokuBuildpack(bp) || 10 | isGitUrl(bp) || 11 | isTarball(bp) 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /commands/_helpers.ts: -------------------------------------------------------------------------------- 1 | import { getConfig } from "../config/index.ts"; 2 | import { RuntimeConfiguration } from "../config/types/index.ts"; 3 | import { Cliffy, Log } from '../deps.ts'; 4 | import { getLogger, NON_INTERACTIVE, renderInteractiveOutput, renderJsonOutput } from "../util/logging.ts"; 5 | import { RenderCLIError } from "../errors.ts"; 6 | 7 | const { Command } = Cliffy; 8 | 9 | export type GlobalOptions = { 10 | verbose?: true; // this is a load-bearing 'true' 11 | nonInteractive?: true; // ditto 12 | prettyJson?: true; 13 | profile?: string; 14 | region?: string; 15 | } 16 | 17 | // @ts-ignore Deno is confused, but this is fine; TODO: resolve 18 | export const Subcommand = Command; 19 | 20 | export async function withConfig(fn: (cfg: RuntimeConfiguration) => Promise) { 21 | const config = await getConfig(); 22 | 23 | return fn(config); 24 | } 25 | 26 | export type ErrorContext = { 27 | path?: string, 28 | } 29 | 30 | export function printErrors(logger: Log.Logger, err: unknown) { 31 | if (err instanceof Deno.errors.NotFound) { 32 | logger.error(`Path not found or unreadable, but we should be giving you a better error for '${err?.constructor?.name ?? 'CLASS_NAME_NOT_FOUND'}'. Please file an issue so we can help.`, err); 33 | } else if (err instanceof RenderCLIError) { 34 | logger.error(err.message); 35 | } else { 36 | logger.error("Unrecognized error; dumping to console.error for full trace."); 37 | console.error(err); 38 | } 39 | } 40 | 41 | 42 | export interface InterrogativeAction { 43 | /** 44 | * The _interrogative form_ of an interactive action. A standard action that uses 45 | * this argument form MUST be async (because it is expected that you use Cliffy's 46 | * `prompt` module to ask questions of the user). No output handling will be 47 | * performed on your behalf; everything should be handled in your method. 48 | * 49 | * Should return the exit code for the command. 50 | * 51 | * https://cliffy.io/docs/prompt 52 | */ 53 | interactive: (logger: Log.Logger) => Promise | number, 54 | } 55 | 56 | export interface ProcessingAction { 57 | /** 58 | * Shared code between interactive and non-interactive modes. Whatever is returned from 59 | * this function will be passed to `interactive` or `nonInteractive`, respectively. 60 | */ 61 | processing: (logger: Log.Logger, isNonInteractive: boolean) => T | Promise; 62 | 63 | /** 64 | * The interactive formatter for this action. It's expected that all output that is 65 | * not tabular be printed via `logger`, and tabular content to be printed via Cliffy's 66 | * `table` facility. https://cliffy.io/docs/table 67 | */ 68 | interactive: (result: T, logger: Log.Logger) => void | Promise; 69 | 70 | /** 71 | * The non-interactive formatter for this action. If this is not set, the CLI will 72 | * bail before processing when `--non-interactive` is passed or when stdout isn't 73 | * a TTY. 74 | * 75 | * You are expected to print only human-readable debug data to `logger`. Any data 76 | * intended to be consumed (e.g., piped to another application) MUST be passed ONLY 77 | * to `console.log`. 78 | */ 79 | nonInteractive?: (result: T, logger: Log.Logger) => void | Promise; 80 | 81 | /** 82 | * Unifies return code logic. After either interactive() or nonInteractive() are called, 83 | * this will return the exit code for the command. 84 | * 85 | * If you return `true` or an array that is not empty, the return code will be 0. If you 86 | * return `false`, `null`, or `undefined`, the return code will be 1. If you return a 87 | * number, the exit code will be that number. 88 | */ 89 | exitCode: (result: T) => unknown; 90 | } 91 | 92 | 93 | export type StandardActionArgs = 94 | | InterrogativeAction 95 | | ProcessingAction 96 | ; 97 | 98 | function computeExitCode(exitResult: unknown): number { 99 | if (Array.isArray(exitResult)) { 100 | return exitResult.length > 0 ? 0 : 1; 101 | } 102 | if (typeof(exitResult) === 'number') { 103 | return exitResult; 104 | } 105 | 106 | return exitResult ? 0 : 1; 107 | } 108 | 109 | export function standardAction( 110 | args: StandardActionArgs, 111 | ) { 112 | 113 | return (async () => { 114 | const logger = await getLogger(); 115 | 116 | if (NON_INTERACTIVE && !('nonInteractive' in args)) { 117 | logger.error("This command can only be run in interactive mode."); 118 | Deno.exit(3); 119 | } 120 | 121 | try { 122 | if (!('processing' in args)) { 123 | logger.debug("Entering interrogative action."); 124 | // interrogative, interactive action 125 | await args.interactive(logger); 126 | } else { 127 | logger.debug("Performing processing."); 128 | const result = await args.processing(logger, NON_INTERACTIVE); 129 | 130 | if (NON_INTERACTIVE) { 131 | logger.debug("Performing non-interactive rendering."); 132 | 133 | if (!args.nonInteractive) { 134 | throw new Error("firewall: got to non-interactive rendering of an action with no non-interactive renderer; should have bailed earlier?"); 135 | } 136 | 137 | await args.nonInteractive(result, logger); 138 | } else { 139 | logger.debug("Performing interactive rendering."); 140 | await args.interactive(result, logger); 141 | } 142 | 143 | const exitResult = args.exitCode(result); 144 | Deno.exit(computeExitCode(exitResult)); 145 | } 146 | } catch (err) { 147 | printErrors(logger, err); 148 | Deno.exit(2); 149 | } 150 | })(); 151 | } 152 | 153 | export type APIGetActionArgs = 154 | | { 155 | processing: (logger: Log.Logger, isNonInteractive: boolean) => T | Promise; 156 | 157 | format?: string, 158 | tableColumns?: string[], 159 | }; 160 | 161 | export function apiGetAction(args: APIGetActionArgs) { 162 | return standardAction({ 163 | processing: args.processing, 164 | interactive: (items: unknown | Array, logger: Log.Logger) => { 165 | if (items && (!Array.isArray(items) || items.length > 0)) { 166 | renderInteractiveOutput(items, args.tableColumns, args.format); 167 | } else { 168 | logger.warning("No results found."); 169 | } 170 | }, 171 | nonInteractive: (items: unknown | Array) => { 172 | renderJsonOutput(items); 173 | }, 174 | exitCode: (items: unknown | Array) => 175 | Array.isArray(items) 176 | ? items.length > 0 177 | ? 0 178 | : 1 179 | : !!items, 180 | }); 181 | } 182 | -------------------------------------------------------------------------------- /commands/blueprint/edit.ts: -------------------------------------------------------------------------------- 1 | import { interactivelyEditBlueprint } from "../../new/blueprint/index.ts"; 2 | import { Subcommand } from "../_helpers.ts"; 3 | 4 | const desc = 5 | `Interactively edits a Render Blueprint (render.yaml) file.`; 6 | 7 | export const blueprintNewCommand = 8 | new Subcommand() 9 | .name('new') 10 | .description(desc) 11 | .arguments<[string]>("") 12 | .action((_opts, path = './render.yaml') => interactivelyEditBlueprint(path)); 13 | -------------------------------------------------------------------------------- /commands/blueprint/from-template.ts: -------------------------------------------------------------------------------- 1 | import { Log } from "../../deps.ts"; 2 | import { templateNewProject, writeExampleBlueprint } from "../../new/repo/index.ts"; 3 | import { standardAction, Subcommand } from "../_helpers.ts"; 4 | 5 | const desc = 6 | `Adds a Render Blueprint (render.yaml) to this repo, based on a template. 7 | 8 | You can fetch a Render Blueprint from a repo via any of the following identifiers: 9 | 10 | repo-name 11 | repo-name@gitref 12 | user/repo-name 13 | user/repo-name@gitref 14 | github:user/repo-name 15 | github:user/repo-name@gitref 16 | 17 | If \`user\` is not provided, \`render-examples\` is assumed. If no source prefix is provided, \`github\` is assumed. 18 | 19 | At present, \`render blueprint from-template\` does not support private repositories. 20 | 21 | Once you've added a Render Blueprint to your repo, you can run \`render blueprint launch\` to deploy your Blueprint. 22 | 23 | (Future TODO: enable \`gitlab:\` prefix, enable arbitrary Git repositories, enable private repositories.)`; 24 | 25 | export const blueprintFromTemplateCommand = 26 | new Subcommand() 27 | .name('from-template') 28 | .description(desc) 29 | .arguments<[string]>("") 30 | .option("--directory ", "directory to write the blueprint to (default: current directory)") 31 | .option("--skip-cleanup", "skips cleaning up tmpdir (on success or failure)") 32 | .action((opts, identifier) => 33 | standardAction({ 34 | interactive: async (_logger: Log.Logger): Promise => { 35 | await writeExampleBlueprint({ 36 | identifier, 37 | repoDir: opts.directory, 38 | skipCleanup: opts.skipCleanup, 39 | }); 40 | return 0; 41 | } 42 | })); 43 | -------------------------------------------------------------------------------- /commands/blueprint/index.ts: -------------------------------------------------------------------------------- 1 | import { Subcommand } from "../_helpers.ts"; 2 | import { blueprintFromTemplateCommand } from "./from-template.ts"; 3 | import { blueprintLaunchCommand } from "./launch.ts"; 4 | 5 | const desc = 6 | `Commands for interacting with Render Blueprints (render.yaml files).`; 7 | 8 | export const blueprintCommand = 9 | new Subcommand() 10 | .name("blueprint") 11 | .description(desc) 12 | .action(function() { 13 | this.showHelp(); 14 | Deno.exit(1); 15 | }) 16 | // .command("new", blueprintNewCommand) 17 | .command("launch", blueprintLaunchCommand) 18 | .command("from-template", blueprintFromTemplateCommand) 19 | // .command("validate", blueprintValidateCommand) 20 | ; 21 | -------------------------------------------------------------------------------- /commands/blueprint/launch.ts: -------------------------------------------------------------------------------- 1 | import { sleep } from "https://deno.land/x/sleep@v1.2.1/sleep.ts"; 2 | import { Log, openAWebsite } from "../../deps.ts"; 3 | import { gitUrlToHttpsUrl, listRemotes } from "../../util/git.ts"; 4 | import { RenderCLIError } from "../../errors.ts"; 5 | import { standardAction, Subcommand } from "../_helpers.ts"; 6 | 7 | const desc = 8 | `Opens Render Deploy for this repo. 9 | 10 | This will open Render Deploy for the \`origin\` upstream for the repo (for the current working directory) or a remote repo that you've specified. You must have created a GitHub or GitLab repo and pushed to it for this to work! 11 | 12 | If it can't open your browser, it'll provide you a link instead. 13 | 14 | NOTE: This is only tested for GitHub and GitLab HTTPS repos. Git SSH URLs will be converted to HTTPS as best we can.`; 15 | 16 | export const blueprintLaunchCommand = 17 | new Subcommand() 18 | .name('new') 19 | .description(desc) 20 | .option("-l, --link", "Prints out the Render Deploy URL rather than attempting to open it.") 21 | .option("-r, --remote ", "The remote to use") 22 | .arguments<[string]>("[path:string]") 23 | .action((opts, path) => standardAction({ 24 | processing: async () => { 25 | let httpsGitUrl: string; 26 | if (path) { 27 | httpsGitUrl = gitUrlToHttpsUrl(path); 28 | } else { 29 | const originName = opts.remote ?? 'origin'; 30 | const remotes = await listRemotes({ https: true }); 31 | 32 | const originUrl = remotes[originName]; 33 | if (!originUrl) { 34 | throw new RenderCLIError(`Could not find an '${originName}' upstream for this repo. Upstreams found: ${Object.keys(remotes).join(', ')}`); 35 | } 36 | httpsGitUrl = originUrl; 37 | } 38 | 39 | return `https://render.com/deploy?repo=${httpsGitUrl}`; 40 | }, 41 | interactive: async (httpsGitUrl: string, logger: Log.Logger) => { 42 | if (opts.link) { 43 | console.log(httpsGitUrl); 44 | } else { 45 | logger.info(`Taking you to the deploy page: ${httpsGitUrl}`); 46 | await sleep(1); 47 | 48 | const p = await openAWebsite(httpsGitUrl); 49 | 50 | if (!(await p.status()).success) { 51 | logger.error(`Could not automatically open browser. Please navigate to this URL directly: ${httpsGitUrl}`); 52 | } 53 | } 54 | }, 55 | nonInteractive: (httpsGitUrl: string) => { 56 | console.log(httpsGitUrl); 57 | }, 58 | exitCode: () => 0, 59 | })); 60 | -------------------------------------------------------------------------------- /commands/buildpack/add.ts: -------------------------------------------------------------------------------- 1 | import { readBuildpacksFile, writeBuildpacksFile } from "../../buildpack/crud.ts"; 2 | import { isValidBuildpackEntry } from "../../buildpack/validate.ts"; 3 | import { RenderCLIError } from "../../errors.ts"; 4 | import { getLogger } from "../../util/logging.ts"; 5 | import { Subcommand } from "../_helpers.ts"; 6 | 7 | const desc = 8 | `Adds buildpacks to your .render-buildpacks.json file.`; 9 | 10 | export const buildpackAddCommand = 11 | new Subcommand() 12 | .name('new') 13 | .description(desc) 14 | .option("--dir ", "the directory in which to run this command") 15 | .arguments("") 16 | .action(async (opts, ...buildpacks) => { 17 | const dir = opts.dir ?? Deno.cwd(); 18 | const logger = await getLogger(); 19 | 20 | if (buildpacks.length === 0) { 21 | throw new RenderCLIError("At least one buildpack must be specified."); 22 | } 23 | 24 | const buildpacksFile = await readBuildpacksFile(dir); 25 | 26 | for (const bp of buildpacks) { 27 | if (!isValidBuildpackEntry(bp)) { 28 | throw new RenderCLIError(`Invalid buildpack entry: ${bp}`); 29 | } 30 | 31 | const idx = buildpacksFile.buildpacks.indexOf(bp); 32 | if (idx >= 0) { 33 | throw new RenderCLIError(`Buildpack entry '${bp}' already exists.`); 34 | } 35 | 36 | logger.info(`Adding '${bp}' to ${dir}/.render-buildpacks.json.`); 37 | buildpacksFile.buildpacks.push(bp); 38 | } 39 | 40 | await writeBuildpacksFile(dir, buildpacksFile); 41 | }); 42 | -------------------------------------------------------------------------------- /commands/buildpack/index.ts: -------------------------------------------------------------------------------- 1 | import { Subcommand } from "../_helpers.ts"; 2 | import { buildpackAddCommand } from "./add.ts"; 3 | import { buildpackInitCommand } from "./init.ts"; 4 | import { buildpackRemoveCommand } from "./remove.ts"; 5 | 6 | const desc = 7 | `Commands for using buildpacks in Render`; 8 | 9 | export const buildpackCommand = 10 | new Subcommand() 11 | .name("buildpack") 12 | .description(desc) 13 | .action(function() { 14 | this.showHelp(); 15 | Deno.exit(1); 16 | }) 17 | .command("init", buildpackInitCommand) 18 | .command("remove", buildpackRemoveCommand) 19 | .command("add", buildpackAddCommand) 20 | ; 21 | -------------------------------------------------------------------------------- /commands/buildpack/init.ts: -------------------------------------------------------------------------------- 1 | import { Cliffy } from "../../deps.ts"; 2 | import { HEROKU_BUILDPACKS } from "../../buildpack/constants.ts"; 3 | import { initBuildpacksFile, initDockerfile } from "../../buildpack/crud.ts"; 4 | import { isValidBuildpackEntry } from "../../buildpack/validate.ts"; 5 | import { standardAction, Subcommand } from "../_helpers.ts"; 6 | import { pathExists } from "../../util/paths.ts"; 7 | import { ForceRequiredError } from "../../errors.ts"; 8 | 9 | const desc = 10 | `Initializes a repository for use with Heroku-style buildpacks within Render. 11 | 12 | You can learn more about buildpacks and Heroku migration at: 13 | https://render.com/docs/migrate-from-heroku 14 | 15 | Similar to our \`heroku-import\` tool, this command will create a 16 | \`Dockerfile.render\` file and a \`.render-buildpacks.json\` file in the 17 | specified directory. If a path is specified, that path will be used 18 | as the first buildpack in \`.render-buildpacks.json\`; otherwise, you 19 | will be asked for one.`; 20 | 21 | export const buildpackInitCommand = 22 | new Subcommand() 23 | .name('new') 24 | .description(desc) 25 | .option("-f, --force", "overwrites existing files if found.") 26 | .option("--dir ", "the directory in which to run this command") 27 | .option("--skip-dockerfile", "don't emit a Dockerfile") 28 | .arguments("[buildpacks...:string]") 29 | .action((opts, ...buildpacks) => standardAction({ 30 | interactive: async () => { 31 | const dir = opts.dir ?? '.'; 32 | 33 | // this is duplicated in the buildpack crud but we want to fail before we ask 34 | // the user questions that require mental effort 35 | const files = [ 36 | `${dir}/.render-buildpacks.json`, 37 | ]; 38 | (!opts.skipDockerfile) && files.push(`${dir}/Dockerfile`); 39 | for (const f of files) { 40 | if (await pathExists(f) && !opts.force) { 41 | throw new ForceRequiredError(`${f} exists`); 42 | } 43 | } 44 | 45 | const initialBuildpacks: Array = buildpacks ?? []; 46 | 47 | if (initialBuildpacks.length === 0) { 48 | const resp1 = await Cliffy.prompt([ 49 | { 50 | name: "buildpacks", 51 | message: "Select appropriate buildpacks (space to toggle)", 52 | type: Cliffy.Checkbox, 53 | options: [ 54 | ...HEROKU_BUILDPACKS, 55 | 'I need a custom buildpack', 56 | ], 57 | } 58 | ]); 59 | 60 | initialBuildpacks.push(...(resp1.buildpacks ?? []).filter(bp => HEROKU_BUILDPACKS.includes(bp))); 61 | 62 | if (resp1.buildpacks?.includes("I need a custom buildpack")) { 63 | console.log( 64 | ` 65 | Render's buildpack system accepts either of the following: 66 | 67 | - a git url, e.g. \`https://github.com/heroku/heroku-geo-buildpack.git\` 68 | - a full path to a tarball, e.g. 69 | \`https://github.com/username/repo/archive/refs/heads/main.tar.gz\` 70 | 71 | Enter a blank line to finish entering custom buildpacks. Also remember that 72 | you can add more buildpacks later with \`render buildpack add\`! 73 | `); 74 | 75 | while (true) { 76 | const { bpUrl } = await Cliffy.prompt([ 77 | { 78 | name: 'bpUrl', 79 | message: "enter a URL for a buildpack (blank line to finish)", 80 | type: Cliffy.Input, 81 | }, 82 | ]); 83 | 84 | if (!bpUrl || bpUrl === '') { 85 | break; 86 | } 87 | 88 | if (!isValidBuildpackEntry(bpUrl)) { 89 | console.error(`!!! '${bpUrl}' isn't a valid buildpack URL.`); 90 | } else { 91 | console.log(`${bpUrl} added.`); 92 | initialBuildpacks.push(bpUrl); 93 | } 94 | } 95 | } 96 | } 97 | 98 | (!opts.skipDockerfile) && await initDockerfile(dir, !!opts.force); 99 | await initBuildpacksFile(dir, initialBuildpacks, !!opts.force); 100 | 101 | console.log( 102 | ` 103 | 104 | ${initialBuildpacks.length} buildpacks configured. You're good to go! 105 | 106 | We've created the following files for you: 107 | 108 | ${files.map(f => `- ${f}`).join('\n')} 109 | 110 | You can now use this Dockerfile on Render either by creating a Blueprint, also 111 | known as a render.yaml file, or by adding a service manually to the Render 112 | Dashboard. 113 | 114 | - For more information on using Dockerfiles on Render, and a step-by-step guide 115 | to adding a Docker service via the Render Dashboard, please visit 116 | https://rndr.in/dockerfiles. 117 | - For more information on Blueprints, please visit https://rndr.in/blueprints. 118 | 119 | As always if you run into any trouble, Render's support team is standing by to 120 | help. You can reach them by emailing support@render.com. 121 | 122 | Thanks for using Render! 123 | 124 | ` 125 | ); 126 | 127 | return 0; 128 | } 129 | })); 130 | -------------------------------------------------------------------------------- /commands/buildpack/remove.ts: -------------------------------------------------------------------------------- 1 | import { readBuildpacksFile, writeBuildpacksFile } from "../../buildpack/crud.ts"; 2 | import { Cliffy } from "../../deps.ts"; 3 | import { RenderCLIError } from "../../errors.ts"; 4 | import { getLogger, NON_INTERACTIVE } from "../../util/logging.ts"; 5 | import { Subcommand } from "../_helpers.ts"; 6 | 7 | const desc = 8 | `Removes buildpacks from your .render-buildpacks.json file.`; 9 | 10 | export const buildpackRemoveCommand = 11 | new Subcommand() 12 | .name('new') 13 | .description(desc) 14 | .option("--dir ", "the directory in which to run this command") 15 | .arguments("[buildpacks...:string]") 16 | .action(async (opts, ...buildpacks) => { 17 | const dir = opts.dir ?? Deno.cwd(); 18 | const logger = await getLogger(); 19 | const buildpacksToRemove = [...buildpacks]; 20 | 21 | const buildpacksFile = await readBuildpacksFile(dir); 22 | 23 | if (buildpacksToRemove.length === 0) { 24 | if (NON_INTERACTIVE) { 25 | throw new RenderCLIError('Buildpacks must be specified as arguments in non-interactive mode.'); 26 | } 27 | 28 | const responses = await Cliffy.prompt([{ 29 | name: 'buildpack', 30 | message: 'Select buildpacks to remove (toggle with space), or hit Ctrl-C to cancel.', 31 | type: Cliffy.Checkbox, 32 | options: buildpacksFile.buildpacks, 33 | }]); 34 | 35 | if (!responses.buildpack) { 36 | Deno.exit(1); 37 | } 38 | 39 | buildpacksToRemove.push(...responses.buildpack); 40 | } 41 | 42 | for (const bp of buildpacksToRemove) { 43 | const idx = buildpacksFile.buildpacks.indexOf(bp); 44 | if (idx < 0) { 45 | logger.warning(`No buildpack '${bp}' found in buildpack file in ${dir}/.render-buildpacks.json.`); 46 | continue; 47 | } 48 | 49 | logger.info(`Removing '${bp}' from ${dir}/.render-buildpacks.json.`); 50 | buildpacksFile.buildpacks.splice(idx, 1); 51 | } 52 | 53 | await writeBuildpacksFile(dir, buildpacksFile); 54 | }); 55 | -------------------------------------------------------------------------------- /commands/commands.ts: -------------------------------------------------------------------------------- 1 | import { Cliffy, sortBy } from "../deps.ts"; 2 | import { renderJsonOutput } from "../util/logging.ts"; 3 | import { standardAction, Subcommand } from "./_helpers.ts"; 4 | 5 | const desc = 6 | `Lists all commands and subcommands.`; 7 | 8 | type CmdInfo = { 9 | fullName: string; 10 | shortDesc: string; 11 | } 12 | 13 | type CmdTreeNode = { 14 | name: string; 15 | description: string; 16 | subcommands: Array; 17 | } 18 | 19 | export const commandsCommand = 20 | new Subcommand() 21 | .name('commands') 22 | .description(desc) 23 | .action(function () { 24 | return standardAction({ 25 | processing: (logger) => { 26 | // TODO: some odd typing here. are the Deno imports correct? 27 | const rootCommand = this.getGlobalParent() as Cliffy.Command; 28 | 29 | function listCommands(cmd: Cliffy.Command): CmdTreeNode { 30 | if (!cmd) { 31 | logger.debug("can't happen: command was falsy in 'render commands'?"); 32 | throw new Error(); 33 | } 34 | 35 | const subcommands = sortBy( 36 | cmd.getCommands(false).map(subcmd => listCommands(subcmd)), 37 | (i: CmdTreeNode) => i.name, 38 | ); 39 | 40 | const ret: CmdTreeNode = { 41 | name: cmd.getName(), 42 | description: cmd.getDescription().split("\n")[0].trim(), 43 | subcommands, 44 | } 45 | 46 | return ret; 47 | } 48 | 49 | return listCommands(rootCommand); 50 | }, 51 | interactive: (result, _logger) => { 52 | function printCommand(cmd: CmdTreeNode, cmdPath: Array = [], indent = "") { 53 | if (cmdPath.length !== 0) { 54 | const precedingPath = Cliffy.colors.cyan(cmdPath.join(" ")); 55 | const currentNode = Cliffy.colors.brightWhite(cmd.name); 56 | console.log(`${indent}${precedingPath} ${currentNode}: ${cmd.description}`); 57 | } 58 | 59 | cmd.subcommands.forEach(subcmd => { 60 | printCommand( 61 | subcmd, 62 | [...cmdPath, cmd.name], 63 | indent + (cmdPath.length === 0 ? '' : ' \\- '), 64 | ); 65 | }); 66 | } 67 | 68 | printCommand(result); 69 | console.log(); 70 | console.log(`Remember that you can always pass ${Cliffy.colors.brightWhite('--help')} to any command (for example,`); 71 | console.log(`${Cliffy.colors.cyan("render services tail --help")}) to see more information about it.`); 72 | }, 73 | nonInteractive: (result) => { 74 | renderJsonOutput(result); 75 | }, 76 | exitCode: () => 0, 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /commands/config/_shared.ts: -------------------------------------------------------------------------------- 1 | import { ALL_REGIONS, Region } from "../../config/types/enums.ts"; 2 | import { ConfigLatest, ProfileLatest } from "../../config/types/index.ts"; 3 | import { Cliffy, Log, YAML } from "../../deps.ts"; 4 | import { RenderCLIError } from "../../errors.ts"; 5 | 6 | export async function requestProfileInfo(): Promise { 7 | const resp = await Cliffy.prompt([ 8 | { 9 | name: 'region', 10 | message: "What is this profile's default region?", 11 | type: Cliffy.Select, 12 | options: ALL_REGIONS, 13 | default: 'oregon', 14 | hint: "You can access any region with the CLI; this'll just save you some typing." 15 | }, 16 | { 17 | name: 'apiKey', 18 | message: 'Provide your API key from the Dashboard:', 19 | label: "API key", 20 | type: Cliffy.Secret, 21 | minLength: 32, 22 | hint: "This will begin with `rnd_` and be 32 characters long." 23 | } 24 | ]); 25 | 26 | // should never be hit, but Cliffy doesn't have a way to specify that apiKey 27 | // won't be falsy or zero-length. 28 | if (!resp.apiKey) { 29 | throw new RenderCLIError("No api key provided; exiting."); 30 | } 31 | 32 | return { 33 | defaultRegion: (resp.region ?? 'oregon') as Region, 34 | apiKey: resp.apiKey, 35 | }; 36 | } 37 | 38 | export async function writeProfile(logger: Log.Logger, configFile: string, cfg: ConfigLatest) { 39 | logger.debug(`writing config to '${configFile}'`); 40 | await Deno.writeTextFile(configFile, YAML.dump(cfg)); 41 | await chmodConfigIfPossible(logger, configFile); 42 | } 43 | 44 | export async function chmodConfigIfPossible(logger: Log.Logger, configFile: string) { 45 | if (Deno.build.os === 'windows') { 46 | logger.warning(`Deno does not currently support file permissions on Windows. As such,`); 47 | logger.warning(`'${configFile}' has user-level default permissions. On single-user`); 48 | logger.warning(`systems, this is fine. On multi-user systems, you may wish to further`); 49 | logger.warning(`secure your Render credentials.`) 50 | logger.warning(''); 51 | logger.warning('See https://rndr.in/windows-file-acl for potential solutions.'); 52 | } else { 53 | logger.debug(`chmod '${configFile}' to 600`); 54 | await Deno.chmod(configFile, 0o600); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /commands/config/add-profile.ts: -------------------------------------------------------------------------------- 1 | import { ConfigLatest } from "../../config/types/index.ts"; 2 | import { Log, YAML } from "../../deps.ts"; 3 | import { ForceRequiredError, InitRequiredError } from "../../errors.ts"; 4 | import { ajv } from "../../util/ajv.ts"; 5 | import { pathExists } from "../../util/paths.ts"; 6 | import { getPaths } from "../../util/paths.ts"; 7 | import { standardAction, Subcommand } from "../_helpers.ts"; 8 | import { requestProfileInfo, writeProfile } from "./_shared.ts"; 9 | 10 | const desc = 11 | `Adds a new profile to a Render CLI config file.`; 12 | 13 | export const configAddProfileCommand = 14 | new Subcommand() 15 | .name('init') 16 | .description(desc) 17 | .option("-f, --force", "overwrites existing profile if found.") 18 | .arguments("") 19 | .action((opts, profileName) => standardAction({ 20 | interactive: async (logger: Log.Logger) => { 21 | const { configFile } = await getPaths(); 22 | 23 | if (!pathExists(configFile)) { 24 | throw new InitRequiredError(`Render config file does not exist at '${configFile}'`); 25 | } 26 | 27 | logger.debug({ configFile }, "Loading config."); 28 | const cfg = YAML.load(await Deno.readTextFile(configFile)) as ConfigLatest; 29 | 30 | logger.debug("Validating config..."); 31 | ajv.validate(ConfigLatest, cfg); 32 | 33 | if (cfg.profiles[profileName] && !opts.force) { 34 | throw new ForceRequiredError(`Profile '${profileName}' already exists in '${configFile}'`); 35 | } 36 | 37 | logger.info(`Let's create a profile named '${profileName}'.`); 38 | const profile = await requestProfileInfo(); 39 | 40 | cfg.profiles[profileName] = profile; 41 | 42 | await writeProfile(logger, configFile, cfg); 43 | 44 | return 0; 45 | } 46 | }) 47 | ); 48 | -------------------------------------------------------------------------------- /commands/config/index.ts: -------------------------------------------------------------------------------- 1 | import { Subcommand } from "../_helpers.ts"; 2 | import { configAddProfileCommand } from "./add-profile.ts"; 3 | import { configInitCommand } from "./init.ts"; 4 | import { configProfilesCommand } from "./profiles.ts"; 5 | import { configSchemaCommand } from "./schema.ts"; 6 | 7 | const desc = 8 | `Commands for interacting with the render-cli configuration.`; 9 | 10 | export const configCommand = 11 | new Subcommand() 12 | .name("config") 13 | .description(desc) 14 | .action(function() { 15 | this.showHelp(); 16 | Deno.exit(1); 17 | }) 18 | .command("init", configInitCommand) 19 | .command("add-profile", configAddProfileCommand) 20 | .command("profiles", configProfilesCommand) 21 | .command("schema", configSchemaCommand) 22 | ; 23 | -------------------------------------------------------------------------------- /commands/config/init.ts: -------------------------------------------------------------------------------- 1 | import { ConfigLatest } from "../../config/types/index.ts"; 2 | import { Cliffy, Log, YAML } from "../../deps.ts"; 3 | import { ForceRequiredError } from "../../errors.ts"; 4 | import { pathExists } from "../../util/paths.ts"; 5 | import { getPaths } from "../../util/paths.ts"; 6 | import { standardAction, Subcommand } from "../_helpers.ts"; 7 | import { chmodConfigIfPossible, requestProfileInfo, writeProfile } from "./_shared.ts"; 8 | 9 | const desc = 10 | `Interactively creates a Render CLI config file.`; 11 | 12 | export const configInitCommand = 13 | new Subcommand() 14 | .name('init') 15 | .description(desc) 16 | .option("-f, --force", "overwrites existing files if found.") 17 | .action((opts) => standardAction({ 18 | interactive: async (logger: Log.Logger) => { 19 | const { renderDir, configFile } = await getPaths(); 20 | 21 | if (await pathExists(configFile) && !opts.force) { 22 | throw new ForceRequiredError(`Render config file already exists at '${configFile}'`); 23 | } 24 | 25 | logger.debug("ensuring Render directory exists..."); 26 | await Deno.mkdir(renderDir, { recursive: true }); 27 | 28 | logger.info("Let's create your default profile."); 29 | const defaultProfile = await requestProfileInfo(); 30 | 31 | const { sshPreserveHosts } = await Cliffy.prompt([ 32 | { 33 | name: 'sshPreserveHosts', 34 | type: Cliffy.Confirm, 35 | message: "Enable SSH pinning in `known_hosts`?", 36 | hint: "Render can protect you against Trust On First Use (TOFU) attacks by keeping your SSH `known_hosts` file updated with our SSH fingerprints. We strongly recommend enabling this.", 37 | default: true, 38 | }, 39 | ]); 40 | 41 | const cfg: ConfigLatest = { 42 | version: 1, 43 | sshPreserveHosts, 44 | profiles: { 45 | default: defaultProfile, 46 | }, 47 | }; 48 | 49 | await writeProfile(logger, configFile, cfg); 50 | 51 | logger.info("Done! You're ready to use the Render CLI!"); 52 | return 0; 53 | }, 54 | })) 55 | ; 56 | -------------------------------------------------------------------------------- /commands/config/profiles.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Subcommand } from "../_helpers.ts"; 3 | import { getConfig } from "../../config/index.ts"; 4 | 5 | const desc = 6 | `Lists your configured profiles.`; 7 | 8 | export const configProfilesCommand = 9 | new Subcommand() 10 | .name('profiles') 11 | .description(desc) 12 | .action(async () => { 13 | const config = await getConfig(); 14 | Object.keys(config.fullConfig.profiles).forEach((profile) => console.log(profile)); 15 | return 0; 16 | }); 17 | -------------------------------------------------------------------------------- /commands/config/schema.ts: -------------------------------------------------------------------------------- 1 | import { YAML } from "../../deps.ts"; 2 | 3 | import { ConfigLatest } from "../../config/types/index.ts"; 4 | import { standardAction, Subcommand } from "../_helpers.ts"; 5 | 6 | const desc = 7 | `Displays the render-cli config schema (YAML; JSON Schema format).`; 8 | 9 | export const configSchemaCommand = 10 | new Subcommand() 11 | .name('regions') 12 | .description(desc) 13 | .action(() => standardAction({ 14 | interactive: () => { 15 | console.log(YAML.dump(ConfigLatest)); 16 | return 0; 17 | }, 18 | })); 19 | -------------------------------------------------------------------------------- /commands/custom-domains/index.ts: -------------------------------------------------------------------------------- 1 | import { Subcommand } from "../_helpers.ts"; 2 | import { customDomainsListCommand } from "./list.ts"; 3 | 4 | const desc = 5 | `Commands for observing and managing custom domains.`; 6 | 7 | export const customDomainsCommand = 8 | new Subcommand() 9 | .name("custom-domains") 10 | .description(desc) 11 | .action(function() { 12 | this.showHelp(); 13 | Deno.exit(1); 14 | }) 15 | .command("list", customDomainsListCommand) 16 | ; 17 | -------------------------------------------------------------------------------- /commands/custom-domains/list.ts: -------------------------------------------------------------------------------- 1 | import { apiGetAction, Subcommand } from "../_helpers.ts"; 2 | import { getConfig } from "../../config/index.ts"; 3 | import { getRequestJSONList } from "../../api/index.ts"; 4 | import { getLogger } from "../../util/logging.ts"; 5 | 6 | const desc = 7 | `Lists the custom domains for a given service.`; 8 | 9 | export const customDomainsListCommand = 10 | new Subcommand() 11 | .name('list') 12 | .description(desc) 13 | .group("Presentational controls") 14 | .option("--format ", "interactive output format", { 15 | default: 'table', 16 | }) 17 | .option("--columns ", "if --format table, the columns to show.", { 18 | default: ['id', 'name', 'createdAt', 'verificationStatus'], 19 | }) 20 | .group("API parameters") 21 | .option( 22 | "--service-id ", 23 | "the service whose deploys to retrieve", 24 | { required: true }, 25 | ) 26 | .option("--name ", "domain names to filter by", { collect: true }) 27 | .option("--domain-type ", "'apex' or 'subdomain'", { collect: true }) 28 | .option("--verification-status ", "'verified' or 'unverified'", { collect: true }) 29 | .option("--created-before ", "services created before (ISO8601)") 30 | .option("--created-after ", "services created after (ISO8601)") 31 | .action((opts) => apiGetAction({ 32 | format: opts.format, 33 | tableColumns: opts.columns, 34 | processing: async () => { 35 | const cfg = await getConfig(); 36 | const logger = await getLogger(); 37 | 38 | logger.debug("dispatching getRequestJSONList"); 39 | const ret = await getRequestJSONList( 40 | logger, 41 | cfg, 42 | 'customDomain', 43 | `/services/${opts.serviceId}/custom-domains`, 44 | { 45 | name: opts.name?.flat(Infinity), 46 | domainType: opts.domainType?.flat(Infinity), 47 | verificationStatus: opts.verificationStatus?.flat(Infinity), 48 | createdBefore: opts.createdBefore, 49 | createdAfter: opts.createdAfter, 50 | }, 51 | ); 52 | logger.info(`list call returned ${ret.length} entries.`); 53 | 54 | return ret; 55 | }, 56 | })); 57 | -------------------------------------------------------------------------------- /commands/dashboard.ts: -------------------------------------------------------------------------------- 1 | import { sleep } from "https://deno.land/x/sleep@v1.2.1/sleep.ts"; 2 | import { Log, openAWebsite } from "../deps.ts"; 3 | import { standardAction, Subcommand } from "./_helpers.ts"; 4 | 5 | const desc = 6 | `Opens the Render dashboard in your browser.`; 7 | 8 | export const dashboardCommand = 9 | new Subcommand() 10 | .name('dashboard') 11 | .description(desc) 12 | .option("-l, --link", "Prints out the Render Dashboard URL rather than attempting to open it.") 13 | .arguments<[string]>("[path:string]") 14 | .action((opts, path) => standardAction({ 15 | processing: () => { 16 | return `https://rndr.in/c/dashboard`; 17 | }, 18 | interactive: async (url: string, logger: Log.Logger) => { 19 | if (opts.link) { 20 | console.log(url); 21 | } else { 22 | logger.info(`Taking you to the dashboard: ${url}`); 23 | await sleep(1); 24 | 25 | const p = await openAWebsite(url); 26 | 27 | if (!(await p.status()).success) { 28 | logger.error(`Could not automatically open browser. Please navigate to this URL directly: ${url}`); 29 | } 30 | } 31 | }, 32 | nonInteractive: (url: string) => { 33 | console.log(url); 34 | }, 35 | exitCode: () => 0, 36 | })); 37 | -------------------------------------------------------------------------------- /commands/deploys/index.ts: -------------------------------------------------------------------------------- 1 | import { Subcommand } from "../_helpers.ts"; 2 | import { deploysListCommand } from "./list.ts"; 3 | 4 | const desc = 5 | `Commands for observing and managing deploys of Render services.`; 6 | 7 | export const deploysCommand = 8 | new Subcommand() 9 | .name("deploys") 10 | .description(desc) 11 | .action(function() { 12 | this.showHelp(); 13 | Deno.exit(1); 14 | }) 15 | .command("list", deploysListCommand) 16 | ; 17 | -------------------------------------------------------------------------------- /commands/deploys/list.ts: -------------------------------------------------------------------------------- 1 | import { apiGetAction, Subcommand } from "../_helpers.ts"; 2 | import { getConfig } from "../../config/index.ts"; 3 | import { getRequestJSONList } from "../../api/index.ts"; 4 | import { getLogger } from "../../util/logging.ts"; 5 | 6 | const desc = 7 | `Lists the deploys for a given service.`; 8 | 9 | export const deploysListCommand = 10 | new Subcommand() 11 | .name('list') 12 | .description(desc) 13 | .group("Presentational controls") 14 | .option("--format ", "interactive output format", { 15 | default: 'table', 16 | }) 17 | .option("--columns ", "if --format table, the columns to show.", { 18 | default: ['id', 'status', 'createdAt', 'commit.id', 'commit.message'], 19 | }) 20 | .group("API parameters") 21 | .option( 22 | "--service-id ", 23 | "the service whose deploys to retrieve", 24 | { required: true }, 25 | ) 26 | .option( 27 | "--start-time ", "start of the time range to return" 28 | ) 29 | .option( 30 | "--end-time ", "end of the time range to return" 31 | ) 32 | .action((opts) => apiGetAction({ 33 | format: opts.format, 34 | tableColumns: opts.columns, 35 | processing: async () => { 36 | const cfg = await getConfig(); 37 | const logger = await getLogger(); 38 | 39 | logger.debug("dispatching getRequestJSONList"); 40 | const ret = await getRequestJSONList( 41 | logger, 42 | cfg, 43 | 'deploy', 44 | `/services/${opts.serviceId}/deploys`, 45 | { 46 | startTime: opts.startTime, 47 | endTime: opts.endTime, 48 | }, 49 | ); 50 | logger.debug(`list call returned ${ret.length} entries.`); 51 | 52 | return ret; 53 | }, 54 | } 55 | ) 56 | ); 57 | -------------------------------------------------------------------------------- /commands/docs.ts: -------------------------------------------------------------------------------- 1 | import { sleep } from "https://deno.land/x/sleep@v1.2.1/sleep.ts"; 2 | import { Log, openAWebsite } from "../deps.ts"; 3 | import { standardAction, Subcommand } from "./_helpers.ts"; 4 | 5 | const desc = 6 | `Opens the Render docs in your browser.`; 7 | 8 | export const docsCommand = 9 | new Subcommand() 10 | .name('docs') 11 | .description(desc) 12 | .option("-l, --link", "Prints out the Render Docs URL rather than attempting to open it.") 13 | .arguments<[string]>("[path:string]") 14 | .action((opts, path) => standardAction({ 15 | processing: () => { 16 | return `https://rndr.in/c/docs`; 17 | }, 18 | interactive: async (url: string, logger: Log.Logger) => { 19 | if (opts.link) { 20 | console.log(url); 21 | } else { 22 | logger.info(`Taking you to the Render docs: ${url}`); 23 | await sleep(1); 24 | 25 | const p = await openAWebsite(url); 26 | 27 | if (!(await p.status()).success) { 28 | logger.error(`Could not automatically open browser. Please navigate to this URL directly: ${url}`); 29 | } 30 | } 31 | }, 32 | nonInteractive: (url: string) => { 33 | console.log(url); 34 | }, 35 | exitCode: () => 0, 36 | })); 37 | -------------------------------------------------------------------------------- /commands/index.ts: -------------------------------------------------------------------------------- 1 | import { Cliffy } from '../deps.ts'; 2 | 3 | import { getLogger, jsonRecordPerLine, nonInteractive, prettyJson, verboseLogging } from "../util/logging.ts"; 4 | import { blueprintCommand } from "./blueprint/index.ts"; 5 | import { buildpackCommand } from './buildpack/index.ts'; 6 | import { commandsCommand } from "./commands.ts"; 7 | import { configCommand } from "./config/index.ts"; 8 | import { repoCommand } from './repo/index.ts'; 9 | import { regionsCommand } from "./regions.ts"; 10 | import { servicesCommand } from "./services/index.ts"; 11 | import { deploysCommand } from "./deploys/index.ts"; 12 | import { customDomainsCommand } from "./custom-domains/index.ts"; 13 | import { jobsCommand } from "./jobs/index.ts"; 14 | import { versionCommand } from './version.ts'; 15 | import { dashboardCommand } from './dashboard.ts'; 16 | import { getPaths, pathExists } from '../util/paths.ts'; 17 | import { funcError } from "../util/errors.ts"; 18 | import { docsCommand } from './docs.ts'; 19 | 20 | async function emitHello() { 21 | const { configFile } = await getPaths(); 22 | 23 | console.log("render-cli is the command line interface for Render. The goal for"); 24 | console.log("this application is to make it easier to interact with Render in"); 25 | console.log("a programmatic way. It's also a good way to learn about the Render"); 26 | console.log("API and what's available in the programming language of your choice."); 27 | 28 | if (!await pathExists(configFile)) { 29 | console.log(); 30 | console.log(`To get started, run ${Cliffy.colors.cyan('render config init')} to create a configuration file.`); 31 | } 32 | 33 | console.log(); 34 | console.log("We're always adding more functionality to the Render CLI. Here are some"); 35 | console.log("common commands you might want to try:"); 36 | console.log(); 37 | console.log(`- ${Cliffy.colors.cyan('render repo from-template')} instantiates a new project from an example`); 38 | console.log(` in the Render example library at [ https://github.com/render-examples ].`); 39 | console.log(`- ${Cliffy.colors.cyan('render blueprint launch')} helps deploy the git repository you're in to`); 40 | console.log(` Render, using the blueprint in the repository.`); 41 | console.log(`- ${Cliffy.colors.cyan('render services ssh')} helps you connect to a service's shell.`); 42 | console.log(`- ${Cliffy.colors.cyan('render services tail')} lets you stream a service's logs as they happen.`); 43 | console.log(`- ${Cliffy.colors.cyan('render buildpack')} helps you manage the buildpacks used in services`); 44 | console.log(` created by the Render Heroku importer or explicitly instantiated via`); 45 | console.log(` ${Cliffy.colors.cyan('render buildpack init')}.`); 46 | console.log(`- ${Cliffy.colors.cyan('render commands')} will give you a full list of all commands in the Render`); 47 | console.log(` CLI, and ${Cliffy.colors.cyan('render --help')} will give you more details on a given`); 48 | console.log(` command.`); 49 | 50 | console.log(); 51 | console.log(`You can also run ${Cliffy.colors.cyan('render --help')} for a more structured help file.`); 52 | console.log(); 53 | console.log("Thanks for using Render!"); 54 | } 55 | 56 | export const ROOT_COMMAND = 57 | (new Cliffy.Command()) 58 | .name("render") 59 | .description("The CLI for the easiest cloud platform you'll ever use.\n\nType `render config init` to get started.") 60 | .globalOption( 61 | "-v, --verbose", 62 | "Makes render-cli a lot more chatty.", 63 | { 64 | action: () => verboseLogging(), 65 | }) 66 | .globalOption( 67 | "--non-interactive", 68 | "Forces Render to act as though it's not in a TTY.", 69 | { 70 | action: async (opts) => { 71 | (await getLogger()).debug("--non-interactive", opts); 72 | nonInteractive(); 73 | }, 74 | }) 75 | .globalOption( 76 | "--pretty-json", 77 | "If in non-interactive mode, prints prettified JSON.", 78 | { 79 | action: async (opts) => { 80 | (await getLogger()).debug("--pretty-json", opts); 81 | prettyJson(); 82 | }, 83 | }) 84 | .globalOption( 85 | "--json-record-per-line", 86 | "if emitting JSON, prints each JSON record as a separate line of stdout.", 87 | { 88 | action: async (opts) => { 89 | (await getLogger()).debug("--json-record-per-line", opts); 90 | jsonRecordPerLine(); 91 | }, 92 | conflicts: ["pretty-json"], 93 | }) 94 | .globalOption( 95 | "-p, --profile ", 96 | "The Render profile to use for this invocation. Overrides RENDERCLI_PROFILE.", 97 | { 98 | action: async (opts) => { 99 | (await getLogger()).debug("--profile", opts); 100 | Deno.env.set( 101 | "RENDERCLI_PROFILE", 102 | opts.profile || funcError(new Error("--profile passed but no argument received")), 103 | ); 104 | }, 105 | } 106 | ) 107 | .globalOption( 108 | "-r, --region ", 109 | "The Render region to use for this invocation; always accepted but not always relevant. Overrides RENDERCLI_REGION.", 110 | { 111 | action: async (opts) => { 112 | (await getLogger()).debug("--region", opts); 113 | Deno.env.set( 114 | "RENDERCLI_REGION", 115 | opts.region || funcError(new Error("--region passed but no argument received")), 116 | ); 117 | }, 118 | }) 119 | .helpOption(false) 120 | .option( 121 | "-h, --help", 122 | "Shows help for this command.", 123 | { 124 | action: async function() { 125 | const { configFile } = await getPaths(); 126 | 127 | if (!await pathExists(configFile)) { 128 | await emitHello(); 129 | } else { 130 | this.showHelp(); 131 | } 132 | 133 | Deno.exit(1); 134 | }, 135 | }, 136 | ) 137 | .action(async function() { 138 | await emitHello(); 139 | Deno.exit(1); 140 | }) 141 | .command("version", versionCommand) 142 | .command("commands", commandsCommand) 143 | .command("config", configCommand) 144 | .command("regions", regionsCommand) 145 | .command("dashboard", dashboardCommand) 146 | .command("docs", docsCommand) 147 | .command("repo", repoCommand) 148 | .command("blueprint", blueprintCommand) 149 | .command("buildpack", buildpackCommand) 150 | .command("services", servicesCommand) 151 | .command("completions", new Cliffy.CompletionsCommand()) 152 | .command("deploys", deploysCommand) 153 | .command("jobs", jobsCommand) 154 | .command("custom-domains", customDomainsCommand) 155 | ; 156 | -------------------------------------------------------------------------------- /commands/jobs/index.ts: -------------------------------------------------------------------------------- 1 | import { Subcommand } from "../_helpers.ts"; 2 | import { jobsListCommand } from "./list.ts"; 3 | 4 | const desc = 5 | `Commands for observing and managing Render jobs.`; 6 | 7 | export const jobsCommand = 8 | new Subcommand() 9 | .name("jobs") 10 | .description(desc) 11 | .action(function() { 12 | this.showHelp(); 13 | Deno.exit(1); 14 | }) 15 | .command("list", jobsListCommand) 16 | ; 17 | -------------------------------------------------------------------------------- /commands/jobs/list.ts: -------------------------------------------------------------------------------- 1 | import { apiGetAction, Subcommand } from "../_helpers.ts"; 2 | import { getConfig } from "../../config/index.ts"; 3 | import { getRequestJSONList } from "../../api/index.ts"; 4 | import { getLogger } from "../../util/logging.ts"; 5 | 6 | const desc = 7 | `Lists the jobs this user can see.`; 8 | 9 | export const jobsListCommand = 10 | new Subcommand() 11 | .name('list') 12 | .description(desc) 13 | .group("Presentational controls") 14 | .option("--format ", "interactive output format", { 15 | default: 'table', 16 | }) 17 | .option("--columns ", "if --format table, the columns to show.", { 18 | default: ['id', 'startCommand', 'planId', 'status', 'finishedAt'], 19 | }) 20 | .group("API parameters") 21 | .option( 22 | "--service-id ", 23 | "the service whose deploys to retrieve", 24 | { required: true }, 25 | ) 26 | .option("--status ", "'pending', 'running', 'succeeded', or 'failed'", { collect: true }) 27 | .option("--created-before ", "jobs created before (ISO8601)") 28 | .option("--created-after ", "jobs created after (ISO8601)") 29 | .option("--started-before ", "jobs started before (ISO8601)") 30 | .option("--started-after ", "jobs started after (ISO8601)") 31 | .option("--finished-before ", "jobs finished before (ISO8601)") 32 | .option("--finished-after ", "jobs finished after (ISO8601)") 33 | .action((opts) => apiGetAction({ 34 | format: opts.format, 35 | tableColumns: opts.columns, 36 | processing: async () => { 37 | const cfg = await getConfig(); 38 | const logger = await getLogger(); 39 | 40 | logger.debug("dispatching getRequestJSONList"); 41 | const ret = await getRequestJSONList( 42 | logger, 43 | cfg, 44 | 'job', 45 | `/services/${opts.serviceId}/jobs`, 46 | { 47 | status: opts.status?.flat(Infinity), 48 | createdBefore: opts.createdBefore, 49 | createdAfter: opts.createdAfter, 50 | startedBefore: opts.startedBefore, 51 | startedAfter: opts.startedAfter, 52 | finishedBefore: opts.finishedBefore, 53 | finishedAfter: opts.finishedAfter, 54 | }, 55 | ); 56 | logger.debug(`list call returned ${ret.length} entries.`); 57 | 58 | return ret; 59 | }, 60 | })); 61 | -------------------------------------------------------------------------------- /commands/regions.ts: -------------------------------------------------------------------------------- 1 | import { ALL_REGIONS } from "../config/types/enums.ts"; 2 | import { Subcommand } from "./_helpers.ts"; 3 | 4 | const desc = 5 | `Lists the Render regions that this version of the CLI knows about. 6 | 7 | NOTE: This list is shipped with a list baked in; in the future we might replace this with an API call.`; 8 | 9 | export const regionsCommand = 10 | new Subcommand() 11 | .name('regions') 12 | .description(desc) 13 | .action(() => { 14 | for (const region of ALL_REGIONS) { 15 | console.log(region); 16 | } 17 | }); 18 | -------------------------------------------------------------------------------- /commands/repo/from-template.ts: -------------------------------------------------------------------------------- 1 | import { Log } from "../../deps.ts"; 2 | import { templateNewProject } from "../../new/repo/index.ts"; 3 | import { standardAction, Subcommand } from "../_helpers.ts"; 4 | 5 | const desc = 6 | `Initializes a new project repository from a template. 7 | 8 | If you want to create a Render Blueprint (render.yaml) for an existing project, see \`render new blueprint\`. 9 | 10 | You can initialize a project with any of the following identifiers: 11 | 12 | repo-name 13 | repo-name@gitref 14 | user/repo-name 15 | user/repo-name@gitref 16 | github:user/repo-name 17 | github:user/repo-name@gitref 18 | 19 | If \`user\` is not provided, \`render-examples\` is assumed. If no source prefix is provided, \`github\` is assumed. 20 | 21 | At present, \`render repo from-template\` does not support private repositories. 22 | 23 | (Future TODO: enable \`gitlab:\` prefix, enable arbitrary Git repositories, enable private repositories.)`; 24 | 25 | export const repoFromTemplateCommand = 26 | new Subcommand() 27 | .name('from-template') 28 | .description(desc) 29 | .arguments<[string]>("") 30 | .option("-o, --output-directory ", "target directory for new repo", { required: false }) 31 | .option("-f, --force", "overwrites existing directory if found.") 32 | .option("--skip-cleanup", "skips cleaning up tmpdir (on success or failure)") 33 | .action((opts, identifier) => 34 | standardAction({ 35 | interactive: async (_logger: Log.Logger): Promise => { 36 | await templateNewProject({ 37 | identifier, 38 | outputDir: opts.outputDirectory, 39 | force: opts.force, 40 | skipCleanup: opts.skipCleanup, 41 | }); 42 | return 0; 43 | } 44 | })); 45 | -------------------------------------------------------------------------------- /commands/repo/index.ts: -------------------------------------------------------------------------------- 1 | import { Subcommand } from "../_helpers.ts"; 2 | import { repoFromTemplateCommand } from "./from-template.ts"; 3 | 4 | const desc = 5 | `Commands for managing Render projects/repos.`; 6 | 7 | export const repoCommand = 8 | new Subcommand() 9 | .name("repo") 10 | .description(desc) 11 | .action(function() { 12 | this.showHelp(); 13 | Deno.exit(1); 14 | }) 15 | .command("from-template", repoFromTemplateCommand) 16 | ; 17 | -------------------------------------------------------------------------------- /commands/services/delete.ts: -------------------------------------------------------------------------------- 1 | import { standardAction, Subcommand } from "../_helpers.ts"; 2 | import { getConfig } from "../../config/index.ts"; 3 | import { deleteRequestRaw } from "../../api/index.ts"; 4 | import { getLogger } from "../../util/logging.ts"; 5 | 6 | const desc = `Deletes a service`; 7 | 8 | export const servicesDeleteCommand = new Subcommand() 9 | .name("list") 10 | .description(desc) 11 | .group("API parameters") 12 | .option("--id ", "the service ID (e.g. `srv-12345`)") 13 | .action((opts) => 14 | standardAction({ 15 | exitCode: (res) => (res?.status == 204 ? 0 : 1), 16 | interactive: () => undefined, 17 | processing: async () => { 18 | const cfg = await getConfig(); 19 | const logger = await getLogger(); 20 | 21 | const ret = await deleteRequestRaw(logger, cfg, `/services/${opts.id}`); 22 | logger.debug(`deleted service ${opts.id}: ${ret.status}`); 23 | 24 | return ret; 25 | }, 26 | }) 27 | ); 28 | -------------------------------------------------------------------------------- /commands/services/index.ts: -------------------------------------------------------------------------------- 1 | import { Subcommand } from "../_helpers.ts"; 2 | import { servicesListCommand } from "./list.ts"; 3 | import { servicesShowCommand } from "./show.ts"; 4 | import { servicesSshCommand } from "./ssh.ts"; 5 | import { servicesTailCommand } from "./tail.ts"; 6 | import { servicesDeleteCommand } from "./delete.ts"; 7 | 8 | const desc = 9 | `Commands for observing and managing Render services.`; 10 | 11 | export const servicesCommand = 12 | new Subcommand() 13 | .name("new") 14 | .description(desc) 15 | .action(function() { 16 | this.showHelp(); 17 | Deno.exit(1); 18 | }) 19 | .command("show", servicesShowCommand) 20 | .command("delete", servicesDeleteCommand) 21 | .command("list", servicesListCommand) 22 | .command("tail", servicesTailCommand) 23 | .command("ssh", servicesSshCommand) 24 | ; 25 | -------------------------------------------------------------------------------- /commands/services/list.ts: -------------------------------------------------------------------------------- 1 | import { apiGetAction, Subcommand } from "../_helpers.ts"; 2 | import { getConfig } from "../../config/index.ts"; 3 | import { getRequestJSONList } from "../../api/index.ts"; 4 | import { getLogger } from "../../util/logging.ts"; 5 | import { funcError } from "../../util/errors.ts"; 6 | import { RenderCLIError } from "../../errors.ts"; 7 | 8 | const desc = 9 | `Lists the services this user can see.`; 10 | 11 | export const servicesListCommand = 12 | new Subcommand() 13 | .name('list') 14 | .description(desc) 15 | .group("Presentational controls") 16 | .option("--format ", "interactive output format", { 17 | default: 'table', 18 | }) 19 | .option("--columns ", "if --format table, the columns to show.", { 20 | default: ['id', 'name', 'type', 'serviceDetails.env', 'slug', 'serviceDetails.numInstances'], 21 | }) 22 | .group("API parameters") 23 | .option("--name ", "the name of a service to filter by", { collect: true }) 24 | .option("--type ", "the service type to filter by", { collect: true }) 25 | .option("--env ", "the runtime environment (docker, ruby, python, etc.)", { collect: true }) 26 | .option("--service-region ", "the region in which a service is located", { collect: true }) 27 | .option("--ownerid ", "the owner ID for the service", { collect: true }) 28 | .option("--created-before ", "services created before (ISO8601)") 29 | .option("--created-after ", "services created after (ISO8601)") 30 | .option("--updated-before ", "services updated before (ISO8601)") 31 | .option("--updated-after ", "services updated after (ISO8601)") 32 | .group("API-nonstandard parameters") 33 | .option("--suspended ", "'true'/'yes', 'false'/'no', or 'all'", { 34 | default: 'false', 35 | }) 36 | .action((opts) => apiGetAction({ 37 | format: opts.format, 38 | tableColumns: opts.columns, 39 | processing: async () => { 40 | const cfg = await getConfig(); 41 | const logger = await getLogger(); 42 | 43 | const ret = await getRequestJSONList( 44 | logger, 45 | cfg, 46 | 'service', 47 | '/services', 48 | { 49 | name: opts.name?.flat(Infinity), 50 | type: opts.type?.flat(Infinity), 51 | env: opts.env?.flat(Infinity), 52 | region: opts.serviceRegion?.flat(Infinity), 53 | suspended: 54 | opts.suspended === 'all' 55 | ? ['suspended', 'not_suspended' ] 56 | : ['true', 'yes'].includes(opts.suspended.toLowerCase()) 57 | ? ['suspended'] 58 | : ['false', 'no'].includes(opts.suspended.toLowerCase()) 59 | ? ['not_suspended'] 60 | : funcError(new RenderCLIError(`invalid --suspended: ${opts.suspended}`)), 61 | createdBefore: opts.createdBefore, 62 | createdAfter: opts.createdAfter, 63 | updatedBefore: opts.updatedBefore, 64 | updatedAfter: opts.createdAfter, 65 | }, 66 | ); 67 | logger.debug(`list call returned ${ret.length} entries.`); 68 | 69 | return ret; 70 | }, 71 | } 72 | ) 73 | ); 74 | -------------------------------------------------------------------------------- /commands/services/show.ts: -------------------------------------------------------------------------------- 1 | import { apiGetAction, Subcommand } from "../_helpers.ts"; 2 | import { getConfig } from "../../config/index.ts"; 3 | import { getRequestJSON } from "../../api/index.ts"; 4 | import { getLogger } from "../../util/logging.ts"; 5 | 6 | const desc = 7 | `Fetches full details about a single service.`; 8 | 9 | export const servicesShowCommand = 10 | new Subcommand() 11 | .name('list') 12 | .description(desc) 13 | .group("Presentational controls") 14 | .group("API parameters") 15 | .option("--id ", "the service ID (e.g. `srv-12345`)") 16 | .action((opts) => apiGetAction({ 17 | processing: async () => { 18 | const cfg = await getConfig(); 19 | const logger = await getLogger(); 20 | 21 | logger.debug("dispatching getRequestJSON"); 22 | const ret = await getRequestJSON( 23 | logger, 24 | cfg, 25 | `/services/${opts.id}`, 26 | ); 27 | 28 | return ret; 29 | }, 30 | } 31 | ) 32 | ); 33 | -------------------------------------------------------------------------------- /commands/services/ssh.ts: -------------------------------------------------------------------------------- 1 | import { getConfig, validateRegion } from "../../config/index.ts"; 2 | import { runSSH } from "../../services/ssh/index.ts"; 3 | import { getLogger } from "../../util/logging.ts"; 4 | import { Subcommand } from "./../_helpers.ts"; 5 | 6 | const desc = 7 | `Opens a SSH session to a Render service. 8 | 9 | This command wraps your local \`ssh\` binary and passes any additional command line arguments to that binary. Before invoking \`ssh\`, \`render ssh\` ensures that your local known hosts are updated with current fingerprints for Render services, reducing the likelihood of a TOFU (trust-on-first-use) attack.`; 10 | 11 | export const servicesSshCommand = 12 | new Subcommand() 13 | .name('ssh') 14 | .description(desc) 15 | .arguments("[sshArgs...:string]") 16 | .stopEarly() // args after the service name will not be parsed, including flags 17 | .option("--preserve-hosts", "Do not update ~/.ssh/known_hosts with Render public keys.") 18 | .option("--id ", "The service ID to access via SSH.", { required: true }) 19 | .action(async (opts, ...sshArgs) => { 20 | const logger = await getLogger(); 21 | const config = await getConfig(); 22 | 23 | const { id } = opts; 24 | 25 | const status = await runSSH({ 26 | config, 27 | serviceId: id, 28 | region: validateRegion(opts.region ?? config.profile.defaultRegion), 29 | sshArgs: sshArgs ?? [], 30 | noHosts: opts.preserveHosts ?? config.fullConfig.sshPreserveHosts ?? false, 31 | }); 32 | 33 | if (!status.success) { 34 | logger.error(`Underlying SSH failure: exit code ${status.code}, signal ${status.signal ?? 'none'}`); 35 | } 36 | Deno.exit(status.code); 37 | }); 38 | -------------------------------------------------------------------------------- /commands/services/tail.ts: -------------------------------------------------------------------------------- 1 | import { apiHost, apiKeyOrThrow, getRequestJSON, handleApiErrors } from "../../api/index.ts"; 2 | import { RenderCLIError } from "../../errors.ts"; 3 | import { UNLOGGABLE_SERVICE_TYPES } from "../../services/constants.ts"; 4 | import { LogTailEntry } from "../../services/types.ts"; 5 | import { ajv, logAjvErrors } from "../../util/ajv.ts"; 6 | import { getLogger } from "../../util/logging.ts"; 7 | import { Subcommand, withConfig } from "../_helpers.ts"; 8 | 9 | const desc = 10 | `Tails logs for a given service. 11 | 12 | TODO: support specifying a particular deploy ID from which to source logs. 13 | 14 | TODO: support pulling only the last X lines of a log.`; 15 | 16 | export const servicesTailCommand = 17 | new Subcommand() 18 | .name('new') 19 | .description(desc) 20 | .option("--raw", "only prints the bare text of the log to stdout") 21 | .option("--json", "prints Render's log tail as JSON, one per message", { conflicts: ['raw'] }) 22 | .option("--deploy-id ", "filter logs to the requested deploy ID", { collect: true }) 23 | .option("--id ", "the service ID whose logs to request", { required: true }) 24 | .action((opts) => withConfig(async (cfg) => { 25 | const logger = await getLogger(); 26 | const apiKey = apiKeyOrThrow(cfg); 27 | 28 | opts.deployId = opts.deployId ?? []; 29 | const deployIds = opts.deployId.length > 0 ? new Set(opts.deployId ?? []) : null; 30 | 31 | const url = `wss://${apiHost(cfg)}/v1/services/${opts.id}/logs/tail`; 32 | logger.debug(`tail url: ${url}, profile name: ${cfg.profileName}`); 33 | 34 | await handleApiErrors(logger, async () => { 35 | logger.debug("dispatching to check for static_site"); 36 | const service = (await getRequestJSON( 37 | logger, 38 | cfg, 39 | `/services/${opts.id}`, 40 | // TODO: resolve later when API clients are functional 41 | // deno-lint-ignore no-explicit-any 42 | )) as any; 43 | 44 | if (UNLOGGABLE_SERVICE_TYPES.has(service.type)) { 45 | throw new RenderCLIError(`Service '${opts.id}' is of type '${service.type}', which has no logs.`); 46 | } 47 | 48 | const stream = new WebSocketStream( 49 | url, 50 | { 51 | headers: { 52 | Authorization: `Bearer ${apiKey}`, 53 | }, 54 | } 55 | ); 56 | 57 | let conn: WebSocketConnection; 58 | let reader: ReadableStreamDefaultReader; 59 | let writer: WritableStreamDefaultWriter; 60 | 61 | try { 62 | conn = await stream.opened; 63 | reader = await conn.readable.getReader(); 64 | writer = await conn.writable.getWriter(); 65 | } catch (err) { 66 | throw err; 67 | } 68 | 69 | const logEntryValidator = ajv.compile(LogTailEntry); 70 | 71 | 72 | setInterval(() => { 73 | writer.write('{ "ping": true }'); 74 | }, 15000); 75 | 76 | while (true) { 77 | const input = (await reader.read()).value; 78 | if (!input) { 79 | logger.debug("empty packet received from web socket?"); 80 | continue; 81 | } 82 | 83 | let json: string; 84 | if (typeof(input) === 'string') { 85 | json = input; 86 | } else { 87 | json = (new TextDecoder()).decode(input); 88 | } 89 | 90 | const rawMsg = JSON.parse(json); 91 | 92 | if (logEntryValidator(rawMsg)) { 93 | const msg = rawMsg as {deployID: string; text: string} 94 | if (deployIds && !deployIds.has(msg.deployID)) { 95 | continue; 96 | } 97 | 98 | let output; 99 | if (opts.json) { 100 | output = json.trim(); 101 | } else { 102 | output = ""; 103 | 104 | if (!opts.raw) { 105 | output += `${msg.deployID}: `; 106 | } 107 | 108 | output += msg.text; 109 | } 110 | 111 | console.log(output); 112 | } else { 113 | logger.error("Unparseable entry from tail socket.") 114 | logAjvErrors(logger, logEntryValidator.errors); 115 | continue; 116 | } 117 | } 118 | }); 119 | })); 120 | -------------------------------------------------------------------------------- /commands/version.ts: -------------------------------------------------------------------------------- 1 | import { VERSION } from "../version.ts"; 2 | import { Subcommand } from "./_helpers.ts"; 3 | 4 | const desc = `Shows the application version.`; 5 | 6 | export const versionCommand = 7 | new Subcommand() 8 | .name('version') 9 | .description(desc) 10 | .action(() => { 11 | console.log(`${VERSION} (${Deno.build.target})`); 12 | }); 13 | -------------------------------------------------------------------------------- /config/index.ts: -------------------------------------------------------------------------------- 1 | import { Log, YAML, } from "../deps.ts"; 2 | import { ajv } from "../util/ajv.ts"; 3 | import { identity } from "../util/fn.ts"; 4 | import { getPaths } from "../util/paths.ts"; 5 | import { APIKeyRequired } from '../errors.ts'; 6 | 7 | import { ALL_REGIONS, Region } from "./types/enums.ts"; 8 | import { assertValidRegion } from "./types/enums.ts"; 9 | import { ConfigAny, ConfigLatest, ProfileLatest, RuntimeConfiguration } from "./types/index.ts"; 10 | import { getLogger } from "../util/logging.ts"; 11 | 12 | let config: RuntimeConfiguration | null = null; 13 | 14 | // TODO: smarten this up with type checks 15 | const CONFIG_UPGRADE_MAPS = { 16 | 1: identity, 17 | } 18 | const FALLBACK_PROFILE: Partial = { 19 | defaultRegion: 'oregon', // mimics dashboard behavior 20 | }; 21 | 22 | const FALLBACK_CONFIG: ConfigLatest = { 23 | version: 1, 24 | profiles: {}, 25 | } 26 | 27 | export async function getConfig(): Promise { 28 | if (config === null) { 29 | const cfg = await fetchAndParseConfig(); 30 | 31 | const runtimeProfile = await buildRuntimeProfile(cfg); 32 | const ret: RuntimeConfiguration = { 33 | fullConfig: cfg, 34 | ...runtimeProfile, 35 | } 36 | 37 | config = ret; 38 | } 39 | 40 | return config; 41 | } 42 | 43 | export async function withConfig(fn: (cfg: RuntimeConfiguration) => T | Promise): Promise { 44 | const cfg = await getConfig(); 45 | 46 | return fn(cfg); 47 | } 48 | 49 | function upgradeConfigFile(config: ConfigAny): ConfigLatest { 50 | const upgradePath = CONFIG_UPGRADE_MAPS[config.version]; 51 | 52 | if (!upgradePath) { 53 | throw new Error(`Unrecognized version, cannot upgrade (is render-cli too old?): ${config.version}`); 54 | } 55 | 56 | return upgradePath(config); 57 | } 58 | 59 | async function parseConfig(content: string): Promise { 60 | const data = await YAML.load(content); 61 | const ret = { 62 | ...FALLBACK_CONFIG, 63 | // deno-lint-ignore no-explicit-any 64 | ...(data as any), 65 | }; 66 | 67 | await ajv.validate(ConfigAny, ret); 68 | if (!ajv.errors) { 69 | return upgradeConfigFile(ret as ConfigAny); 70 | } 71 | 72 | throw new Error(`Config validation error: ${Deno.inspect(ajv.errors)}`); 73 | } 74 | 75 | async function fetchAndParseConfig(): Promise { 76 | const path = getPaths().configFile; 77 | 78 | try { 79 | const decoder = new TextDecoder(); 80 | const content = decoder.decode(await Deno.readFile(path)); 81 | return parseConfig(content); 82 | } catch (err) { 83 | if (err instanceof Deno.errors.NotFound) { 84 | return FALLBACK_CONFIG; 85 | } 86 | 87 | throw err; 88 | } 89 | } 90 | 91 | async function buildRuntimeProfile( 92 | cfg: ConfigLatest, 93 | ): Promise<{ profile: ProfileLatest, profileName: string }> { 94 | const logger = await getLogger(); 95 | const profileFromEnv = Deno.env.get("RENDERCLI_PROFILE"); 96 | const profileName = profileFromEnv ?? 'default'; 97 | logger.debug(`Using profile '${profileName}' (env: ${profileFromEnv})`); 98 | const profile = cfg.profiles[profileName] ?? {}; 99 | 100 | const ret: ProfileLatest = { 101 | ...FALLBACK_PROFILE, 102 | ...profile, 103 | } 104 | 105 | const actualRegion = Deno.env.get("RENDERCLI_REGION") ?? ret.defaultRegion; 106 | assertValidRegion(actualRegion); 107 | // TODO: clean this up - the assertion should be making the cast unnecessary, but TS disagrees 108 | ret.defaultRegion = actualRegion as Region; 109 | 110 | ret.apiKey = Deno.env.get("RENDERCLI_APIKEY") ?? ret.apiKey; 111 | ret.apiHost = Deno.env.get("RENDERCLI_APIHOST") ?? ret.apiHost; 112 | 113 | if (!ret.apiKey) { 114 | throw new APIKeyRequired(); 115 | } 116 | 117 | return { profile: ret, profileName }; 118 | } 119 | 120 | export function validateRegion(s: string): Region { 121 | if (!ajv.validate(Region, s)) { 122 | throw new Error(`Invalid region '${s}'. Valid regions: ${ALL_REGIONS.join(' ')}`); 123 | } 124 | 125 | return s as Region; 126 | } 127 | -------------------------------------------------------------------------------- /config/types/enums.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Static, 3 | Type 4 | } from '../../deps.ts'; 5 | import { ajv } from "../../util/ajv.ts"; 6 | 7 | export const Region = Type.Union([ 8 | Type.Literal('singapore'), 9 | Type.Literal('oregon'), 10 | Type.Literal('ohio'), 11 | Type.Literal('frankfurt'), 12 | ]); 13 | export type Region = Static; 14 | export const ALL_REGIONS = Region.anyOf.map(i => i.const); 15 | 16 | export function assertValidRegion(s: string): asserts s is Region { 17 | if (!ajv.validate(Region, s)) { 18 | throw new Error(`Region '${s}' is not one of: ${ALL_REGIONS.join(' ')}`); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /config/types/index.ts: -------------------------------------------------------------------------------- 1 | import { Type } from "../../deps.ts"; 2 | import { ConfigV1, ProfileV1 } from './v1.ts'; 3 | 4 | export type ConfigAny = 5 | | ConfigV1; 6 | export const ConfigAny = Type.Union([ 7 | ConfigV1, 8 | ]); 9 | 10 | export type ConfigLatest = ConfigV1; 11 | export const ConfigLatest = ConfigV1; 12 | 13 | export type ProfileLatest = ProfileV1; 14 | export const ProfileLatest = ProfileV1; 15 | 16 | export type UpgradeFn = (cfg: T) => ConfigLatest; 17 | 18 | export type RuntimeConfiguration = { 19 | fullConfig: ConfigLatest; 20 | profileName: string; 21 | profile: ProfileLatest; 22 | } 23 | -------------------------------------------------------------------------------- /config/types/v1.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Static, 3 | Type 4 | } from '../../deps.ts'; 5 | import { Region } from "./enums.ts"; 6 | 7 | export const APIKeyV1 = Type.String({ 8 | pattern: 'rnd_[0-9a-zA-Z\_]+', 9 | description: "Your Render API key. Will begin with 'rnd_'.", 10 | }); 11 | export type APIKeyV1 = Static; 12 | 13 | export const ProfileV1 = Type.Object({ 14 | apiKey: APIKeyV1, 15 | apiHost: Type.Optional(Type.String()), 16 | defaultRegion: Region, 17 | }); 18 | export type ProfileV1 = Static; 19 | 20 | export const ConfigV1 = Type.Object({ 21 | version: Type.Literal(1), 22 | sshPreserveHosts: Type.Optional(Type.Boolean({ 23 | description: "If true, render-cli will not keep ~/.ssh/known_hosts up to date with current public keys.", 24 | })), 25 | profiles: Type.Record(Type.String(), ProfileV1), 26 | }); 27 | export type ConfigV1 = Static; 28 | -------------------------------------------------------------------------------- /deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "importMap": "import_map.json", 3 | "lock": "deps-lock.json", 4 | "tasks": { 5 | "run": "deno run --unstable --allow-read --allow-net --allow-run --allow-write --allow-env -- entry-point.ts" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /deps.ts: -------------------------------------------------------------------------------- 1 | export { 2 | assertEquals, 3 | assertThrows, 4 | } from 'https://deno.land/std@0.151.0/testing/asserts.ts';; 5 | export * as Path from "https://deno.land/std@0.151.0/path/mod.ts"; 6 | export * as FS from "https://deno.land/std@0.151.0/fs/mod.ts"; 7 | export * as Log from "https://deno.land/std@0.151.0/log/mod.ts"; 8 | export * as QueryString from "https://deno.land/x/querystring@v1.0.2/mod.js"; 9 | export { 10 | sortBy 11 | } from "https://raw.githubusercontent.com/lodash/lodash/4.17.21-es/lodash.js"; 12 | 13 | export { Type, type Static } from "https://deno.land/x/typebox@0.28.10/src/typebox.ts"; 14 | export * as Typebox from "https://deno.land/x/typebox@0.28.10/src/typebox.ts"; 15 | 16 | export { default as Ajv } from "https://esm.sh/v86/ajv@8.11.0"; 17 | export { default as AjvFormats } from "https://esm.sh/v86/ajv-formats@2.1.1"; 18 | 19 | export * as Cliffy from "https://deno.land/x/cliffy@v0.25.6/mod.ts"; 20 | 21 | export { default as stripIndent } from "https://esm.sh/v86/strip-indent@4.0.0"; 22 | export { default as YAML } from "https://esm.sh/v86/js-yaml@4.1.0"; 23 | export { default as pipe } from "https://deno.land/x/froebel@v0.20.0/pipe.ts"; 24 | export { openAWebsite } from "https://deno.land/x/open_a_website@0.1.1/mod.ts"; 25 | 26 | 27 | export { isURL } from "https://deno.land/x/is_url/mod.ts"; 28 | export { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts"; 29 | export { tgz } from "https://deno.land/x/compress@v0.4.4/mod.ts"; 30 | export { download } from "https://deno.land/x/download@v1.0.1/mod.ts"; 31 | -------------------------------------------------------------------------------- /entry-point.ts: -------------------------------------------------------------------------------- 1 | import { ROOT_COMMAND } from "./commands/index.ts"; 2 | 3 | await (ROOT_COMMAND.parse(Deno.args)); 4 | -------------------------------------------------------------------------------- /errors.ts: -------------------------------------------------------------------------------- 1 | import { Typebox } from "./deps.ts"; 2 | import { ajv } from './util/ajv.ts'; 3 | 4 | export class RenderCLIError extends Error { 5 | 6 | } 7 | 8 | export class InitRequiredError extends RenderCLIError { 9 | constructor(msg: string) { 10 | super(`${msg}; run 'render config init' to create a config file.`); 11 | } 12 | } 13 | 14 | export class ForceRequiredError extends RenderCLIError { 15 | constructor(msg: string) { 16 | super(`${msg}; pass --force to do this anyway.`); 17 | } 18 | } 19 | 20 | export class PathNotFound extends RenderCLIError { 21 | constructor ( 22 | path: string, 23 | cause: Deno.errors.NotFound, 24 | ) { 25 | super(`path '${path}' not found or not readable.`, { cause }); 26 | } 27 | } 28 | 29 | export class RepoNotFound extends RenderCLIError { 30 | constructor ( 31 | name: string, 32 | ) { 33 | super(`Repo '${name}' not found.`); 34 | } 35 | } 36 | 37 | export class APIKeyRequired extends RenderCLIError { 38 | constructor() { 39 | super( 40 | 'No API key found. Please set the RENDERCLI_APIKEY environment variable or run `render config init`.', 41 | ); 42 | } 43 | } 44 | 45 | export class ValidationFailed extends RenderCLIError { 46 | constructor(schema: Typebox.TSchema, errors: typeof ajv.errors) { 47 | super( 48 | `Error validating object of type ${schema.title ?? schema.$id ?? 'unknown'}: ` + 49 | "\n\n" + 50 | (errors ?? []).map((error:any) => ajv.errorsText([error])).join("\n") 51 | ); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /import_map.json: -------------------------------------------------------------------------------- 1 | { 2 | "imports": {} 3 | } 4 | -------------------------------------------------------------------------------- /new/blueprint/index.ts: -------------------------------------------------------------------------------- 1 | import { NON_INTERACTIVE } from "../../util/logging.ts"; 2 | 3 | export async function interactivelyEditBlueprint(path: string): Promise { 4 | if (NON_INTERACTIVE) { 5 | throw new Error("Not usable in non-interactive mode."); 6 | } 7 | 8 | throw new Error("TODO: implement"); 9 | } -------------------------------------------------------------------------------- /new/repo/index.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "https://deno.land/std@0.151.0/testing/asserts.ts"; 2 | import { verboseLogging } from "../../util/logging.ts"; 3 | import { pathExists } from "../../util/paths.ts"; 4 | import { templateNewProject, writeExampleBlueprint } from "./index.ts"; 5 | 6 | // TODO: tests DO pass, but currently leak resources from the `tgz` dep and we should remove it 7 | // uncomment once fixed (n.b.: this is not a problem in the CLI itself, just the tests) 8 | // Also be aware of GitHub API rate limiting when running tests. 9 | 10 | // TODO: add tests for 'resolveTemplateIdentifier' when not just github 11 | // TODO: add tests for `force` option 12 | // Deno.test("repo from-template", async (t) => { 13 | // const root = await Deno.makeTempDir(); 14 | // const outputDir = `${root}/output`; 15 | 16 | // await templateNewProject({ 17 | // identifier: "sveltekit", 18 | // outputDir, 19 | // }); 20 | 21 | // assertEquals(true, !!(await pathExists(`${outputDir}/render.yaml`))); 22 | 23 | // await Deno.remove(root, { recursive: true }); 24 | 25 | // }); 26 | 27 | // Deno.test("blueprint from-template", async (t) => { 28 | // const root = Deno.makeTempDirSync(); 29 | // const repoDir = `${root}/output`; 30 | 31 | // await Deno.mkdir(repoDir); 32 | // const proc = Deno.run({ 33 | // cmd: [ 'git', 'init' ], 34 | // cwd: repoDir, 35 | // }); 36 | // const success = (await proc.status()).success; 37 | // assertEquals(true, success); 38 | 39 | // await writeExampleBlueprint({ 40 | // identifier: "sveltekit", 41 | // repoDir, 42 | // }); 43 | 44 | // assertEquals(true, !!(await pathExists(`${repoDir}/render.yaml`))); 45 | 46 | // await Deno.remove(root, { recursive: true }); 47 | // }); 48 | -------------------------------------------------------------------------------- /new/repo/index.ts: -------------------------------------------------------------------------------- 1 | import { ForceRequiredError, RepoNotFound } from "../../errors.ts"; 2 | import { Cliffy, Path, FS, download, tgz } from "../../deps.ts"; 3 | import { identity } from "../../util/fn.ts"; 4 | import { unwrapAsyncIterator } from "../../util/iter.ts"; 5 | import { getLogger } from "../../util/logging.ts"; 6 | import { pathExists } from "../../util/paths.ts"; 7 | import { findUp } from "../../util/find-up.ts"; 8 | 9 | export type TemplateNewProjectArgs = { 10 | identifier: string; 11 | outputDir?: string; 12 | force?: boolean; 13 | skipCleanup?: boolean; 14 | } 15 | 16 | const GITHUB_REPO_API_BASE = "https://api.github.com/repos"; 17 | const IDENTIFIER_REGEX = /^(?:(?:(?(github)):)?(?:(?[a-zA-Z0-9\-\_]+)\/)?(?:(?[a-zA-Z0-9\-\_]+))(?:@(?.+)))|(?[a-zA-Z0-9\-\_]+)$/; 18 | const DEFAULT_PROVIDER = 'github'; 19 | const DEFAULT_USER = 'render-examples'; 20 | 21 | type Locator = { 22 | provider: string, 23 | user: string, 24 | repo: string, 25 | gitref?: string, 26 | } 27 | 28 | function resolveTemplateIdentifier(s: string): Locator { 29 | if (s.startsWith('http:') || s.startsWith('https:')) { 30 | // the reason we don't support arbitrary git repos is that GitHub lets us download 31 | // a given gitref as a tarball and it's WAY WAY faster, plus doesn't run the risk of 32 | // hitting weird git-credential-manager jank. 33 | throw new Error("TODO: support arbitrary git repos as Render templates."); 34 | } 35 | 36 | const match = IDENTIFIER_REGEX.exec(s); 37 | if (!match) { 38 | throw new Error(`Invalid template identifier: ${s}`); 39 | } 40 | 41 | const ret: Locator = { 42 | provider: match.groups?.provider ?? DEFAULT_PROVIDER, 43 | user: match.groups?.user ?? DEFAULT_USER, 44 | repo: match.groups?.repoSolo ?? match.groups?.repo!, 45 | gitref: match.groups?.gitref 46 | }; 47 | 48 | if (!ret.repo) { 49 | throw new Error(`Couldn't resolve template identifier '${s}'; best guess: '${Deno.inspect(ret)}'.`); 50 | } 51 | 52 | return ret; 53 | } 54 | 55 | 56 | 57 | export async function templateNewProject(args: TemplateNewProjectArgs): Promise { 58 | const logger = await getLogger(); 59 | 60 | const tempDir = await Deno.makeTempDir({ prefix: 'rendercli_' }); 61 | 62 | try { 63 | logger.debug("CWD:", Deno.cwd()); 64 | logger.debug(`Attempting to resolve template '${args.identifier}'.`); 65 | const locator = resolveTemplateIdentifier(args.identifier); 66 | 67 | logger.debug(`Ensuring repo is a valid Render template: ${Deno.inspect(locator)}`); 68 | await ensureRepoIsValid(locator); 69 | 70 | if (!args.outputDir) { 71 | const response = await Cliffy.prompt([ 72 | { 73 | name: 'outputDir', 74 | message: 'What do you want this project to be called?', 75 | default: locator.repo, 76 | type: Cliffy.Input, 77 | }, 78 | ]); 79 | 80 | args.outputDir = response.outputDir ?? `./${locator.repo}`; 81 | } 82 | 83 | args.outputDir = Path.resolve(args.outputDir); 84 | 85 | if (await pathExists(args.outputDir) && !args.force) { 86 | throw new ForceRequiredError(`'${args.outputDir}' already exists`); 87 | } 88 | 89 | logger.debug(`Initializing your project at '${args.outputDir}'...`); 90 | await downloadRepo(locator, tempDir, args.outputDir, args.force); 91 | 92 | // TODO: much like degit, do we want to offer customization steps per-template? 93 | // this could be a separate yaml file in the repo, deleted once the repo 94 | // is instantiated 95 | 96 | console.log(`🎉 Done! 🎉 Your project's now ready to use! Just`); 97 | console.log(""); 98 | console.log(Cliffy.colors.brightYellow(`cd ${args.outputDir}`)); 99 | console.log(""); 100 | console.log("and you're good to go!"); 101 | console.log(""); 102 | console.log(Cliffy.colors.brightWhite("Now that you have a new blueprint set up, you'll need to push it to your repo.")) 103 | console.log(`Once done, ${Cliffy.colors.cyan("render buildpack launch")} to easily deploy your project to Render.`); 104 | console.log(""); 105 | console.log("Thanks for using Render, and good luck with your new project!"); 106 | } finally { 107 | if (!args.skipCleanup) { 108 | await Deno.remove(tempDir, { recursive: true }); 109 | } 110 | } 111 | } 112 | 113 | async function ensureRepoIsValid(loc: Locator) { 114 | const logger = await getLogger(); 115 | 116 | switch (loc.provider) { 117 | case 'github': { 118 | const repoInfoResp = await fetch([ GITHUB_REPO_API_BASE, loc.user, loc.repo ].join('/')); 119 | if (repoInfoResp.status !== 200) { 120 | throw new RepoNotFound(`github:${loc.user}/${loc.repo}`); 121 | } 122 | 123 | const repoInfo = await repoInfoResp.json(); 124 | logger.debug(`Found a repo: ${repoInfo.full_name}`); 125 | return true; 126 | } 127 | default: { 128 | throw new Error(`unrecognized project provider: ${loc.provider}`); 129 | } 130 | } 131 | } 132 | 133 | // this isn't "clone" repo because we're using the github tarball service to save 134 | // user time. 135 | async function downloadRepo(loc: Locator, tempDir: string, outDir: string, force?: boolean): Promise { 136 | const logger = await getLogger(); 137 | 138 | switch (loc.provider) { 139 | case 'github': { 140 | const tarballUrl = [GITHUB_REPO_API_BASE, loc.user, loc.repo, loc.gitref, 'tarball'].filter(identity).join('/'); 141 | logger.debug(`tarball URL: ${tarballUrl}`); 142 | 143 | const destinationFile = `${tempDir}/repo.tgz`; 144 | const tempUnzip = `${tempDir}/unzipped` 145 | 146 | logger.debug(`Downloading to '${destinationFile}'...`); 147 | await download(tarballUrl, { dir: tempDir, file: 'repo.tgz' }); 148 | 149 | logger.debug(`Decompressing to '${tempUnzip}'...`); 150 | await tgz.uncompress(destinationFile, tempUnzip); 151 | 152 | // github sticks the actual repo one level deep, so we gotta go get it 153 | const entries = await unwrapAsyncIterator(FS.expandGlob(`${loc.user}-${loc.repo}-*`, { root: tempUnzip })); 154 | if (entries.length !== 1) { 155 | throw new Error(`Wrong number of dir entries in Github tarball download: ${Deno.inspect(entries.map(e => e.path))}`); 156 | } 157 | 158 | const entry = entries[0]; 159 | logger.debug(`Checking '${entry.path}' for render.yaml...`); 160 | if (!(await pathExists(`${entry.path}/render.yaml`))) { 161 | throw new Error("This repo doesn't seem to be a valid Render template; there's no render.yaml file. Contact us for assistance!"); 162 | } 163 | 164 | logger.debug(`Moving '${entry.path}' to '${outDir}'...`); 165 | await FS.move(entry.path, outDir, { overwrite: force }); 166 | 167 | return; 168 | } 169 | default: { 170 | throw new Error(`unrecognized project provider: ${loc.provider}`); 171 | } 172 | } 173 | } 174 | 175 | export type WriteExampleBlueprintArgs = { 176 | identifier: string; 177 | repoDir?: string; 178 | skipCleanup?: boolean; 179 | }; 180 | 181 | export async function writeExampleBlueprint( 182 | args: WriteExampleBlueprintArgs, 183 | ) { 184 | const logger = await getLogger(); 185 | 186 | const tempDir = await Deno.makeTempDir({ prefix: 'rendercli_' }); 187 | const userRepoLocation = Path.resolve(args.repoDir ?? Deno.cwd()); 188 | 189 | try { 190 | logger.debug("Repo diretory:", userRepoLocation); 191 | logger.debug(`Attempting to resolve template '${args.identifier}'.`); 192 | const locator = resolveTemplateIdentifier(args.identifier); 193 | 194 | logger.debug(`Ensuring repo is a valid Render template: ${Deno.inspect(locator)}`); 195 | await ensureRepoIsValid(locator); 196 | 197 | 198 | logger.debug(`Making sure we're in a git repo from '${userRepoLocation}'...`); 199 | const gitDir = await findUp(userRepoLocation, '.git', { searchFor: 'directories' });; 200 | 201 | if (!gitDir) { 202 | throw new Error("You must be in a git repository to use this command."); 203 | } 204 | 205 | const repoDir = Path.dirname(Path.resolve(gitDir)); 206 | logger.debug(`Repo dir: ${repoDir}`); 207 | 208 | const renderYamlPath = `${repoDir}/render.yaml`; 209 | if (await pathExists(renderYamlPath)) { 210 | logger.warning("This repo already has a render.yaml file; we're going to copy it to 'render.yaml.old'."); 211 | await Deno.copyFile(renderYamlPath, `${renderYamlPath}.old`); 212 | } 213 | 214 | const unzipDir = `${tempDir}/blueprint-source`; 215 | await downloadRepo(locator, tempDir, unzipDir, true); 216 | 217 | logger.debug(`Copying '${unzipDir}/render.yaml' to '${renderYamlPath}'...`); 218 | await Deno.copyFile(`${unzipDir}/render.yaml`, renderYamlPath); 219 | 220 | console.log(`🎉 Done! 🎉 Your project's now ready to use! Your new ${Cliffy.colors.cyan(locator.repo)} blueprint has been saved to`); 221 | console.log(""); 222 | console.log(Cliffy.colors.brightYellow(renderYamlPath)); 223 | console.log(""); 224 | console.log(Cliffy.colors.brightWhite("Please make sure to review the blueprint's contents to make sure they're appropriate for your app!")); 225 | console.log(""); 226 | console.log(Cliffy.colors.brightWhite("Now that you have a new blueprint set up, you'll need to push it to your repo.")) 227 | console.log(`Once done, ${Cliffy.colors.cyan("render buildpack launch")} to easily deploy your project to Render.`); 228 | console.log(""); 229 | console.log("Thanks for using Render, and good luck with your new project!"); 230 | } finally { 231 | if (!args.skipCleanup) { 232 | await Deno.remove(tempDir, { recursive: true }); 233 | } 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /render.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | - type: web 3 | name: sveltekit 4 | env: node 5 | buildCommand: npm install && npm run build 6 | startCommand: node build/index.js 7 | autoDeploy: false -------------------------------------------------------------------------------- /services/constants.ts: -------------------------------------------------------------------------------- 1 | export const UNLOGGABLE_SERVICE_TYPES = Object.freeze(new Set([ 2 | 'static_site', 3 | ])); 4 | 5 | export const UNSHELLABLE_SERVICE_TYPES = Object.freeze(new Set([ 6 | 'static_site', 7 | ])); 8 | -------------------------------------------------------------------------------- /services/ssh/index.ts: -------------------------------------------------------------------------------- 1 | import { getRequestJSON } from "../../api/requests.ts"; 2 | import { Region } from "../../config/types/enums.ts"; 3 | import { RuntimeConfiguration } from "../../config/types/index.ts"; 4 | import { RenderCLIError } from "../../errors.ts"; 5 | import { getLogger, NON_INTERACTIVE } from "../../util/logging.ts"; 6 | import { UNSHELLABLE_SERVICE_TYPES } from "../constants.ts"; 7 | import { updateKnownHosts } from "./known-hosts.ts"; 8 | 9 | const RENDER_CLI_SUBCOMMAND_ENV = { 10 | IS_RENDERCLI_SUBCOMMAND: "1", 11 | }; 12 | 13 | export type RunSSHArgs = { 14 | config: RuntimeConfiguration, 15 | serviceId: string; 16 | region: Region; 17 | sshArgs: Array; 18 | noHosts?: boolean; 19 | } 20 | 21 | export async function runSSH(args: RunSSHArgs): Promise { 22 | const config = args.config; 23 | const logger = await getLogger(); 24 | 25 | logger.debug("dispatching to check for static_site"); 26 | const service = (await getRequestJSON( 27 | logger, 28 | config, 29 | `/services/${args.serviceId}`, 30 | // TODO: resolve later when API clients are functional 31 | // deno-lint-ignore no-explicit-any 32 | )) as any; 33 | 34 | if (UNSHELLABLE_SERVICE_TYPES.has(service.type)) { 35 | throw new RenderCLIError(`Service '${args.serviceId}' is of type '${service.type}', which cannot be SSH'd into.`); 36 | } 37 | 38 | logger.debug("Updating known hosts before invoking."); 39 | if (!args.noHosts) { 40 | await updateKnownHosts(); 41 | } 42 | 43 | const sshTarget = `${args.serviceId}@ssh.${args.region}.render.com`; 44 | 45 | logger.debug(`SSH target: ${sshTarget}`); 46 | 47 | const cmd = ['ssh', sshTarget, ...args.sshArgs]; 48 | logger.debug(`Deno.run args: ${JSON.stringify(cmd)}`); 49 | 50 | const process = Deno.run({ 51 | cmd, 52 | stdout: 'inherit', 53 | stderr: 'inherit', 54 | stdin: NON_INTERACTIVE ? 'null' : 'inherit', 55 | env: RENDER_CLI_SUBCOMMAND_ENV, 56 | }); 57 | 58 | logger.debug(`SSH process started as PID ${process.pid}`); 59 | const status = await process.status(); 60 | 61 | return status; 62 | } 63 | -------------------------------------------------------------------------------- /services/ssh/known-hosts.ts: -------------------------------------------------------------------------------- 1 | import { FS } from "../../deps.ts"; 2 | 3 | import { Region } from "../../config/types/enums.ts"; 4 | import { getLogger } from "../../util/logging.ts"; 5 | import { getPaths } from "../../util/paths.ts"; 6 | 7 | 8 | export type SSHEndpointInfo = Readonly<{ 9 | pubKey: string, 10 | }>; 11 | 12 | export const SSH_ENDPOINT_KEYS: Readonly> = { 13 | ohio: { 14 | pubKey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINMjC1BfZQ3CYotN1/EqI48hvBpZ80zfgRdK8NpP58v1", 15 | }, 16 | frankfurt: { 17 | pubKey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILg6kMvQOQjMREehk1wvBKsfe1I3+acRuS8cVSdLjinK", 18 | }, 19 | oregon: { 20 | pubKey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFON8eay2FgHDBIVOLxWn/AWnsDJhCVvlY1igWEFoLD2", 21 | }, 22 | singapore: { 23 | pubKey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGVcVcsy7RXA60ZyHs/OMS5aQj4YQy7Qn2nJCXHz4zLA", 24 | }, 25 | }; 26 | 27 | const SSH_SERVER_REGEX = RegExp( 28 | "^ssh.(?(" + 29 | Object.keys(SSH_ENDPOINT_KEYS).join("|") + 30 | ")).render.com", 31 | ); 32 | 33 | export async function updateKnownHosts(path?: string) { 34 | // this fn is written as it is because if we don't actually change known_hosts, 35 | // we don't want to update the one that the user already has (because a change in 36 | // mtime can trigger security stuff in some endpoint protection). 37 | const p = path ?? `${(await getPaths()).sshDir}/known_hosts`; 38 | 39 | const logger = await getLogger(); 40 | const file = await Deno.readTextFile(p); 41 | const lines = file.split("\n"); 42 | const outLines: Array = []; 43 | 44 | let regions = Object.keys(SSH_ENDPOINT_KEYS); 45 | let changesMade = false; 46 | 47 | lines.forEach(line => { 48 | const match = SSH_SERVER_REGEX.exec(line); 49 | 50 | if (line.length === 0 || !match) { 51 | outLines.push(line); 52 | return; 53 | } 54 | 55 | const region = match.groups?.region; 56 | if (!region) { 57 | // should never actually hit this, given the regex 58 | throw new Error("firewall: bad ssh known_hosts match"); 59 | } 60 | 61 | const [host, ...rest] = line.split(" "); 62 | const key = rest.join(' '); 63 | 64 | const knownKey = SSH_ENDPOINT_KEYS[region as Region].pubKey; 65 | if (key?.trim() !== knownKey) { 66 | logger.warning(`Incorrect SSH pubkey found for '${host}'; was '${key}', updated to '${knownKey}'.`); 67 | outLines.push(`${host} ${knownKey}`); 68 | changesMade = true; 69 | } 70 | 71 | regions = regions.filter(r => r === region); 72 | }); 73 | 74 | logger.debug(`Regions not in known hosts: ${regions.join(' ')}`); 75 | 76 | for (const region of regions) { 77 | const { pubKey } = SSH_ENDPOINT_KEYS[region as Region]; 78 | changesMade = true; 79 | 80 | const newLine = `ssh.${region}.render.com ${pubKey}`; 81 | logger.info("Appending to known_hosts:", newLine); 82 | outLines.push(newLine); 83 | } 84 | 85 | if (changesMade) { 86 | logger.info(`Updating '${p}'. Copying old to '${p}.old'.`); 87 | await FS.copy(p, `${p}.old`, { overwrite: true }); 88 | await Deno.writeTextFile(p, outLines.join("\n")); 89 | } else { 90 | logger.debug("No updates to known_hosts."); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /services/types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Static, 3 | Type 4 | } from '../deps.ts'; 5 | 6 | export const LogTailEntry = Type.Object({ 7 | id: Type.String(), 8 | serviceID: Type.String(), 9 | deployID: Type.String(), 10 | timestamp: Type.Number(), 11 | text: Type.String(), 12 | }); 13 | export type LogTailEntry = Static; 14 | 15 | -------------------------------------------------------------------------------- /util/ajv.ts: -------------------------------------------------------------------------------- 1 | import { Ajv, AjvFormats, Log, Typebox } from "../deps.ts"; 2 | import { ValidationFailed } from "../errors.ts"; 3 | 4 | // @ts-ignore esm.sh compile weirdness; typecheck is correct. 5 | export const ajv = AjvFormats(new Ajv({ 6 | allErrors: true, 7 | }), [ 8 | 'date-time', 9 | 'time', 10 | 'date', 11 | 'email', 12 | 'hostname', 13 | 'ipv4', 14 | 'ipv6', 15 | 'uri', 16 | 'uri-reference', 17 | 'uuid', 18 | 'uri-template', 19 | 'json-pointer', 20 | 'relative-json-pointer', 21 | 'regex', 22 | ]) 23 | .addKeyword("kind") 24 | .addKeyword("modifier"); 25 | 26 | export function logAjvErrors(logger: Log.Logger, errors: typeof ajv.errors) { 27 | if (!errors) { 28 | return; 29 | } 30 | 31 | for (const error of errors) { 32 | // TODO: ajv default errors aren't great 33 | logger.error(ajv.errorsText([error])); 34 | } 35 | } 36 | 37 | export function assertType(schema: T, content: unknown): asserts content is Typebox.Static { 38 | const isValid = ajv.validate(schema, content); 39 | if (!isValid) { 40 | throw new ValidationFailed(schema, ajv.errors); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /util/errors.ts: -------------------------------------------------------------------------------- 1 | // any here lets us use `funcError` in a ternary tree 2 | // deno-lint-ignore no-explicit-any 3 | export function funcError(err: Error): T { 4 | throw err; 5 | } 6 | -------------------------------------------------------------------------------- /util/find-up.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "../deps.ts"; 2 | import { 3 | findUp, 4 | } from './find-up.ts'; 5 | 6 | function f(path: string) { 7 | Deno.writeFileSync(path, new Uint8Array()); 8 | } 9 | 10 | Deno.test('findUp', async (t) => { 11 | const root = Deno.makeTempDirSync({ 12 | prefix: 'find-up-test-', 13 | }); 14 | 15 | Deno.mkdirSync(`${root}/a/b/c/d/e/f`, { recursive: true }); 16 | Deno.mkdirSync(`${root}/a/b/c/X/Y/Z`, { recursive: true }); 17 | Deno.mkdirSync(`${root}/1/2/3/4/5/6/abc`, { recursive: true }); 18 | Deno.mkdirSync(`${root}/1/abc`, { recursive: true }); 19 | 20 | f(`${root}/a/b/c/d/e/f/package.json`); 21 | f(`${root}/a/b/c/package.json`); 22 | f(`${root}/1/2/3/4/abc`); 23 | 24 | await t.step('should find a file in the current directory', async () => { 25 | const ret = await findUp(`${root}/a/b/c/d/e/f`, 'package.json'); 26 | assertEquals(ret, `${root}/a/b/c/d/e/f/package.json`); 27 | }); 28 | 29 | await t.step('should traverse upwards to find a file', async () => { 30 | const ret = await findUp(`${root}/a/b/c/d/e`, 'package.json'); 31 | assertEquals(ret, `${root}/a/b/c/package.json`); 32 | }); 33 | 34 | await t.step('should iterate multiple start paths', async () => { 35 | const ret = await findUp([`${root}/1/2/3/4/5/6`, `${root}/a/b/c/d/e`], 'package.json'); 36 | assertEquals(ret, `${root}/a/b/c/package.json`); 37 | }); 38 | 39 | await t.step('should skip directories when opts.searchFor === "files"', async () => { 40 | const ret = await findUp(`${root}/1/2/3/4/5/6`, 'abc', { searchFor: 'files' }); 41 | assertEquals(ret, `${root}/1/2/3/4/abc`); 42 | }); 43 | 44 | await t.step('should skip files when opts.searchFor === "directories"', async () => { 45 | const ret = await findUp(`${root}/1/2/3/4/5`, 'abc', { searchFor: 'directories' }); 46 | assertEquals(ret, `${root}/1/abc`); 47 | }); 48 | 49 | await t.step('should return null when no file is found', async () => { 50 | const ret = await findUp(`${root}/a/b/c/d/e`, 'does-not-exist'); 51 | assertEquals(ret, null); 52 | }); 53 | 54 | await Deno.remove(root, { recursive: true }); 55 | }); 56 | -------------------------------------------------------------------------------- /util/find-up.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Path } from "../deps.ts"; 3 | import { pathExists } from "./paths.ts"; 4 | 5 | // this originates from @eropple/find-up: 6 | // https://github.com/eropple/find-up/blob/master/src/index.ts 7 | 8 | export interface FindUpOpts { 9 | /** 10 | * Determines whether to search for directories or files. Leave undefined for either. 11 | */ 12 | searchFor?: 'directories' | 'files'; 13 | } 14 | 15 | /** 16 | * Searches upward from the given `startPath`(s) and return the full path of a file that 17 | * matches `searchFile`. 18 | * 19 | * While multiple `startPath` entries can be given, the first `searchFile` found in is the 20 | * only one that will be returned. Subsequent `startPath`s will not be evaluated. 21 | * 22 | * @param startPaths The set of paths to search from. Will search in order. 23 | * @param searchFile The filename to search for. Does not support globs. 24 | */ 25 | export async function findUp(startPaths: string | Array, searchFile: string, opts: FindUpOpts = {}): Promise { 26 | startPaths = (typeof startPaths === 'string') ? [startPaths] : startPaths; 27 | 28 | for (const startPath of startPaths) { 29 | let p: string = startPath; 30 | let lastPath: string | null = null; 31 | do { 32 | const candidate = `${p}/${searchFile}`; 33 | 34 | const fstat = await pathExists(candidate); 35 | if (fstat) { 36 | if (opts.searchFor) { 37 | if (opts.searchFor === 'files' && fstat.isFile) { 38 | return candidate; 39 | } 40 | 41 | if (opts.searchFor === 'directories' && fstat.isDirectory) { 42 | return candidate; 43 | } 44 | } else { 45 | return candidate; 46 | } 47 | } 48 | 49 | // `path.resolve('/..') === '/'`; I assume the same is true on Windows. 50 | lastPath = p; 51 | p = Path.resolve(`${p}/..`); 52 | } while (p !== lastPath); 53 | } 54 | 55 | return null; 56 | } 57 | -------------------------------------------------------------------------------- /util/fn.ts: -------------------------------------------------------------------------------- 1 | export const identity = (a: T) => a; 2 | -------------------------------------------------------------------------------- /util/git.ts: -------------------------------------------------------------------------------- 1 | // no good deno git library yet. :( 2 | 3 | export async function listRemotes(args: { cwd?: string, https?: boolean }): Promise> { 4 | const process = await Deno.run({ 5 | cmd: ['git', 'remote', '-v'], 6 | cwd: args.cwd ?? Deno.cwd(), 7 | stdin: 'null', 8 | stdout: 'piped', 9 | }); 10 | 11 | const decoder = new TextDecoder(); 12 | const lines = 13 | decoder.decode(await process.output()) 14 | .split("\n").map(i => i.trim()); 15 | 16 | const ret: Record = {}; 17 | 18 | lines.forEach(line => { 19 | const [name, url] = line.split(/[\ \t]/).filter(i => i); 20 | 21 | if (!name || !url) { 22 | return; 23 | } 24 | 25 | ret[name] = gitUrlToHttpsUrl(url); 26 | }); 27 | 28 | return ret; 29 | } 30 | 31 | export function gitUrlToHttpsUrl(gitUrl: string) { 32 | if (gitUrl.startsWith('https://')) { 33 | return gitUrl; 34 | } 35 | 36 | const [sshPart, urlPart] = gitUrl.replace("git+ssh://", "").split(':'); 37 | 38 | switch (sshPart) { 39 | case 'git@github.com': 40 | return `https://github.com/${urlPart}`; 41 | case 'git@gitlab.com': 42 | return `https://gitlab.com/${urlPart}`; 43 | default: 44 | throw new Error('Unrecognized SSH URL: ' + gitUrl); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /util/iter.ts: -------------------------------------------------------------------------------- 1 | export async function unwrapAsyncIterator(iter: AsyncIterableIterator): Promise> { 2 | const ret: Array = []; 3 | 4 | // deno-lint-ignore no-explicit-any 5 | let i: IteratorResult; 6 | do { 7 | i = await iter.next(); 8 | i.value && ret.push(i.value); 9 | } while (!i.done); 10 | 11 | return ret; 12 | } 13 | -------------------------------------------------------------------------------- /util/logging.ts: -------------------------------------------------------------------------------- 1 | import { Cliffy, Log } from '../deps.ts'; 2 | import { VERSION } from '../version.ts'; 3 | import { getAtPath } from "./objects.ts"; 4 | 5 | let isLoggingSetup = false; 6 | let LOG_VERBOSITY: 'INFO' | 'DEBUG' = 'INFO'; 7 | let PRETTY_JSON = false; 8 | let JSON_RECORD_PER_LINE = false; 9 | export let NON_INTERACTIVE = !Deno.isatty(Deno.stdout.rid); 10 | 11 | export async function verboseLogging() { 12 | if (isLoggingSetup) { 13 | Log.getLogger().warning("`verboseLogging` called after logging setup."); 14 | } 15 | 16 | // Deno's log function may be already called when these methods are called 17 | // because the CLI framework doesn't seem consistent in what order it calls 18 | // the `action` parameter for global options, so as a compromise we blow away 19 | // the existing logging config and re-instantiate. 20 | LOG_VERBOSITY = 'DEBUG' 21 | isLoggingSetup = false; 22 | await setupLogging(); 23 | } 24 | 25 | export function nonInteractive() { 26 | if (isLoggingSetup) { 27 | Log.getLogger().warning("`nonInteractive` called after logging setup."); 28 | } 29 | 30 | NON_INTERACTIVE = true; 31 | } 32 | 33 | export function prettyJson() { 34 | if (isLoggingSetup) { 35 | Log.getLogger().warning("`prettyJson` called after logging setup."); 36 | } 37 | 38 | PRETTY_JSON = true; 39 | } 40 | 41 | export function jsonRecordPerLine() { 42 | if (isLoggingSetup) { 43 | Log.getLogger().warning("`prettyJson` called after logging setup."); 44 | } 45 | 46 | JSON_RECORD_PER_LINE = true; 47 | } 48 | 49 | 50 | export type SetupLoggingArgs = { 51 | nonInteractive?: boolean, 52 | verbosity?: 'INFO' | 'DEBUG', 53 | } 54 | 55 | // @ts-ignore: Deno types for Log.handlers seem odd, but it's legit 56 | class StderrHandler extends Log.handlers.ConsoleHandler { 57 | private readonly encoder = new TextEncoder(); 58 | 59 | override log(msg: string): void { 60 | Deno.stderr.writeSync(this.encoder.encode(msg + "\n")); 61 | } 62 | } 63 | 64 | async function setupLogging() { 65 | if (!isLoggingSetup) { 66 | const verbosity = LOG_VERBOSITY; 67 | const handler = NON_INTERACTIVE 68 | ? new StderrHandler(verbosity) 69 | : new Log.handlers.ConsoleHandler(verbosity); 70 | 71 | await Log.setup({ 72 | handlers: { 73 | console: handler, 74 | }, 75 | loggers: { 76 | default: { 77 | level: verbosity, 78 | handlers: ['console'], 79 | }, 80 | } 81 | }); 82 | 83 | isLoggingSetup = true; 84 | } 85 | 86 | if (VERSION.startsWith("0")) { 87 | (await getLogger()).warning(`render-cli is still pre-1.0.0 and as such all functionality should be considered subject to change.`); 88 | } 89 | } 90 | 91 | export async function getLogger(name?: string) { 92 | if (!isLoggingSetup) { 93 | await setupLogging(); 94 | } 95 | 96 | return Log.getLogger(name); 97 | } 98 | 99 | export function renderInteractiveOutput( 100 | obj: unknown, 101 | tableColumns?: string[], 102 | format = 'table', 103 | ): void { 104 | switch (format) { 105 | case 'json': 106 | renderJsonOutput(obj); 107 | return; 108 | case 'table': 109 | if (Array.isArray(obj)) { 110 | if (!tableColumns) { 111 | throw new Error(`Interactive output in table mode, but no table columns provided.`); 112 | } 113 | 114 | const table = Cliffy.Table.from( 115 | obj.map( 116 | // deno-lint-ignore no-explicit-any 117 | (o: any) => tableColumns.map(c => getAtPath(c, o)), 118 | ), 119 | ); 120 | table.unshift(tableColumns); 121 | 122 | table.render(); 123 | } else { 124 | console.log(Deno.inspect(obj, { 125 | colors: true, 126 | depth: Infinity, 127 | sorted: true, 128 | })); 129 | } 130 | return; 131 | default: 132 | throw new Error(`Unrecognized interactive output format: '${format}'`); 133 | } 134 | } 135 | 136 | export function renderJsonOutput(obj: unknown): void { 137 | const output = ( 138 | JSON_RECORD_PER_LINE 139 | ? Array.isArray(obj) 140 | ? obj.map(item => JSON.stringify(item)).join("\n") 141 | : JSON.stringify(obj) 142 | : PRETTY_JSON 143 | ? JSON.stringify(obj, null, 2) 144 | : JSON.stringify(obj) 145 | ); 146 | 147 | console.log(output); 148 | } 149 | -------------------------------------------------------------------------------- /util/objects.ts: -------------------------------------------------------------------------------- 1 | // we are deep in "dealing with untyped data" territory here. hopefully 2 | // we can get riud of stuff like this once we have a typed API client. 3 | // 4 | // deno-lint-ignore-file no-explicit-any 5 | export function getAtPath(path: string | Array, obj: Record): any { 6 | const p = 7 | typeof(path) === 'string' 8 | ? path.split('.') 9 | : path; 10 | 11 | let curr = obj; 12 | while (p.length > 0) { 13 | curr = curr[p.shift()!]; 14 | } 15 | 16 | return curr; 17 | } 18 | -------------------------------------------------------------------------------- /util/paths.ts: -------------------------------------------------------------------------------- 1 | export type AppPaths = { 2 | renderDir: string, 3 | configFile: string, 4 | cacheDir: string, 5 | sshDir: string, 6 | }; 7 | 8 | export function getPaths(): AppPaths { 9 | const defaultHome = Deno.env.get("HOME"); 10 | if (!defaultHome) { 11 | throw new Error("No $HOME env var set."); 12 | } 13 | 14 | const renderDir = `${defaultHome}/.render`; 15 | 16 | return { 17 | renderDir, 18 | configFile: Deno.env.get("RENDERCLI_CONFIG_FILE") ?? `${renderDir}/config.yaml`, 19 | cacheDir: Deno.env.get("RENDERCLI_CACHE_DIR") ?? `${renderDir}/cache`, 20 | sshDir: Deno.env.get("RENDERCLI_SSH_DIR") ?? `${defaultHome}/.ssh`, 21 | }; 22 | } 23 | 24 | 25 | export async function pathExists( 26 | path: string, 27 | ): Promise { 28 | try { 29 | const ret = await Deno.stat(path); 30 | return ret; 31 | } catch (err) { 32 | if (err instanceof Deno.errors.NotFound) { 33 | return false; 34 | } 35 | 36 | throw err; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /util/shell.ts: -------------------------------------------------------------------------------- 1 | import { RenderCLIError } from "../errors.ts"; 2 | 3 | export async function simpleRun(cmd: Array, msgOnFail?: string): Promise { 4 | const process = Deno.run({ 5 | cmd, 6 | stderr: 'null', 7 | stdout: 'piped', 8 | }); 9 | const code = (await process.status()).code; 10 | if (code != 0) { 11 | throw new RenderCLIError( 12 | `${msgOnFail ?? 'Command failed.'} \`${cmd.join(' ')}\` exit code: ${code}` 13 | ) 14 | } 15 | 16 | const arr = await process.output(); 17 | return (new TextDecoder()).decode(arr); 18 | } 19 | -------------------------------------------------------------------------------- /version.ts: -------------------------------------------------------------------------------- 1 | export const VERSION = "git-tree" as const; 2 | --------------------------------------------------------------------------------