| 📦 Turbo JS 91999d1 k33g 10h ago | 1 | #!/usr/bin/env bash |
| 2 | # |
| 3 | # Build turbo-js and install it where your shell can find it. |
| 4 | # |
| 5 | # scripts/install.sh # install into GOBIN, or GOPATH/bin |
| 6 | # scripts/install.sh --prefix ~/bin # install somewhere else |
| 7 | # scripts/install.sh --with-server # install the language server too |
| 8 | # scripts/install.sh --uninstall # remove it again |
| 9 | # |
| 10 | # The build goes to a temporary file first, so a failed build never replaces a |
| 11 | # working installation, and the install itself is a rename rather than a write |
| 12 | # over the binary that is already there. The version is stamped in by the |
| 13 | # linker, so `turbo-js -version` names the commit it was built from. |
| 14 | |
| 15 | set -euo pipefail |
| 16 | |
| 17 | readonly BINARY=turbo-js |
| 18 | readonly SERVER=typescript-language-server |
| 19 | |
| 20 | # --- output ----------------------------------------------------------------- |
| 21 | |
| 22 | if [ -t 1 ]; then |
| 23 | readonly BOLD=$'\033[1m' DIM=$'\033[2m' RED=$'\033[31m' GREEN=$'\033[32m' YELLOW=$'\033[33m' RESET=$'\033[0m' |
| 24 | else |
| 25 | readonly BOLD='' DIM='' RED='' GREEN='' YELLOW='' RESET='' |
| 26 | fi |
| 27 | |
| 28 | info() { printf '%s\n' "$*"; } |
| 29 | step() { printf '%s==>%s %s\n' "$BOLD" "$RESET" "$*"; } |
| 30 | ok() { printf '%s ✓%s %s\n' "$GREEN" "$RESET" "$*"; } |
| 31 | warn() { printf '%s !%s %s\n' "$YELLOW" "$RESET" "$*"; } |
| 32 | die() { |
| 33 | printf '%s ✗%s %s\n' "$RED" "$RESET" "$*" >&2 |
| 34 | exit 1 |
| 35 | } |
| 36 | |
| 37 | usage() { |
| 38 | cat <<EOF |
| 39 | ${BOLD}$BINARY installer${RESET} |
| 40 | |
| 41 | scripts/install.sh [options] |
| 42 | |
| 43 | Options: |
| 44 | -p, --prefix DIR install into DIR (default: GOBIN, or GOPATH/bin) |
| 45 | --with-server also install $SERVER, which completion needs |
| 46 | --uninstall remove an installed $BINARY and stop |
| 47 | -h, --help show this and stop |
| 48 | EOF |
| 49 | } |
| 50 | |
| 51 | # --- arguments -------------------------------------------------------------- |
| 52 | |
| 53 | prefix="" |
| 54 | with_server=false |
| 55 | uninstall=false |
| 56 | |
| 57 | while [ $# -gt 0 ]; do |
| 58 | case "$1" in |
| 59 | -p | --prefix) |
| 60 | [ $# -ge 2 ] || die "--prefix needs a directory" |
| 61 | prefix="$2" |
| 62 | shift 2 |
| 63 | ;; |
| 64 | --with-server) |
| 65 | with_server=true |
| 66 | shift |
| 67 | ;; |
| 68 | --uninstall) |
| 69 | uninstall=true |
| 70 | shift |
| 71 | ;; |
| 72 | -h | --help) |
| 73 | usage |
| 74 | exit 0 |
| 75 | ;; |
| 76 | *) die "unknown option: $1 (try --help)" ;; |
| 77 | esac |
| 78 | done |
| 79 | |
| 80 | # --- where things are ------------------------------------------------------- |
| 81 | |
| 82 | cd "$(dirname "${BASH_SOURCE[0]}")/.." |
| 83 | readonly REPO="$PWD" |
| 84 | |
| 85 | command -v go >/dev/null 2>&1 || die "go is not installed: https://go.dev/dl/" |
| 86 | |
| 87 | # default_prefix returns where "go install" would put a binary: GOBIN when it |
| 88 | # is set, GOPATH/bin otherwise. That is the directory a Go developer is most |
| 89 | # likely to already have on PATH. |
| 90 | default_prefix() { |
| 91 | local gobin |
| 92 | gobin="$(go env GOBIN)" |
| 93 | if [ -n "$gobin" ]; then |
| 94 | printf '%s\n' "$gobin" |
| 95 | else |
| 96 | printf '%s/bin\n' "$(go env GOPATH)" |
| 97 | fi |
| 98 | } |
| 99 | |
| 100 | [ -n "$prefix" ] || prefix="$(default_prefix)" |
| 101 | [ -n "$prefix" ] || die "cannot work out where to install; use --prefix DIR" |
| 102 | readonly TARGET="$prefix/$BINARY" |
| 103 | |
| 104 | # --- uninstall -------------------------------------------------------------- |
| 105 | |
| 106 | if $uninstall; then |
| 107 | step "Removing $TARGET" |
| 108 | if [ -e "$TARGET" ]; then |
| 109 | rm -f "$TARGET" |
| 110 | ok "removed" |
| 111 | else |
| 112 | warn "nothing installed at $TARGET" |
| 113 | fi |
| 114 | exit 0 |
| 115 | fi |
| 116 | |
| 117 | # --- toolchain -------------------------------------------------------------- |
| 118 | |
| 119 | step "Checking the Go toolchain" |
| 120 | |
| 121 | # The requirement lives in go.mod, so this check cannot drift from the build. |
| 122 | required="$(awk '/^go /{print $2; exit}' "$REPO/go.mod")" |
| 123 | installed="$(go env GOVERSION)" |
| 124 | installed="${installed#go}" |
| 125 | |
| 126 | if [ "$(printf '%s\n%s\n' "$required" "$installed" | sort -V | head -1)" != "$required" ]; then |
| 127 | die "Go $required or later is needed, but $installed is installed" |
| 128 | fi |
| 129 | ok "go $installed (go.mod asks for $required or later)" |
| 130 | |
| 131 | # --- build ------------------------------------------------------------------ |
| 132 | |
| 133 | step "Building $BINARY" |
| 134 | |
| 135 | readonly STAGING="$(mktemp -d)" |
| 136 | trap 'rm -rf "$STAGING"' EXIT |
| 137 | |
| 138 | # The version the binary reports is stamped in by the linker, so that an |
| 139 | # installed editor names the commit it was actually built from rather than a |
| 140 | # constant somebody forgot to bump. Outside a git checkout — installed from a |
| 141 | # tarball, say — there is nothing to describe and the binary works the version |
| 142 | # out from its own build information instead. |
| 143 | version_pkg="rickub.com/turbo-editors/turbo-core/version" |
| 144 | if describe="$(git -C "$REPO" describe --tags --dirty 2>/dev/null)"; then |
| 145 | commit="$(git -C "$REPO" rev-parse --short HEAD 2>/dev/null || true)" |
| 146 | built="$(date -u +%Y-%m-%dT%H:%M:%SZ)" |
| 147 | ldflags="-X '$version_pkg.stamp=$describe' -X '$version_pkg.commit=$commit' -X '$version_pkg.built=$built'" |
| 148 | else |
| 149 | ldflags="" |
| 150 | fi |
| 151 | |
| 152 | if ! go build -ldflags "$ldflags" -o "$STAGING/$BINARY" "$REPO" 2>"$STAGING/build.log"; then |
| 153 | cat "$STAGING/build.log" >&2 |
| 154 | info "" |
| 155 | warn "A failure in the repository root is often a stray .go file that has" |
| 156 | warn "landed in package main. 'go vet .' names it." |
| 157 | die "build failed; nothing was installed" |
| 158 | fi |
| 159 | |
| 160 | # Running the staged binary is the only proof that the flags above reached the |
| 161 | # linker rather than merely looking right. It happens before the install, so a |
| 162 | # binary that cannot name its own version never replaces a working one. |
| 163 | if ! stamped="$("$REPO/scripts/check-version.sh" "$STAGING/$BINARY" "${describe:-}" "${commit:-}" 2>&1)"; then |
| 164 | info "$stamped" |
| 165 | die "the build did not carry its version; nothing was installed" |
| 166 | fi |
| 167 | ok "built — $stamped" |
| 168 | |
| 169 | # --- install ---------------------------------------------------------------- |
| 170 | |
| 171 | step "Installing into $prefix" |
| 172 | |
| 173 | mkdir -p "$prefix" || die "cannot create $prefix" |
| 174 | |
| 175 | # Install by renaming a complete file over the target, never by writing into |
| 176 | # the one that is there. |
| 177 | # |
| 178 | # macOS caches a binary's code signature against its inode. cp writes new bytes |
| 179 | # into the existing inode, so the cached signature ends up describing something |
| 180 | # else and the kernel refuses to execute the result — a reinstall that builds, |
| 181 | # installs, and then will not run. A rename gives the name a fresh inode, so |
| 182 | # there is nothing stale to cache. It is atomic besides: no moment at which a |
| 183 | # half-written turbo-js is on the PATH. |
| 184 | # |
| 185 | # The temporary has to sit in $prefix, because a rename only works within one |
| 186 | # filesystem and $STAGING is somewhere else entirely. |
| 187 | readonly INCOMING="$prefix/.$BINARY.incoming.$$" |
| 188 | trap 'rm -rf "$STAGING"; rm -f "$INCOMING"' EXIT |
| 189 | |
| 190 | cp "$STAGING/$BINARY" "$INCOMING" || die "cannot write into $prefix" |
| 191 | chmod 0755 "$INCOMING" |
| 192 | mv -f "$INCOMING" "$TARGET" || die "cannot replace $TARGET" |
| 193 | |
| 194 | # Whatever the system said is the useful part: "does not run" on its own tells |
| 195 | # nobody anything they can act on. |
| 196 | if ! verify="$("$TARGET" -version 2>&1)"; then |
| 197 | info "$verify" |
| 198 | die "the installed binary does not run" |
| 199 | fi |
| 200 | version="$verify" |
| 201 | ok "$version → $TARGET" |
| 202 | |
| 203 | # --- PATH ------------------------------------------------------------------- |
| 204 | |
| 205 | # on_path reports whether a directory is one the shell searches. |
| 206 | on_path() { |
| 207 | case ":${PATH:-}:" in |
| 208 | *":$1:"*) return 0 ;; |
| 209 | *) return 1 ;; |
| 210 | esac |
| 211 | } |
| 212 | |
| 213 | # shell_profile guesses the file that sets PATH for the user's shell. |
| 214 | shell_profile() { |
| 215 | case "${SHELL:-}" in |
| 216 | */zsh) printf '~/.zshrc\n' ;; |
| 217 | */fish) printf '~/.config/fish/config.fish\n' ;; |
| 218 | *) printf '~/.bashrc\n' ;; |
| 219 | esac |
| 220 | } |
| 221 | |
| 222 | step "Checking your PATH" |
| 223 | if on_path "$prefix"; then |
| 224 | ok "$prefix is on your PATH" |
| 225 | else |
| 226 | warn "$prefix is not on your PATH. Add it:" |
| 227 | info "" |
| 228 | info " echo 'export PATH=\"\$PATH:$prefix\"' >> $(shell_profile)" |
| 229 | info " exec \$SHELL" |
| 230 | fi |
| 231 | |
| 232 | # --- the language server ---------------------------------------------------- |
| 233 | |
| 234 | # find_server looks where the editor itself looks: PATH, then the directory |
| 235 | # nvm's current Node writes global binaries into, then a per-user npm prefix, |
| 236 | # then pnpm's and Volta's, then the two system prefixes. |
| 237 | # |
| 238 | # Finding it is not the same as its working. A version manager leaves a shim |
| 239 | # on PATH for a Node that has since been removed, and the shim fails only when |
| 240 | # it is run — so this asks the server for its version rather than trusting the |
| 241 | # file's existence, which is the difference between "you have completion" and |
| 242 | # "you will find out you have not when you press Ctrl-Space". |
| 243 | find_server() { |
| 244 | local candidate |
| 245 | for candidate in \ |
| 246 | "$(command -v "$SERVER" 2>/dev/null || true)" \ |
| 247 | "${NVM_BIN:+$NVM_BIN/$SERVER}" \ |
| 248 | "${NPM_CONFIG_PREFIX:-$HOME/.npm-global}/bin/$SERVER" \ |
| 249 | "${PNPM_HOME:+$PNPM_HOME/$SERVER}" \ |
| 250 | "${VOLTA_HOME:-$HOME/.volta}/bin/$SERVER" \ |
| 251 | "/usr/local/bin/$SERVER" \ |
| 252 | "/opt/homebrew/bin/$SERVER"; do |
| 253 | [ -n "$candidate" ] && [ -x "$candidate" ] || continue |
| 254 | "$candidate" --version >/dev/null 2>&1 || continue |
| 255 | printf '%s\n' "$candidate" |
| 256 | return 0 |
| 257 | done |
| 258 | return 1 |
| 259 | } |
| 260 | |
| 261 | step "Checking the language server" |
| 262 | |
| 263 | if $with_server && ! find_server >/dev/null; then |
| 264 | command -v npm >/dev/null 2>&1 || die "npm is not installed, so $SERVER cannot be: https://nodejs.org" |
| 265 | info " installing $SERVER, which takes a minute…" |
| 266 | # typescript@6, not typescript: TypeScript 7 is the native port and ships no |
| 267 | # tsserver.js, and the server refuses to start beside it. |
| 268 | npm install -g typescript-language-server typescript@6 || die "could not install $SERVER" |
| 269 | fi |
| 270 | |
| 271 | if server_path="$(find_server)"; then |
| 272 | ok "$SERVER at $server_path" |
| 273 | else |
| 274 | warn "$SERVER is not installed, so there will be no completion." |
| 275 | warn "Editing, colouring and themes all work without it." |
| 276 | info "" |
| 277 | info " npm install -g typescript-language-server typescript@6" |
| 278 | info " ${DIM}or re-run this script with --with-server${RESET}" |
| 279 | fi |
| 280 | |
| 281 | # --- what to do next -------------------------------------------------------- |
| 282 | |
| 283 | info "" |
| 284 | step "Ready" |
| 285 | info "" |
| 286 | info " Open a file ${BOLD}inside a Node project${RESET} — the server is started in the" |
| 287 | info " directory holding package.json:" |
| 288 | info "" |
| 289 | info " cd /path/to/your/project" |
| 290 | info " $BINARY main.js" |
| 291 | info "" |
| 292 | info " ${DIM}F10 menu · F2 save · Alt-X exit · type a '.' for completion${RESET}" |
| 293 | info " ${DIM}themes: $BINARY -list-themes${RESET}" |
| 294 | info "" |