Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
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.

install.tsBlame411 lines · 1 contributor
46d6165Claude1/**
2 * Block L2 — one-command install.
3 *
63fe719Claude4 * 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/`.
46d6165Claude8 *
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
17import { Hono } from "hono";
63fe719Claude18import { readFileSync, existsSync, statSync, readdirSync } from "fs";
46d6165Claude19import { join } from "path";
20
21const install = new Hono();
22
c4e643cClaude23// ─── 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
46d6165Claude155// 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.
159const FALLBACK_SCRIPT = `#!/usr/bin/env bash
160echo "Gluecron install script unavailable on this host." >&2
161echo "Fetch it directly from https://gluecron.com/install" >&2
162exit 1
163`;
164
165function 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
183export const INSTALL_SCRIPT_SRC = loadScript();
184
185install.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
63fe719Claude194// ─── 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
202const MARKETPLACE_URL =
203 "https://marketplace.visualstudio.com/items?itemName=gluecron.gluecron-vscode";
204
205function 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
230function 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
236function 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
250cd Gluecron.com/editor-extensions/vscode
251npm install
252npm run compile
253npx vsce package
254code --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
300install.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
1ab8aceClaude307// ─── 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
314const JETBRAINS_MARKETPLACE_URL =
315 "https://plugins.jetbrains.com/plugin/com.gluecron.plugin";
316
317function 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
340function 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
356cd 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&nbsp;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
404install.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
46d6165Claude411export default install;