CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
install.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 46d6165 | 1 | /** |
| 2 | * Block L2 — one-command install. | |
| 3 | * | |
| 63fe719 | 4 | * GET /install -> the bash installer (scripts/install.sh). |
| 5 | * GET /install/vscode -> a tiny HTML landing for the VS Code extension | |
| 6 | * with install instructions and a .vsix link if | |
| 7 | * one has been uploaded to `public/`. | |
| 46d6165 | 8 | * |
| 9 | * Curl-able: `curl -sSL https://gluecron.com/install | bash`. | |
| 10 | * | |
| 11 | * The script is read from disk once at module load and cached in memory; we | |
| 12 | * also serve it with `Cache-Control: public, max-age=300` so any CDN in front | |
| 13 | * of us can absorb the load. Mirrors the static-string pattern used by | |
| 14 | * `src/routes/pwa.ts`. | |
| 15 | */ | |
| 16 | ||
| 17 | import { Hono } from "hono"; | |
| 63fe719 | 18 | import { readFileSync, existsSync, statSync, readdirSync } from "fs"; |
| 46d6165 | 19 | import { join } from "path"; |
| 20 | ||
| 21 | const install = new Hono(); | |
| 22 | ||
| c4e643c | 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 | ||
| 31 | const FALLBACK_SELF_HOST_SCRIPT = `#!/usr/bin/env bash | |
| 32 | echo "Gluecron self-host install script unavailable on this host." >&2 | |
| 33 | echo "Fetch it directly from https://gluecron.com/install-server" >&2 | |
| 34 | exit 1 | |
| 35 | `; | |
| 36 | ||
| 37 | function 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 | ||
| 53 | export 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. | |
| 58 | function 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 | ||
| 66 | install.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 | ||
| 93 | const DIST_DIR_CANDIDATES = [ | |
| 94 | join(process.cwd(), "dist"), | |
| 95 | join(import.meta.dir, "..", "..", "dist"), | |
| 96 | ]; | |
| 97 | ||
| 98 | function resolveDistRoot(): string | null { | |
| 99 | for (const d of DIST_DIR_CANDIDATES) { | |
| 100 | if (existsSync(d)) return d; | |
| 101 | } | |
| 102 | return null; | |
| 103 | } | |
| 104 | ||
| 105 | function 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 | ||
| 117 | install.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 | ||
| 46d6165 | 155 | // Resolve at module-load time. We try the on-disk script first; if anything |
| 156 | // goes sideways (missing file in a stripped container image, weird CWD, etc.) | |
| 157 | // we fall back to a stub that points the user at the canonical URL so the | |
| 158 | // endpoint always returns *something* shell-safe. | |
| 159 | const FALLBACK_SCRIPT = `#!/usr/bin/env bash | |
| 160 | echo "Gluecron install script unavailable on this host." >&2 | |
| 161 | echo "Fetch it directly from https://gluecron.com/install" >&2 | |
| 162 | exit 1 | |
| 163 | `; | |
| 164 | ||
| 165 | function loadScript(): string { | |
| 166 | // Walk a few candidate locations so this works in dev, tests, and the | |
| 167 | // fly.io container where CWD may be /app. | |
| 168 | const candidates = [ | |
| 169 | join(process.cwd(), "scripts", "install.sh"), | |
| 170 | join(import.meta.dir, "..", "..", "scripts", "install.sh"), | |
| 171 | ]; | |
| 172 | for (const p of candidates) { | |
| 173 | try { | |
| 174 | const buf = readFileSync(p, "utf8"); | |
| 175 | if (buf && buf.length > 0) return buf; | |
| 176 | } catch { | |
| 177 | // try the next candidate | |
| 178 | } | |
| 179 | } | |
| 180 | return FALLBACK_SCRIPT; | |
| 181 | } | |
| 182 | ||
| 183 | export const INSTALL_SCRIPT_SRC = loadScript(); | |
| 184 | ||
| 185 | install.get("/install", (c) => { | |
| 186 | c.header("content-type", "text/x-shellscript; charset=utf-8"); | |
| 187 | c.header("cache-control", "public, max-age=300"); | |
| 188 | // Make `curl https://gluecron.com/install -o install.sh` give a sensible | |
| 189 | // default filename without forcing it for piped installs. | |
| 190 | c.header("content-disposition", 'inline; filename="install.sh"'); | |
| 191 | return c.body(INSTALL_SCRIPT_SRC); | |
| 192 | }); | |
| 193 | ||
| 63fe719 | 194 | // ─── VS Code extension landing ────────────────────────────────────────────── |
| 195 | // | |
| 196 | // We don't (yet) have a marketplace listing, so this page shows install | |
| 197 | // instructions and — if a .vsix has been dropped into `public/` — a | |
| 198 | // download link. The page is intentionally minimalist: it does NOT pull | |
| 199 | // in the main app layout so a misbehaving CSS change can't break the | |
| 200 | // install funnel. | |
| 201 | ||
| 202 | const MARKETPLACE_URL = | |
| 203 | "https://marketplace.visualstudio.com/items?itemName=gluecron.gluecron-vscode"; | |
| 204 | ||
| 205 | function findVsix(): { name: string; size: number } | null { | |
| 206 | // Look under `public/` so the file is served by the static handler | |
| 207 | // (Bun's `Bun.file` route at /:filename). Keep it deterministic — | |
| 208 | // largest-version sorted last after lexicographic sort works for our | |
| 209 | // semver naming. | |
| 210 | const candidates = [ | |
| 211 | join(process.cwd(), "public"), | |
| 212 | join(import.meta.dir, "..", "..", "public"), | |
| 213 | ]; | |
| 214 | for (const dir of candidates) { | |
| 215 | try { | |
| 216 | if (!existsSync(dir)) continue; | |
| 217 | const files = readdirSync(dir).filter((f) => f.endsWith(".vsix")); | |
| 218 | if (files.length === 0) continue; | |
| 219 | files.sort(); | |
| 220 | const pick = files[files.length - 1]; | |
| 221 | const st = statSync(join(dir, pick)); | |
| 222 | return { name: pick, size: st.size }; | |
| 223 | } catch { | |
| 224 | // try the next candidate | |
| 225 | } | |
| 226 | } | |
| 227 | return null; | |
| 228 | } | |
| 229 | ||
| 230 | function formatBytes(n: number): string { | |
| 231 | if (n < 1024) return `${n} B`; | |
| 232 | if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KiB`; | |
| 233 | return `${(n / (1024 * 1024)).toFixed(1)} MiB`; | |
| 234 | } | |
| 235 | ||
| 236 | function vscodeLandingHtml(vsix: { name: string; size: number } | null): string { | |
| 237 | const vsixBlock = vsix | |
| 238 | ? ` | |
| 239 | <p> | |
| 240 | <a class="cta" href="/${encodeURIComponent(vsix.name)}" download> | |
| 241 | Download ${vsix.name} (${formatBytes(vsix.size)}) | |
| 242 | </a> | |
| 243 | </p> | |
| 244 | <pre><code>code --install-extension ${vsix.name}</code></pre>` | |
| 245 | : ` | |
| 246 | <p class="muted"> | |
| 247 | No <code>.vsix</code> uploaded yet — build one yourself: | |
| 248 | </p> | |
| 249 | <pre><code>git clone https://gluecron.com/ccantynz/Gluecron.com | |
| 250 | cd Gluecron.com/editor-extensions/vscode | |
| 251 | npm install | |
| 252 | npm run compile | |
| 253 | npx vsce package | |
| 254 | code --install-extension gluecron-vscode-0.1.0.vsix</code></pre>`; | |
| 255 | ||
| 256 | return `<!DOCTYPE html> | |
| 257 | <html lang="en"> | |
| 258 | <head> | |
| 259 | <meta charset="utf-8" /> | |
| 260 | <title>Gluecron for VS Code</title> | |
| 261 | <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| 262 | <style> | |
| 263 | :root { color-scheme: dark light; } | |
| 264 | body { font: 15px/1.55 system-ui, sans-serif; max-width: 640px; margin: 5rem auto; padding: 0 1rem; } | |
| 265 | h1 { margin-top: 0; font-size: 1.6rem; } | |
| 266 | .badge { display: inline-block; padding: 4px 10px; border-radius: 4px; background: #2c2c2c; color: #fff; font-size: 13px; text-decoration: none; } | |
| 267 | .cta { display: inline-block; padding: 8px 14px; border-radius: 6px; background: #1f6feb; color: #fff; text-decoration: none; font-weight: 600; } | |
| 268 | pre { background: #0e1116; color: #e6edf3; padding: 12px; border-radius: 6px; overflow-x: auto; } | |
| 269 | code { font: 13px ui-monospace, monospace; } | |
| 270 | .muted { opacity: 0.75; } | |
| 271 | ul { padding-left: 1.2rem; } | |
| 272 | </style> | |
| 273 | </head> | |
| 274 | <body> | |
| 275 | <h1>Gluecron for VS Code</h1> | |
| 276 | <p> | |
| 277 | Chat with this repo, ship PRs, run specs, voice-to-PR, and let Claude write | |
| 278 | your commit messages — without leaving the editor. | |
| 279 | </p> | |
| 280 | <p> | |
| 281 | <a class="badge" href="${MARKETPLACE_URL}" rel="nofollow"> | |
| 282 | Marketplace listing (coming soon) | |
| 283 | </a> | |
| 284 | </p> | |
| 285 | <h2>Install</h2> | |
| 286 | ${vsixBlock} | |
| 287 | <h2>What you get</h2> | |
| 288 | <ul> | |
| 289 | <li>Sidebar <strong>repo chat</strong> grounded in the current repository</li> | |
| 290 | <li>One-click <strong>AI commit messages</strong> in the SCM title bar</li> | |
| 291 | <li><strong>Open in Gluecron</strong> deep-links for the active file / line</li> | |
| 292 | <li>Embedded <strong>pull requests</strong>, <strong>issues</strong>, and <strong>AI standups</strong></li> | |
| 293 | <li><strong>Ship spec</strong> + <strong>voice-to-PR</strong> shortcuts</li> | |
| 294 | </ul> | |
| 295 | <p class="muted">Source: <a href="/ccantynz/Gluecron.com/tree/main/editor-extensions/vscode">editor-extensions/vscode</a></p> | |
| 296 | </body> | |
| 297 | </html>`; | |
| 298 | } | |
| 299 | ||
| 300 | install.get("/install/vscode", (c) => { | |
| 301 | const vsix = findVsix(); | |
| 302 | c.header("content-type", "text/html; charset=utf-8"); | |
| 303 | c.header("cache-control", "public, max-age=300"); | |
| 304 | return c.body(vscodeLandingHtml(vsix)); | |
| 305 | }); | |
| 306 | ||
| 1ab8ace | 307 | // ─── JetBrains plugin landing ─────────────────────────────────────────────── |
| 308 | // | |
| 309 | // Same pattern as the VS Code landing above: marketplace badge + sideload | |
| 310 | // .zip link if `./gradlew buildPlugin` has been run and the artifact is | |
| 311 | // staged under `public/`. The plugin .zip is named | |
| 312 | // `gluecron-jetbrains-<version>.zip` (declared in build.gradle.kts). | |
| 313 | ||
| 314 | const JETBRAINS_MARKETPLACE_URL = | |
| 315 | "https://plugins.jetbrains.com/plugin/com.gluecron.plugin"; | |
| 316 | ||
| 317 | function findJetbrainsZip(): { name: string; size: number } | null { | |
| 318 | const candidates = [ | |
| 319 | join(process.cwd(), "public"), | |
| 320 | join(import.meta.dir, "..", "..", "public"), | |
| 321 | ]; | |
| 322 | for (const dir of candidates) { | |
| 323 | try { | |
| 324 | if (!existsSync(dir)) continue; | |
| 325 | const files = readdirSync(dir).filter( | |
| 326 | (f) => f.startsWith("gluecron-jetbrains-") && f.endsWith(".zip") | |
| 327 | ); | |
| 328 | if (files.length === 0) continue; | |
| 329 | files.sort(); | |
| 330 | const pick = files[files.length - 1]; | |
| 331 | const st = statSync(join(dir, pick)); | |
| 332 | return { name: pick, size: st.size }; | |
| 333 | } catch { | |
| 334 | // try the next candidate | |
| 335 | } | |
| 336 | } | |
| 337 | return null; | |
| 338 | } | |
| 339 | ||
| 340 | function jetbrainsLandingHtml( | |
| 341 | zip: { name: string; size: number } | null | |
| 342 | ): string { | |
| 343 | const zipBlock = zip | |
| 344 | ? ` | |
| 345 | <p> | |
| 346 | <a class="cta" href="/${encodeURIComponent(zip.name)}" download> | |
| 347 | Download ${zip.name} (${formatBytes(zip.size)}) | |
| 348 | </a> | |
| 349 | </p> | |
| 350 | <p class="muted">In your IDE: <b>Settings → Plugins → ⚙ → Install Plugin from Disk…</b></p>` | |
| 351 | : ` | |
| 352 | <p class="muted"> | |
| 353 | No plugin <code>.zip</code> uploaded yet — build one yourself: | |
| 354 | </p> | |
| 355 | <pre><code>git clone https://gluecron.com/ccantynz/Gluecron.com | |
| 356 | cd Gluecron.com/editor-extensions/jetbrains | |
| 357 | ./gradlew buildPlugin | |
| 358 | # artifact: build/distributions/gluecron-jetbrains-0.1.0.zip</code></pre>`; | |
| 359 | ||
| 360 | return `<!DOCTYPE html> | |
| 361 | <html lang="en"> | |
| 362 | <head> | |
| 363 | <meta charset="utf-8" /> | |
| 364 | <title>Gluecron for JetBrains</title> | |
| 365 | <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| 366 | <style> | |
| 367 | :root { color-scheme: dark light; } | |
| 368 | body { font: 15px/1.55 system-ui, sans-serif; max-width: 640px; margin: 5rem auto; padding: 0 1rem; } | |
| 369 | h1 { margin-top: 0; font-size: 1.6rem; } | |
| 370 | .badge { display: inline-block; padding: 4px 10px; border-radius: 4px; background: #2c2c2c; color: #fff; font-size: 13px; text-decoration: none; } | |
| 371 | .cta { display: inline-block; padding: 8px 14px; border-radius: 6px; background: #1f6feb; color: #fff; text-decoration: none; font-weight: 600; } | |
| 372 | pre { background: #0e1116; color: #e6edf3; padding: 12px; border-radius: 6px; overflow-x: auto; } | |
| 373 | code { font: 13px ui-monospace, monospace; } | |
| 374 | .muted { opacity: 0.75; } | |
| 375 | ul { padding-left: 1.2rem; } | |
| 376 | </style> | |
| 377 | </head> | |
| 378 | <body> | |
| 379 | <h1>Gluecron for JetBrains IDEs</h1> | |
| 380 | <p> | |
| 381 | AI-native git inside IntelliJ IDEA, WebStorm, GoLand, PyCharm, | |
| 382 | RustRover, and Rider — chat with the repo, draft commit messages, | |
| 383 | ship specs, voice-to-PR. | |
| 384 | </p> | |
| 385 | <p> | |
| 386 | <a class="badge" href="${JETBRAINS_MARKETPLACE_URL}" rel="nofollow"> | |
| 387 | Marketplace listing (coming soon) | |
| 388 | </a> | |
| 389 | </p> | |
| 390 | <h2>Install</h2> | |
| 391 | ${zipBlock} | |
| 392 | <h2>What you get</h2> | |
| 393 | <ul> | |
| 394 | <li>Sidebar <strong>tool window</strong> with Chat / PRs / Issues / Standups tabs</li> | |
| 395 | <li>One-click <strong>AI commit messages</strong> in the VCS commit dialog</li> | |
| 396 | <li><strong>Ship spec</strong> + <strong>Voice-to-PR</strong> from the Tools menu</li> | |
| 397 | <li>Works on every JetBrains IDE 2024.1+ (IDEA, WebStorm, GoLand, PyCharm, RustRover, Rider)</li> | |
| 398 | </ul> | |
| 399 | <p class="muted">Source: <a href="/ccantynz/Gluecron.com/tree/main/editor-extensions/jetbrains">editor-extensions/jetbrains</a></p> | |
| 400 | </body> | |
| 401 | </html>`; | |
| 402 | } | |
| 403 | ||
| 404 | install.get("/install/jetbrains", (c) => { | |
| 405 | const zip = findJetbrainsZip(); | |
| 406 | c.header("content-type", "text/html; charset=utf-8"); | |
| 407 | c.header("cache-control", "public, max-age=300"); | |
| 408 | return c.body(jetbrainsLandingHtml(zip)); | |
| 409 | }); | |
| 410 | ||
| 46d6165 | 411 | export default install; |