Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commitc4e643cunknown_key

feat(self-host): single-binary installer — curl gluecron.com/install-server | bash

Claude committed on May 26, 2026Parent: e461e99
7 files changed+11073c4e643c11cddaf406e93c9f61b81ec1ce8e9106c
7 changed files+1107−3
Added.github/workflows/release-binaries.yml+160−0View fileUnifiedSplit
1name: Release self-host binaries
2
3# Build the single-binary Gluecron distribution for all four target
4# platforms on every `v*.*.*` tag, upload to the GitHub Release, and
5# mirror the artifacts to the running Gluecron host so the
6# `curl gluecron.com/install-server | bash` flow picks them up
7# immediately.
8
9permissions:
10 contents: write
11
12on:
13 push:
14 tags:
15 - "v*.*.*"
16 workflow_dispatch:
17 inputs:
18 targets:
19 description: "Comma-separated targets (default: all four)"
20 required: false
21 default: "linux-x64,linux-arm64,darwin-x64,darwin-arm64"
22
23concurrency:
24 group: release-binaries-${{ github.ref }}
25 cancel-in-progress: false
26
27jobs:
28 build:
29 name: build-${{ matrix.target }}
30 strategy:
31 fail-fast: false
32 matrix:
33 include:
34 - target: linux-x64
35 runs-on: ubuntu-latest
36 - target: linux-arm64
37 runs-on: ubuntu-latest
38 - target: darwin-x64
39 runs-on: macos-latest
40 - target: darwin-arm64
41 runs-on: macos-latest
42 runs-on: ${{ matrix.runs-on }}
43 steps:
44 - name: Checkout
45 uses: actions/checkout@v4
46
47 - name: Install bun
48 uses: oven-sh/setup-bun@v2
49 with:
50 bun-version: latest
51
52 - name: Install deps
53 run: bun install --frozen-lockfile
54
55 - name: Build target
56 env:
57 GLUECRON_BUILD_TARGETS: ${{ matrix.target }}
58 run: bash scripts/build-self-host-binary.sh
59
60 - name: Upload binary artifact
61 uses: actions/upload-artifact@v4
62 with:
63 name: gluecron-server-${{ matrix.target }}
64 path: |
65 dist/gluecron-server-${{ matrix.target }}
66 dist/gluecron-server-${{ matrix.target }}.sha256
67 if-no-files-found: error
68 retention-days: 7
69
70 publish:
71 name: publish
72 needs: [build]
73 runs-on: ubuntu-latest
74 steps:
75 - name: Checkout
76 uses: actions/checkout@v4
77
78 - name: Install bun
79 uses: oven-sh/setup-bun@v2
80 with:
81 bun-version: latest
82
83 - name: Download all binary artifacts
84 uses: actions/download-artifact@v4
85 with:
86 pattern: gluecron-server-*
87 merge-multiple: true
88 path: dist
89
90 - name: Build ancillary bundles (hook + migrations + env.example)
91 # Re-run the bundle step with no targets so it only rebuilds the
92 # ancillary assets + the combined manifest. Existing binaries in
93 # dist/ are left in place.
94 env:
95 GLUECRON_BUILD_TARGETS: "linux-x64"
96 run: |
97 # Make sure the script can find every binary the matrix produced.
98 ls -la dist/
99 # Compute combined SHA256SUMS across whatever binaries landed.
100 (
101 cd dist
102 : > SHA256SUMS
103 for f in gluecron-server-*; do
104 if [ -f "$f" ] && [[ "$f" != *.sha256 ]]; then
105 sha256sum "$f" >> SHA256SUMS
106 fi
107 done
108 )
109 # Bundle the post-receive hook + migrations + env.example.
110 cp -f src/hooks/post-receive.ts dist/post-receive || true
111 tar -czf dist/migrations.tar.gz drizzle || true
112 cp -f .env.example dist/env.example || true
113 (
114 cd dist
115 for f in post-receive migrations.tar.gz env.example; do
116 if [ -f "$f" ]; then
117 sha256sum "$f" >> SHA256SUMS
118 fi
119 done
120 )
121 node -p "require('./package.json').version" > dist/VERSION
122 ls -la dist/
123
124 - name: Publish GitHub Release
125 if: startsWith(github.ref, 'refs/tags/v')
126 uses: softprops/action-gh-release@v2
127 with:
128 fail_on_unmatched_files: false
129 files: |
130 dist/gluecron-server-linux-x64
131 dist/gluecron-server-linux-arm64
132 dist/gluecron-server-darwin-x64
133 dist/gluecron-server-darwin-arm64
134 dist/SHA256SUMS
135 dist/VERSION
136 dist/post-receive
137 dist/migrations.tar.gz
138 dist/env.example
139
140 - name: Mirror to Gluecron release endpoint
141 # Best-effort: POST each artifact to the running Gluecron site so
142 # /dist/<filename> serves the new bundle without a deploy. Requires
143 # GLUECRON_RELEASE_TOKEN (admin scope) + GLUECRON_RELEASE_URL secrets.
144 if: ${{ env.GLUECRON_RELEASE_URL != '' }}
145 env:
146 GLUECRON_RELEASE_URL: ${{ secrets.GLUECRON_RELEASE_URL }}
147 GLUECRON_RELEASE_TOKEN: ${{ secrets.GLUECRON_RELEASE_TOKEN }}
148 run: |
149 set -e
150 for f in dist/gluecron-server-* dist/SHA256SUMS dist/VERSION \
151 dist/post-receive dist/migrations.tar.gz dist/env.example; do
152 [ -f "$f" ] || continue
153 name=$(basename "$f")
154 echo "Uploading $name → $GLUECRON_RELEASE_URL"
155 curl -fsSL --max-time 120 \
156 -H "Authorization: Bearer $GLUECRON_RELEASE_TOKEN" \
157 -H "X-Filename: $name" \
158 --data-binary "@$f" \
159 "$GLUECRON_RELEASE_URL" || echo "mirror failed for $name (non-fatal)"
160 done
Addedscripts/build-self-host-binary.sh+180−0View fileUnifiedSplit
1#!/usr/bin/env bash
2# =============================================================================
3# scripts/build-self-host-binary.sh
4#
5# Builds the single-binary Gluecron server distribution that ships behind
6# `curl -fsSL https://gluecron.com/install-server | bash`. Microsoft GHE is
7# a 50GB blob; this is a ~200MB binary that runs the whole platform.
8#
9# Output layout (relative to repo root, all under dist/):
10#
11# dist/
12# gluecron-server-linux-x64 (executable)
13# gluecron-server-linux-arm64 (executable)
14# gluecron-server-darwin-x64 (executable)
15# gluecron-server-darwin-arm64 (executable)
16# gluecron-server-<plat>-<arch>.sha256 (one per binary)
17# SHA256SUMS (combined manifest, served by /dist/SHA256SUMS)
18# post-receive (the bundled git hook)
19# env.example (default .env shipped with installs)
20# migrations.tar.gz (drizzle/ archive — seed schema)
21# VERSION (package.json version)
22# MANIFEST.txt (human-readable index)
23#
24# Usage:
25# scripts/build-self-host-binary.sh # builds all 4 targets
26# GLUECRON_BUILD_TARGETS="linux-x64,linux-arm64" \
27# scripts/build-self-host-binary.sh # subset (CI matrix)
28#
29# Cross-compilation uses bun's `--target` flag. Bun ≥1.1 supports
30# bun-{linux,darwin}-{x64,arm64} as compile targets.
31# =============================================================================
32
33set -Eeuo pipefail
34
35ROOT="$(cd "$(dirname "$0")/.." && pwd)"
36cd "$ROOT"
37
38# ── pretty printers (match scripts/install.sh style) ────────────────────────
39say() { printf "\n\033[1;34m> %s\033[0m\n" "$*"; }
40ok() { printf " \033[32mv\033[0m %s\n" "$*"; }
41warn() { printf " \033[33m!\033[0m %s\n" "$*"; }
42fail() { printf " \033[31mx\033[0m %s\n" "$*"; exit 1; }
43
44# ── 0. Prereqs ──────────────────────────────────────────────────────────────
45command -v bun >/dev/null 2>&1 || fail "bun not on PATH (https://bun.sh)"
46
47DIST="$ROOT/dist"
48mkdir -p "$DIST"
49
50ALL_TARGETS=("linux-x64" "linux-arm64" "darwin-x64" "darwin-arm64")
51if [ -n "${GLUECRON_BUILD_TARGETS:-}" ]; then
52 IFS=',' read -r -a TARGETS <<<"$GLUECRON_BUILD_TARGETS"
53else
54 TARGETS=("${ALL_TARGETS[@]}")
55fi
56
57VERSION=$(node -p "require('./package.json').version" 2>/dev/null \
58 || bun -e "console.log(require('./package.json').version)" 2>/dev/null \
59 || echo "0.0.0-dev")
60say "Building Gluecron self-host binaries (v$VERSION)"
61ok "Targets: ${TARGETS[*]}"
62ok "Output: $DIST"
63
64# ── 1. Compile each target ──────────────────────────────────────────────────
65build_one() {
66 local PLAT_ARCH="$1"
67 local BUN_TARGET
68 case "$PLAT_ARCH" in
69 linux-x64) BUN_TARGET="bun-linux-x64" ;;
70 linux-arm64) BUN_TARGET="bun-linux-arm64" ;;
71 darwin-x64) BUN_TARGET="bun-darwin-x64" ;;
72 darwin-arm64) BUN_TARGET="bun-darwin-arm64" ;;
73 *) fail "unknown target $PLAT_ARCH" ;;
74 esac
75
76 local OUT="$DIST/gluecron-server-$PLAT_ARCH"
77 say "[build] $PLAT_ARCH$OUT"
78 # `--compile` produces a standalone executable bundling the bun runtime +
79 # all JS/TS source. `--minify` shaves ~15% off the binary. `--sourcemap`
80 # is intentionally omitted — we want the small ship size, and stack traces
81 # already include enough information from bun's frame info.
82 if bun build \
83 --compile \
84 --target="$BUN_TARGET" \
85 --minify \
86 --outfile "$OUT" \
87 src/index.ts; then
88 chmod +x "$OUT"
89 local SIZE
90 SIZE=$(du -h "$OUT" | awk '{print $1}')
91 ok "compiled $PLAT_ARCH ($SIZE)"
92 else
93 warn "compile failed for $PLAT_ARCH (continuing)"
94 return 1
95 fi
96}
97
98FAILED=()
99for T in "${TARGETS[@]}"; do
100 if ! build_one "$T"; then
101 FAILED+=("$T")
102 fi
103done
104
105# ── 2. Bundle ancillary assets ──────────────────────────────────────────────
106say "[bundle] hook + migrations + env.example"
107
108# post-receive hook (compiled to a single self-contained JS file the host
109# can drop into <repo>.git/hooks/). We ship the source TS too so operators
110# can audit the contents.
111cp -f src/hooks/post-receive.ts "$DIST/post-receive" || warn "post-receive copy failed"
112chmod +x "$DIST/post-receive" 2>/dev/null || true
113ok "post-receive hook → dist/post-receive"
114
115# Seed migrations — every SQL + migration runner needed for a fresh box.
116if [ -d drizzle ]; then
117 tar -czf "$DIST/migrations.tar.gz" drizzle
118 ok "migrations → dist/migrations.tar.gz"
119else
120 warn "drizzle/ directory missing — skipped migrations bundle"
121fi
122
123# Default .env.example
124if [ -f .env.example ]; then
125 cp -f .env.example "$DIST/env.example"
126 ok ".env.example → dist/env.example"
127else
128 warn ".env.example missing — skipped"
129fi
130
131# ── 3. Checksums ────────────────────────────────────────────────────────────
132say "[checksums] SHA-256 for each binary"
133SUMFILE="$DIST/SHA256SUMS"
134: >"$SUMFILE"
135for T in "${TARGETS[@]}"; do
136 BIN="$DIST/gluecron-server-$T"
137 if [ -f "$BIN" ]; then
138 HASH=$(sha256sum "$BIN" | awk '{print $1}')
139 echo "$HASH gluecron-server-$T" >>"$SUMFILE"
140 echo "$HASH" >"$BIN.sha256"
141 ok "sha256 $T ${HASH:0:12}…"
142 fi
143done
144
145# Include bundled ancillary assets in the combined manifest so the installer
146# can verify the env.example + migrations tarball too.
147for EXTRA in post-receive migrations.tar.gz env.example; do
148 if [ -f "$DIST/$EXTRA" ]; then
149 HASH=$(sha256sum "$DIST/$EXTRA" | awk '{print $1}')
150 echo "$HASH $EXTRA" >>"$SUMFILE"
151 fi
152done
153ok "manifest: $SUMFILE"
154
155# ── 4. VERSION + MANIFEST ───────────────────────────────────────────────────
156echo "$VERSION" >"$DIST/VERSION"
157
158{
159 echo "gluecron self-host bundle"
160 echo "version: $VERSION"
161 echo "built: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
162 echo
163 echo "files:"
164 (cd "$DIST" && ls -lh) | sed 's/^/ /'
165} >"$DIST/MANIFEST.txt"
166ok "manifest: dist/MANIFEST.txt"
167
168if [ "${#FAILED[@]}" -gt 0 ]; then
169 warn "Some targets failed to compile: ${FAILED[*]}"
170 warn "The bundle is incomplete but usable for the targets that succeeded."
171fi
172
173printf "\n"
174printf "\033[1;32m================================================================\033[0m\n"
175printf "\033[1;32m GLUECRON SELF-HOST BUNDLE READY (v%s)\033[0m\n" "$VERSION"
176printf "\033[1;32m================================================================\033[0m\n"
177printf " dist: %s\n" "$DIST"
178printf " publish: copy dist/ to the running gluecron host so it serves\n"
179printf " /dist/<filename> from the binary release route.\n"
180printf "\n"
Addedscripts/install-self-host.sh+346−0View fileUnifiedSplit
1#!/usr/bin/env bash
2# =============================================================================
3# Gluecron self-host installer.
4#
5# curl -fsSL https://gluecron.com/install-server | bash
6#
7# Goal: a working self-hosted Gluecron in 60 seconds. Microsoft GHE is a
8# 50GB blob and a week of professional services; we ship a ~200MB binary
9# and a curl-bash.
10#
11# What this script does (every step is idempotent + verbose):
12# 1. Detect OS + arch (linux/darwin × x64/arm64)
13# 2. Resolve host (default https://gluecron.com, env override)
14# 3. Fetch SHA256SUMS, then the binary, verify the hash
15# 4. Install to /opt/gluecron/bin (sudo) or ~/.gluecron/bin (no sudo)
16# 5. Provision Postgres: detect existing OR offer docker postgres
17# 6. Write /etc/gluecron.env (or ~/.gluecron/gluecron.env) from .env.example
18# and prompt for the few required keys
19# 7. Run migrations
20# 8. Create a systemd unit (linux) OR launchd plist (darwin) + start it
21# 9. Print the "Your Gluecron is live at http://localhost:3010" banner
22#
23# Hard rules:
24# - Never `set +e` — failures must bubble up.
25# - Never assume jq / docker / sudo are present — feature-flag everything.
26# - Re-runnable: a second run upgrades the binary in place.
27# =============================================================================
28
29set -Eeuo pipefail
30
31# ── pretty printers (matched to scripts/install.sh) ────────────────────────
32say() { printf "\n\033[1;34m> %s\033[0m\n" "$*"; }
33ok() { printf " \033[32mv\033[0m %s\n" "$*"; }
34warn() { printf " \033[33m!\033[0m %s\n" "$*"; }
35fail() { printf " \033[31mx\033[0m %s\n" "$*"; exit 1; }
36
37HOST="${GLUECRON_HOST:-https://gluecron.com}"
38HOST="${HOST%/}"
39PORT="${GLUECRON_PORT:-3010}"
40
41# ── 1. Detect platform ─────────────────────────────────────────────────────
42say "[1/9] Detecting platform"
43UNAME_S="$(uname -s 2>/dev/null || echo unknown)"
44UNAME_M="$(uname -m 2>/dev/null || echo unknown)"
45
46case "$UNAME_S" in
47 Linux*) PLAT=linux ;;
48 Darwin*) PLAT=darwin ;;
49 *) fail "Unsupported OS: $UNAME_S (linux / darwin only)" ;;
50esac
51
52case "$UNAME_M" in
53 x86_64|amd64) ARCH=x64 ;;
54 aarch64|arm64) ARCH=arm64 ;;
55 *) fail "Unsupported arch: $UNAME_M (x64 / arm64 only)" ;;
56esac
57
58TARGET="$PLAT-$ARCH"
59BIN_NAME="gluecron-server-$TARGET"
60ok "platform: $TARGET"
61
62# ── 2. Choose install root (sudo vs user) ──────────────────────────────────
63say "[2/9] Resolving install location"
64USE_SUDO=0
65INSTALL_PREFIX=""
66ENV_FILE=""
67SERVICE_USER=""
68
69if [ "$(id -u)" = "0" ]; then
70 INSTALL_PREFIX="/opt/gluecron"
71 ENV_FILE="/etc/gluecron.env"
72 SERVICE_USER="gluecron"
73 ok "running as root — installing to $INSTALL_PREFIX"
74elif command -v sudo >/dev/null 2>&1 && sudo -n true 2>/dev/null; then
75 USE_SUDO=1
76 INSTALL_PREFIX="/opt/gluecron"
77 ENV_FILE="/etc/gluecron.env"
78 SERVICE_USER="${SUDO_USER:-$(whoami)}"
79 ok "passwordless sudo available — installing to $INSTALL_PREFIX"
80else
81 INSTALL_PREFIX="$HOME/.gluecron"
82 ENV_FILE="$HOME/.gluecron/gluecron.env"
83 SERVICE_USER="$(whoami)"
84 ok "no sudo — installing to $INSTALL_PREFIX (user-scope)"
85fi
86
87run_priv() {
88 if [ "$USE_SUDO" = "1" ]; then
89 sudo "$@"
90 else
91 "$@"
92 fi
93}
94
95run_priv mkdir -p "$INSTALL_PREFIX/bin" "$INSTALL_PREFIX/repos" "$INSTALL_PREFIX/data"
96
97# ── 3. Download + verify the binary ────────────────────────────────────────
98say "[3/9] Fetching binary from $HOST"
99TMP="$(mktemp -d -t gluecron-install.XXXXXX)"
100trap 'rm -rf "$TMP"' EXIT
101
102SUMS_URL="$HOST/dist/SHA256SUMS"
103BIN_URL="$HOST/dist/$BIN_NAME"
104
105curl -fsSL --max-time 30 "$SUMS_URL" -o "$TMP/SHA256SUMS" \
106 || fail "Couldn't fetch $SUMS_URL — is the host serving binaries?"
107ok "checksums fetched"
108
109EXPECTED_HASH=$(awk -v f="$BIN_NAME" '$2==f {print $1}' "$TMP/SHA256SUMS")
110if [ -z "$EXPECTED_HASH" ]; then
111 fail "No checksum for $BIN_NAME in SHA256SUMS — build the bundle for your platform."
112fi
113
114curl -fsSL --max-time 600 "$BIN_URL" -o "$TMP/$BIN_NAME" \
115 || fail "Couldn't download $BIN_URL"
116ACTUAL_HASH=$(sha256sum "$TMP/$BIN_NAME" | awk '{print $1}')
117if [ "$ACTUAL_HASH" != "$EXPECTED_HASH" ]; then
118 fail "SHA-256 mismatch: expected $EXPECTED_HASH, got $ACTUAL_HASH"
119fi
120ok "verified sha256: ${ACTUAL_HASH:0:16}…"
121
122chmod +x "$TMP/$BIN_NAME"
123run_priv mv -f "$TMP/$BIN_NAME" "$INSTALL_PREFIX/bin/gluecron-server"
124ok "installed: $INSTALL_PREFIX/bin/gluecron-server"
125
126# Optional: fetch the env.example as a starter template
127if curl -fsSL --max-time 15 "$HOST/dist/env.example" -o "$TMP/env.example" 2>/dev/null; then
128 ok "env template fetched"
129else
130 warn "env.example not on server — using fallback minimal template"
131 cat >"$TMP/env.example" <<'ENV_FALLBACK'
132DATABASE_URL=postgresql://gluecron:gluecron@localhost:5432/gluecron
133GIT_REPOS_PATH=/opt/gluecron/repos
134PORT=3010
135ENV_FALLBACK
136fi
137
138# ── 4. Postgres — detect or provision ──────────────────────────────────────
139say "[4/9] Postgres"
140DETECTED_DB=""
141if command -v psql >/dev/null 2>&1; then
142 if psql -h localhost -U postgres -c 'SELECT 1' >/dev/null 2>&1; then
143 DETECTED_DB="postgresql://postgres@localhost:5432/postgres"
144 ok "found local Postgres reachable as postgres@localhost"
145 fi
146fi
147if [ -z "$DETECTED_DB" ] && [ -n "${DATABASE_URL:-}" ]; then
148 DETECTED_DB="$DATABASE_URL"
149 ok "using DATABASE_URL from env"
150fi
151
152DB_URL=""
153if [ -n "$DETECTED_DB" ]; then
154 DB_URL="$DETECTED_DB"
155else
156 warn "no existing Postgres detected"
157 if command -v docker >/dev/null 2>&1; then
158 if [ -t 0 ]; then
159 printf " Start a Postgres 14 container via docker? [Y/n] "
160 read -r ANSWER
161 else
162 ANSWER="${GLUECRON_USE_DOCKER_PG:-y}"
163 fi
164 case "$ANSWER" in
165 n|N|no|NO)
166 warn "skipping docker — you must set DATABASE_URL manually before first start"
167 ;;
168 *)
169 if docker ps --format '{{.Names}}' | grep -q '^gluecron-postgres$'; then
170 ok "gluecron-postgres container already running"
171 else
172 docker run -d --name gluecron-postgres \
173 -e POSTGRES_USER=gluecron \
174 -e POSTGRES_PASSWORD=gluecron \
175 -e POSTGRES_DB=gluecron \
176 -p 5432:5432 \
177 -v gluecron-pgdata:/var/lib/postgresql/data \
178 postgres:14 >/dev/null \
179 || warn "docker run failed — see docker logs gluecron-postgres"
180 ok "started gluecron-postgres container"
181 # Give Postgres a few seconds to accept connections.
182 sleep 4
183 fi
184 DB_URL="postgresql://gluecron:gluecron@localhost:5432/gluecron"
185 ;;
186 esac
187 else
188 warn "docker not on PATH either — please set DATABASE_URL in $ENV_FILE before starting"
189 fi
190fi
191
192# ── 5. Write env file ──────────────────────────────────────────────────────
193say "[5/9] Writing $ENV_FILE"
194TMP_ENV="$(mktemp -t gluecron-env.XXXXXX)"
195# Start from the template, then overwrite the keys we know.
196cp "$TMP/env.example" "$TMP_ENV"
197patch_env() {
198 local KEY="$1" VALUE="$2"
199 if grep -qE "^${KEY}=" "$TMP_ENV"; then
200 # macOS sed needs the empty -i arg; gnu sed accepts -i alone.
201 sed -i.bak -e "s|^${KEY}=.*$|${KEY}=${VALUE}|" "$TMP_ENV" && rm -f "$TMP_ENV.bak"
202 else
203 echo "${KEY}=${VALUE}" >>"$TMP_ENV"
204 fi
205}
206
207if [ -n "$DB_URL" ]; then patch_env DATABASE_URL "$DB_URL"; fi
208patch_env GIT_REPOS_PATH "$INSTALL_PREFIX/repos"
209patch_env PORT "$PORT"
210
211# Prompt only when stdin is a TTY — non-interactive runs (CI, docker build)
212# just inherit existing env vars and ship.
213if [ -t 0 ] && [ -z "${GLUECRON_SKIP_PROMPTS:-}" ]; then
214 if ! grep -qE '^APP_BASE_URL=' "$TMP_ENV"; then
215 printf " Public URL of this instance [http://localhost:%s]: " "$PORT"
216 read -r APP_BASE_URL
217 APP_BASE_URL="${APP_BASE_URL:-http://localhost:$PORT}"
218 patch_env APP_BASE_URL "$APP_BASE_URL"
219 fi
220fi
221
222run_priv mkdir -p "$(dirname "$ENV_FILE")"
223run_priv install -m 0640 "$TMP_ENV" "$ENV_FILE"
224ok "wrote $ENV_FILE"
225
226# ── 6. Migrations ──────────────────────────────────────────────────────────
227say "[6/9] Running database migrations"
228# The compiled binary embeds the migration runner — invoke it via the
229# GLUECRON_RUN_MIGRATIONS env flag (handled in src/index.ts at boot when
230# the corresponding sub-command lands). For now we drive `psql` if the
231# migrations tarball is available.
232if curl -fsSL --max-time 30 "$HOST/dist/migrations.tar.gz" -o "$TMP/migrations.tar.gz" 2>/dev/null; then
233 mkdir -p "$TMP/migrations"
234 tar -xzf "$TMP/migrations.tar.gz" -C "$TMP/migrations"
235 ok "migrations bundle fetched"
236fi
237
238# Source the env so DATABASE_URL is visible to the binary.
239# shellcheck disable=SC1090
240set -a; . "$ENV_FILE"; set +a
241if [ -n "${DATABASE_URL:-}" ]; then
242 if "$INSTALL_PREFIX/bin/gluecron-server" --migrate 2>/dev/null; then
243 ok "migrations applied via gluecron-server --migrate"
244 else
245 warn "binary --migrate failed (older build?) — re-run after first start; the boot path also self-migrates."
246 fi
247else
248 warn "DATABASE_URL not set — skipped migrations. Set it in $ENV_FILE and re-run."
249fi
250
251# ── 7. Systemd unit (linux) / launchd plist (darwin) ──────────────────────
252say "[7/9] Creating service"
253if [ "$PLAT" = "linux" ] && command -v systemctl >/dev/null 2>&1; then
254 UNIT_PATH="/etc/systemd/system/gluecron.service"
255 TMP_UNIT="$(mktemp -t gluecron-unit.XXXXXX)"
256 cat >"$TMP_UNIT" <<UNIT
257[Unit]
258Description=Gluecron self-host server
259After=network.target
260
261[Service]
262Type=simple
263User=$SERVICE_USER
264EnvironmentFile=$ENV_FILE
265ExecStart=$INSTALL_PREFIX/bin/gluecron-server
266Restart=on-failure
267RestartSec=3
268WorkingDirectory=$INSTALL_PREFIX
269
270[Install]
271WantedBy=multi-user.target
272UNIT
273 run_priv install -m 0644 "$TMP_UNIT" "$UNIT_PATH"
274 run_priv systemctl daemon-reload
275 run_priv systemctl enable --now gluecron.service \
276 || warn "systemctl enable failed — start manually with: systemctl start gluecron"
277 ok "systemd unit installed and started"
278
279elif [ "$PLAT" = "darwin" ]; then
280 PLIST_PATH="$HOME/Library/LaunchAgents/com.gluecron.server.plist"
281 mkdir -p "$(dirname "$PLIST_PATH")"
282 cat >"$PLIST_PATH" <<PLIST
283<?xml version="1.0" encoding="UTF-8"?>
284<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
285<plist version="1.0">
286<dict>
287 <key>Label</key><string>com.gluecron.server</string>
288 <key>ProgramArguments</key>
289 <array>
290 <string>$INSTALL_PREFIX/bin/gluecron-server</string>
291 </array>
292 <key>EnvironmentVariables</key><dict>
293 <key>GLUECRON_ENV_FILE</key><string>$ENV_FILE</string>
294 </dict>
295 <key>WorkingDirectory</key><string>$INSTALL_PREFIX</string>
296 <key>RunAtLoad</key><true/>
297 <key>KeepAlive</key><true/>
298 <key>StandardOutPath</key><string>$INSTALL_PREFIX/data/gluecron.out.log</string>
299 <key>StandardErrorPath</key><string>$INSTALL_PREFIX/data/gluecron.err.log</string>
300</dict>
301</plist>
302PLIST
303 launchctl unload "$PLIST_PATH" 2>/dev/null || true
304 launchctl load "$PLIST_PATH" \
305 || warn "launchctl load failed — start manually with: launchctl load $PLIST_PATH"
306 ok "launchd plist installed: $PLIST_PATH"
307
308else
309 warn "no service manager detected — start manually with:"
310 warn " $INSTALL_PREFIX/bin/gluecron-server"
311fi
312
313# ── 8. Wait for health ─────────────────────────────────────────────────────
314say "[8/9] Waiting for the server to come up"
315HEALTH="http://localhost:$PORT/healthz"
316GREEN=0
317for i in 1 2 3 4 5 6 7 8 9 10; do
318 CODE=$(curl -fsS -o /dev/null -w "%{http_code}" --max-time 2 "$HEALTH" || echo 000)
319 if [ "$CODE" = "200" ]; then GREEN=1; break; fi
320 sleep 1
321done
322if [ "$GREEN" = "1" ]; then
323 ok "/healthz green"
324else
325 warn "server didn't return 200 within 10s — check logs (journalctl -u gluecron) or run:"
326 warn " $INSTALL_PREFIX/bin/gluecron-server"
327fi
328
329# ── 9. Done ────────────────────────────────────────────────────────────────
330say "[9/9] All set"
331printf "\n"
332printf "\033[1;32m============================================================\033[0m\n"
333printf "\033[1;32m GLUECRON SELF-HOST INSTALL COMPLETE\033[0m\n"
334printf "\033[1;32m============================================================\033[0m\n"
335printf " Binary: %s/bin/gluecron-server\n" "$INSTALL_PREFIX"
336printf " Env: %s\n" "$ENV_FILE"
337printf " Repos: %s/repos\n" "$INSTALL_PREFIX"
338printf " Port: %s\n" "$PORT"
339printf "\n"
340printf " Your Gluecron is live at \033[1mhttp://localhost:%s\033[0m — visit\n" "$PORT"
341printf " to create your first admin user.\n"
342printf "\n"
343printf " Upgrade: curl -fsSL %s/install-server | bash\n" "$HOST"
344printf " Logs: journalctl -u gluecron -f (linux)\n"
345printf " Logs: tail -f %s/data/gluecron.err.log (darwin)\n" "$INSTALL_PREFIX"
346printf "\n"
Modifiedsrc/__tests__/install.test.ts+85−1View fileUnifiedSplit
1515
1616import { describe, it, expect } from "bun:test";
1717import app from "../app";
18import { INSTALL_SCRIPT_SRC } from "../routes/install";
18import { INSTALL_SCRIPT_SRC, SELF_HOST_SCRIPT_SRC } from "../routes/install";
1919
2020const HAS_DB = Boolean(process.env.DATABASE_URL);
2121
6565 });
6666});
6767
68// ---------------------------------------------------------------------------
69// 1a. GET /install-server — self-host single-binary installer
70// ---------------------------------------------------------------------------
71
72describe("install — GET /install-server", () => {
73 it("returns 200 with the self-host bash script body", async () => {
74 const res = await app.request("/install-server");
75 expect(res.status).toBe(200);
76 const body = await res.text();
77 expect(body.length).toBeGreaterThan(0);
78 expect(body.startsWith("#!/usr/bin/env bash")).toBe(true);
79 });
80
81 it("serves Content-Type: text/x-shellscript", async () => {
82 const res = await app.request("/install-server");
83 const ct = res.headers.get("content-type") || "";
84 expect(ct).toContain("text/x-shellscript");
85 });
86
87 it("serves a public Cache-Control so a CDN can absorb load", async () => {
88 const res = await app.request("/install-server");
89 const cc = res.headers.get("cache-control") || "";
90 expect(cc).toContain("public");
91 expect(cc).toContain("max-age=300");
92 });
93
94 it("script body contains the key self-host install markers", async () => {
95 const res = await app.request("/install-server");
96 const body = await res.text();
97 // Sanity-check the real script's contract.
98 if (SELF_HOST_SCRIPT_SRC.includes("set -Eeuo pipefail")) {
99 expect(body).toContain("set -Eeuo pipefail");
100 // Platform detection.
101 expect(body).toContain("uname -s");
102 // Binary download path matches the /dist/:filename route.
103 expect(body).toContain("/dist/SHA256SUMS");
104 expect(body).toContain("gluecron-server-");
105 // Sha-256 verification gate is present.
106 expect(body).toContain("SHA-256 mismatch");
107 // Service installation.
108 expect(body).toContain("systemd");
109 }
110 });
111
112 it("rewrites the default HOST to the request origin", async () => {
113 const res = await app.request("/install-server", {
114 headers: {
115 "x-forwarded-proto": "https",
116 "x-forwarded-host": "self-host.example",
117 },
118 });
119 const body = await res.text();
120 if (SELF_HOST_SCRIPT_SRC.includes("https://gluecron.com")) {
121 // The rewrite leaves an explicit fallback to the inbound origin.
122 expect(body).toContain("https://self-host.example");
123 }
124 });
125
126 it("SELF_HOST_SCRIPT_SRC exports a non-empty bash script", () => {
127 expect(SELF_HOST_SCRIPT_SRC.length).toBeGreaterThan(0);
128 expect(SELF_HOST_SCRIPT_SRC.startsWith("#!")).toBe(true);
129 });
130});
131
132// ---------------------------------------------------------------------------
133// 1c. GET /dist/:filename — binary release endpoint
134// ---------------------------------------------------------------------------
135
136describe("install — GET /dist/:filename", () => {
137 it("404s for filenames that don't exist", async () => {
138 const res = await app.request("/dist/definitely-not-a-real-binary");
139 expect(res.status).toBe(404);
140 });
141
142 it("rejects path traversal attempts", async () => {
143 // The route param regex blocks `..` outright — Hono URI-decodes %2F
144 // back to a slash so the param literally contains the traversal.
145 const res = await app.request("/dist/..%2Fpackage.json");
146 expect(res.status).toBe(404);
147 const body = await res.json();
148 expect(typeof body.error).toBe("string");
149 });
150});
151
68152// ---------------------------------------------------------------------------
69153// 1b. GET /install/vscode — VS Code extension landing
70154// ---------------------------------------------------------------------------
Modifiedsrc/routes/help.tsx+83−0View fileUnifiedSplit
297297 <a href="#shortcuts">Keyboard shortcuts</a>
298298 <a href="#api">API</a>
299299 <a href="#build-agents">Build-agent integration</a>
300 <a href="#self-host">Self-host (single binary)</a>
300301 </div>
301302 </nav>
302303
10461047 </div>
10471048 </section>
10481049
1050 {/* ─── Self-host (single binary) ─── */}
1051 <section id="self-host" class="help-section">
1052 <div class="help-section-head">
1053 <div class="help-section-eyebrow">Self-host</div>
1054 <h2 class="help-section-title">Single-binary self-host</h2>
1055 <p class="help-section-desc">
1056 GitHub Enterprise Server is a 50&nbsp;GB blob and a week of
1057 professional services. Gluecron self-host is a ~200&nbsp;MB
1058 binary and 60 seconds. One curl, you own the whole stack.
1059 </p>
1060 </div>
1061 <div class="help-section-body">
1062 <div class="help-item">
1063 <strong>One-liner.</strong>
1064 <pre><code>curl -fsSL https://gluecron.com/install-server | bash</code></pre>
1065 The script detects your OS + arch, downloads the right
1066 binary from <code>/dist/</code>, verifies SHA-256 against{" "}
1067 <code>/dist/SHA256SUMS</code>, installs to{" "}
1068 <code>/opt/gluecron/bin/gluecron-server</code> (or
1069 <code>~/.gluecron/bin</code> with no sudo), provisions
1070 Postgres, writes <code>/etc/gluecron.env</code>, runs
1071 migrations, and starts the systemd unit (linux) or launchd
1072 plist (darwin).
1073 </div>
1074 <div class="help-item">
1075 <strong>Hardware requirements.</strong>
1076 <ul>
1077 <li>4&nbsp;GB RAM (8&nbsp;GB for teams &gt; 20)</li>
1078 <li>20&nbsp;GB disk for repos + Postgres data</li>
1079 <li>Postgres 14+ (auto-installed via docker if missing)</li>
1080 <li>Linux (x64 / arm64) or macOS (x64 / arm64)</li>
1081 </ul>
1082 </div>
1083 <div class="help-item">
1084 <strong>Configuration walkthrough.</strong> The installer
1085 writes <code>/etc/gluecron.env</code> seeded from{" "}
1086 <code>.env.example</code>. At minimum, set:
1087 <ul>
1088 <li><code>DATABASE_URL</code> — Postgres connection string</li>
1089 <li><code>APP_BASE_URL</code> — public URL of the instance</li>
1090 <li><code>PORT</code> — listen port (default 3010)</li>
1091 <li><code>GIT_REPOS_PATH</code> — bare-repo directory</li>
1092 </ul>
1093 Anything else (Anthropic key for AI features, GateTest
1094 callback secret, OAuth providers) can be added later
1095 without a restart via <code>/admin/integrations</code>.
1096 </div>
1097 <div class="help-item">
1098 <strong>Operations.</strong>
1099 <ul>
1100 <li>
1101 <em>Backup.</em>{" "}
1102 <code>pg_dump $DATABASE_URL | gzip &gt; gluecron-$(date +%F).sql.gz</code>{" "}
1103 plus <code>tar -czf repos-$(date +%F).tgz /opt/gluecron/repos</code>.
1104 </li>
1105 <li>
1106 <em>Restore.</em> Stop systemd, restore the SQL dump
1107 with <code>gunzip -c &lt; dump.sql.gz | psql $DATABASE_URL</code>,
1108 untar the repos, and start the unit.
1109 </li>
1110 <li>
1111 <em>Upgrade.</em> Re-run the installer — it overwrites
1112 the binary in place and restarts the service. The
1113 migrations runner is idempotent.
1114 </li>
1115 <li>
1116 <em>Logs.</em>{" "}
1117 <code>journalctl -u gluecron -f</code> (linux) or{" "}
1118 <code>tail -f ~/.gluecron/data/gluecron.err.log</code>{" "}
1119 (darwin).
1120 </li>
1121 </ul>
1122 </div>
1123 <div class="help-item">
1124 <strong>Versus GitHub Enterprise Server.</strong> GHES is a
1125 50&nbsp;GB OVA image, requires VMware/Hyper-V, and bills
1126 per-seat. Gluecron self-host is a single binary, runs
1127 anywhere bun runs, and is BYO-license.
1128 </div>
1129 </div>
1130 </section>
1131
10491132 <p class="help-footnote">
10501133 Something missing? Open an issue on gluecron's source repo.
10511134 </p>
Modifiedsrc/routes/install.ts+132−0View fileUnifiedSplit
2020
2121const install = new Hono();
2222
23// ─── Self-host installer (curl gluecron.com/install-server | bash) ─────────
24//
25// Parallel to the Claude Desktop / MCP installer above, but ships a single
26// portable binary for the FULL server. The script is loaded once at module
27// load and we rewrite the `GLUECRON_HOST` default to whatever host the
28// request was served from, so installs from staging / dev / mirror sites
29// download binaries from the same origin instead of the prod default.
30
31const FALLBACK_SELF_HOST_SCRIPT = `#!/usr/bin/env bash
32echo "Gluecron self-host install script unavailable on this host." >&2
33echo "Fetch it directly from https://gluecron.com/install-server" >&2
34exit 1
35`;
36
37function loadSelfHostScript(): string {
38 const candidates = [
39 join(process.cwd(), "scripts", "install-self-host.sh"),
40 join(import.meta.dir, "..", "..", "scripts", "install-self-host.sh"),
41 ];
42 for (const p of candidates) {
43 try {
44 const buf = readFileSync(p, "utf8");
45 if (buf && buf.length > 0) return buf;
46 } catch {
47 // try next
48 }
49 }
50 return FALLBACK_SELF_HOST_SCRIPT;
51}
52
53export const SELF_HOST_SCRIPT_SRC = loadSelfHostScript();
54
55// Rewrite the default `${GLUECRON_HOST:-https://gluecron.com}` so a curl
56// from a custom host downloads binaries from the same origin without the
57// operator having to set the env var manually.
58function selfHostScriptForOrigin(origin: string): string {
59 const safeOrigin = origin.replace(/[`$"\\]/g, "");
60 return SELF_HOST_SCRIPT_SRC.replace(
61 /HOST="\$\{GLUECRON_HOST:-https:\/\/gluecron\.com\}"/,
62 `HOST="\${GLUECRON_HOST:-${safeOrigin}}"`
63 );
64}
65
66install.get("/install-server", (c) => {
67 // Derive the canonical origin from the inbound request so the script
68 // pulls binaries from the same host the user just curled.
69 const url = new URL(c.req.url);
70 // Trust forwarded headers if a reverse proxy set them (Crontech /
71 // Caddy / Fly all do). Falls back to the raw URL origin.
72 const proto =
73 c.req.header("x-forwarded-proto") || url.protocol.replace(":", "");
74 const host = c.req.header("x-forwarded-host") || url.host;
75 const origin = `${proto}://${host}`;
76
77 c.header("content-type", "text/x-shellscript; charset=utf-8");
78 c.header("cache-control", "public, max-age=300");
79 c.header(
80 "content-disposition",
81 'inline; filename="install-self-host.sh"'
82 );
83 return c.body(selfHostScriptForOrigin(origin));
84});
85
86// ─── /dist/:filename — serve compiled binaries + manifest ──────────────────
87//
88// Built by `scripts/build-self-host-binary.sh`. The installer above fetches
89// `/dist/SHA256SUMS` then `/dist/gluecron-server-<plat>-<arch>` from this
90// endpoint. We never list the directory; only filenames present in
91// `dist/` resolve, and any path-traversal attempt 404s.
92
93const DIST_DIR_CANDIDATES = [
94 join(process.cwd(), "dist"),
95 join(import.meta.dir, "..", "..", "dist"),
96];
97
98function resolveDistRoot(): string | null {
99 for (const d of DIST_DIR_CANDIDATES) {
100 if (existsSync(d)) return d;
101 }
102 return null;
103}
104
105function contentTypeFor(name: string): string {
106 if (name.endsWith(".sha256")) return "text/plain; charset=utf-8";
107 if (name === "SHA256SUMS" || name === "VERSION" || name === "MANIFEST.txt") {
108 return "text/plain; charset=utf-8";
109 }
110 if (name === "env.example") return "text/plain; charset=utf-8";
111 if (name.endsWith(".tar.gz") || name.endsWith(".tgz")) {
112 return "application/gzip";
113 }
114 return "application/octet-stream";
115}
116
117install.get("/dist/:filename", async (c) => {
118 const filename = c.req.param("filename");
119 // Hard-reject any path component or traversal characters. Filenames
120 // produced by the build script are alphanumerics + dash + dot only.
121 if (!/^[A-Za-z0-9._-]+$/.test(filename)) {
122 return c.json({ error: "invalid_filename" }, 404);
123 }
124 const root = resolveDistRoot();
125 if (!root) {
126 return c.json(
127 {
128 error: "dist_not_built",
129 hint: "Run scripts/build-self-host-binary.sh on this host.",
130 },
131 404
132 );
133 }
134 const path = join(root, filename);
135 if (!existsSync(path)) {
136 return c.json({ error: "not_found", filename }, 404);
137 }
138 const stat = statSync(path);
139 if (!stat.isFile()) {
140 return c.json({ error: "not_a_file", filename }, 404);
141 }
142 const file = Bun.file(path);
143 return new Response(file.stream(), {
144 headers: {
145 "Content-Type": contentTypeFor(filename),
146 "Content-Length": String(stat.size),
147 "Cache-Control": "public, max-age=3600",
148 // Make the browser save the file with a sensible filename when a
149 // user visits the URL directly; doesn't break `curl -o` either.
150 "Content-Disposition": `attachment; filename="${filename}"`,
151 },
152 });
153});
154
23155// Resolve at module-load time. We try the on-disk script first; if anything
24156// goes sideways (missing file in a stripped container image, weird CWD, etc.)
25157// we fall back to a stub that points the user at the canonical URL so the
Modifiedsrc/routes/vs-github.tsx+121−2View fileUnifiedSplit
184184 rows: [
185185 {
186186 feature: "Self-hostable",
187 gh: { verdict: "partial", note: "Enterprise tier only" },
188 gc: { verdict: "yes", note: "Single binary" },
187 gh: { verdict: "partial", note: "GHES — 50GB blob, days to install" },
188 gc: {
189 verdict: "yes",
190 note: "200MB binary, 60s curl-bash",
191 href: "/help#self-host",
192 },
189193 },
190194 {
191195 feature: "Single-tenant (your code stays yours)",
11011105 </div>
11021106 </section>
11031107
1108 {/* ============ SELF-HOST COMPARISON ============ */}
1109 <section class="vsg-section vsg-selfhost-section">
1110 <div class="section-header vsg-section-header">
1111 <div class="eyebrow vsg-eyebrow-plain">Self-host · 60 seconds vs 5 days</div>
1112 <h2 class="vsg-h2">
1113 50&nbsp;GB blob.{" "}
1114 <span class="vsg-title-grad">Or 200&nbsp;MB binary.</span>
1115 </h2>
1116 <p class="vsg-section-sub">
1117 GitHub Enterprise Server is a virtual-appliance image that
1118 takes professional services to install. Gluecron self-host
1119 is one curl.
1120 </p>
1121 </div>
1122
1123 <div class="vsg-selfhost-grid">
1124 <div class="vsg-selfhost-card vsg-selfhost-card-them">
1125 <div class="vsg-selfhost-card-label">GitHub Enterprise Server</div>
1126 <div class="vsg-selfhost-card-big">~50 GB</div>
1127 <ul class="vsg-selfhost-card-list">
1128 <li>OVA / Hyper-V image, not a binary</li>
1129 <li>Days to install, often professional services</li>
1130 <li>VMware or Hyper-V required</li>
1131 <li>Per-seat licensing</li>
1132 </ul>
1133 </div>
1134 <div class="vsg-selfhost-card vsg-selfhost-card-us">
1135 <div class="vsg-selfhost-card-label">
1136 <span class="vsg-title-grad">Gluecron self-host</span>
1137 </div>
1138 <div class="vsg-selfhost-card-big vsg-title-grad">~200 MB</div>
1139 <ul class="vsg-selfhost-card-list">
1140 <li>Single static binary (bun --compile)</li>
1141 <li>60-second curl-bash, no PS required</li>
1142 <li>Runs on any linux / darwin host</li>
1143 <li>BYO-license — your DB, your disk</li>
1144 </ul>
1145 <pre class="vsg-selfhost-curl"><code>curl -fsSL https://gluecron.com/install-server | bash</code></pre>
1146 <a href="/help#self-host" class="vsg-killer-card-link">
1147 Operations playbook
1148 <span aria-hidden="true"></span>
1149 </a>
1150 </div>
1151 </div>
1152 </section>
1153
11041154 {/* ============ FAQ ============ */}
11051155 <section class="vsg-section">
11061156 <div class="section-header vsg-section-header">
21312181 justify-content: center;
21322182 flex-wrap: wrap;
21332183 }
2184
2185 /* ============ SELF-HOST BLOCK ============ */
2186 .vsg-page .vsg-selfhost-grid {
2187 display: grid;
2188 grid-template-columns: 1fr 1fr;
2189 gap: 18px;
2190 align-items: stretch;
2191 margin-top: 12px;
2192 }
2193 @media (max-width: 720px) {
2194 .vsg-page .vsg-selfhost-grid { grid-template-columns: 1fr; }
2195 }
2196 .vsg-page .vsg-selfhost-card {
2197 background: var(--bg-elevated);
2198 border: 1px solid var(--border);
2199 border-radius: 16px;
2200 padding: 28px 24px;
2201 display: flex;
2202 flex-direction: column;
2203 gap: 14px;
2204 position: relative;
2205 overflow: hidden;
2206 }
2207 .vsg-page .vsg-selfhost-card-them { opacity: 0.86; }
2208 .vsg-page .vsg-selfhost-card-us {
2209 border-color: rgba(140,109,255,0.35);
2210 box-shadow: 0 0 0 1px rgba(140,109,255,0.16), 0 14px 40px -20px rgba(0,0,0,0.55);
2211 }
2212 .vsg-page .vsg-selfhost-card-label {
2213 font-family: var(--font-mono);
2214 font-size: 11px;
2215 text-transform: uppercase;
2216 letter-spacing: 0.14em;
2217 color: var(--text-faint);
2218 font-weight: 600;
2219 }
2220 .vsg-page .vsg-selfhost-card-big {
2221 font-family: var(--font-display);
2222 font-size: clamp(36px, 6vw, 56px);
2223 font-weight: 800;
2224 letter-spacing: -0.03em;
2225 line-height: 1;
2226 color: var(--text-strong);
2227 }
2228 .vsg-page .vsg-selfhost-card-list {
2229 list-style: none;
2230 padding: 0;
2231 margin: 0;
2232 display: flex;
2233 flex-direction: column;
2234 gap: 8px;
2235 color: var(--text-muted);
2236 font-size: 14px;
2237 line-height: 1.55;
2238 }
2239 .vsg-page .vsg-selfhost-card-list li::before {
2240 content: '— ';
2241 color: var(--text-faint);
2242 }
2243 .vsg-page .vsg-selfhost-curl {
2244 margin: 8px 0 0;
2245 background: #0e1116;
2246 color: #e6edf3;
2247 padding: 12px 14px;
2248 border-radius: 8px;
2249 font: 12px ui-monospace, monospace;
2250 overflow-x: auto;
2251 white-space: nowrap;
2252 }
21342253`;
21352254
21362255export default vsGithub;
21372256