Commit80bed05unknown_key
feat: /help, onboarding polish, import flow, error tracking, launch comms
feat: /help, onboarding polish, import flow, error tracking, launch comms Five-agent parallel polish pass ahead of going live: - /help — new public quickstart route (softAuth). Covers HTTPS + SSH clone, GitHub import, webhook HMAC payload, PAT usage, gates + AI review, shortcuts, API link. Mounted in app.tsx, added to sitemap. - First-repo UX — onboarding wizard + dashboard empty state tightened around 3 CTAs: new repo, import, explore. - Import flow — progress banner during clone, better validation + error recovery. New src/lib/import-helper.ts. - Observability — lightweight zero-deps error reporter (src/lib/observability.ts). ERROR_WEBHOOK_URL + SENTRY_DSN env hooks. Fire-and-forget, never throws. Wired into app.onError. - Launch comms — docs/LAUNCH_ANNOUNCEMENT.md (Show HN, tweet thread, LinkedIn, demo shot list, press kit, changelog seed). Tests: 140 pass / 54 fail (+3 real passing vs baseline; the extra failing file is the same sandbox jsx-dev-runtime env error). No regressions. https://claude.ai/code/session_017Do52tMX1P9jPTWXhEohXH
13 files changed+1019−15680bed05e48ef6966ecae70107cc21442ee787242
13 changed files+1019−156
Modified.env.example+5−0View fileUnifiedSplit
@@ -29,3 +29,8 @@ APP_BASE_URL=http://localhost:3000
2929CRONTECH_STATUS_URL=https://crontech.ai/api/platform-status
3030GLUECRON_STATUS_URL=https://gluecron.com/api/platform-status
3131GATETEST_STATUS_URL=https://gatetest.io/api/platform-status
32# Error tracking (optional). If unset, errors are logged to stderr only.
33# ERROR_WEBHOOK_URL — POST JSON {timestamp,message,stack,context,env} on unhandled errors.
34ERROR_WEBHOOK_URL=
35# SENTRY_DSN — if set, errors POST to the Sentry envelope endpoint directly (no SDK).
36SENTRY_DSN=
Modified.gitignore+1−0View fileUnifiedSplit
@@ -6,3 +6,4 @@ dist/
66repos/
77drizzle/meta/
88.DS_Store
9.test-repos-*/
Addeddocs/LAUNCH_ANNOUNCEMENT.md+110−0View fileUnifiedSplit
@@ -0,0 +1,110 @@
1# Gluecron Launch Announcement Package
2
3Prepared for the public launch on 2026-04-21. Placeholder URL throughout: `https://gluecron.fly.dev` (owner to replace once the final Fly app name is chosen).
4
5---
6
7## 1. Show HN post
8
9**Title:** Show HN: Gluecron – a GitHub alternative with AI review on by default
10
11**Body:**
12
13Gluecron is a self-hostable code platform that tries to keep every push production-ready. It started as a weekend experiment in gluing an AI reviewer to a git server and grew into something closer to a full GitHub replacement.
14
15What it does today: git Smart HTTP hosting (clone, push, fetch), pull requests with inline review, issues, discussions, wikis, gists, projects, releases, tags, a package registry speaking the npm protocol, and static page hosting from a `gh-pages` branch. Orgs, teams, CODEOWNERS with team-based resolution, 2FA/TOTP, WebAuthn passkeys, and OIDC SSO (Okta, Azure AD, Auth0, Google Workspace) are all in the box.
16
17What makes it different:
18
19- AI code review runs on every PR and can block merges when `requireAiApproval` is set on a branch protection rule. AI security review plus a 15-pattern secret scanner run on every push.
20- Repository rulesets enforce commit, branch, tag, blocked-path, file-size, and force-push policies at push time.
21- An autopilot background ticker handles mirror sync, merge-queue processing, weekly email digests, and advisory rescans. Opt out with `AUTOPILOT_DISABLED=1`.
22- A workflow runner discovers `.gluecron/workflows/*.yml` on push and executes each step as a Bun subprocess with per-step timeouts and size-capped logs.
23- Outbound HMAC-signed webhooks on push/issue/PR/star, an inbound GateTest callback, and a commit status API for external CI.
24- Everything AI-flavoured gracefully degrades without `ANTHROPIC_API_KEY`, so it is genuinely usable without paid keys.
25
26Runs anywhere Bun runs. `fly.toml` and `Dockerfile` both ship in the repo. Backed by Neon Postgres.
27
28Stack: Bun, Hono (with server-rendered JSX), Drizzle ORM, Neon PostgreSQL, git CLI subprocesses for the Smart HTTP protocol.
29
30Live site: https://gluecron.fly.dev
31Source: https://gluecron.fly.dev/gluecron/gluecron
32
33Happy to answer questions about the gate engine, the autopilot loop, or how the AI reviewer is wired into branch protection.
34
35---
36
37## 2. Tweet thread
38
39**1/** Gluecron is live. It is a GitHub alternative with AI review, a secret scanner, and push-time policy gates on by default. Self-hostable, one Bun process, Neon Postgres behind it. https://gluecron.fly.dev
40
41**2/** Every push runs an AI security review plus a 15-pattern secret scanner. Every PR runs an AI code reviewer that can block merges when `requireAiApproval` is set on a branch protection rule. Opt out per feature, never by default.
42
43**3/** Repository rulesets cover commit/branch/tag patterns, blocked paths, file-size caps, and force-push forbiddance. Workflows live at `.gluecron/workflows/*.yml` and run as Bun subprocesses with per-step timeouts and size-capped logs.
44
45**4/** Also shipped: orgs and teams, CODEOWNERS with team resolution, 2FA, passkeys, OIDC SSO, merge queues, required checks, discussions, wikis, projects, releases, an npm-protocol package registry, and `gh-pages` static hosting.
46
47**5/** Built on Bun, Hono with server-rendered JSX, Drizzle ORM, and Neon Postgres. Deploy target is Fly.io (`fly.toml` in repo) or any Docker host. Try it, break it, file an issue: https://gluecron.fly.dev
48
49---
50
51## 3. LinkedIn post
52
53Gluecron is now publicly available at https://gluecron.fly.dev.
54
55Gluecron is a self-hostable code platform that aims to keep every push production-ready. It provides git Smart HTTP hosting, pull requests with inline review, issues, discussions, wikis, projects, releases, an npm-protocol package registry, and static page hosting, alongside the governance layer teams usually have to assemble themselves.
56
57The differentiator is that AI review, a push-time secret scanner, and repository rulesets are enabled by default rather than bolted on. An AI code reviewer runs on every pull request and can block merges when a branch protection rule requires approval. A 15-pattern secret scanner and an AI security review run on every push. Repository rulesets enforce commit, branch, tag, blocked-path, file-size, and force-push policies.
58
59Platform features include organizations and teams, CODEOWNERS with team-based resolution, two-factor authentication, WebAuthn passkeys, OIDC single sign-on for Okta, Azure AD, Auth0, and Google Workspace, merge queues, required-check matrices, and a workflow runner for `.gluecron/workflows/*.yml` files.
60
61Gluecron is built on Bun, Hono with server-rendered JSX, Drizzle ORM, and Neon PostgreSQL. It ships with a `fly.toml` for Fly.io and a `Dockerfile` for any container host. Features that depend on `ANTHROPIC_API_KEY` degrade gracefully when the key is absent.
62
63Feedback, bug reports, and pull requests are welcome.
64
65---
66
67## 4. Demo video shot list (60 seconds)
68
69- **0:00 – 0:07 Register.** Landing page, click Register, fill in username and password, submit. Arrive on the personal dashboard.
70- **0:08 – 0:14 Create repo.** Click New Repository. Name it `demo`, set visibility to public, click Create. Empty repo page renders with clone instructions.
71- **0:15 – 0:24 Push a buggy commit.** Terminal window overlay. `git clone`, edit a file to introduce a hardcoded API key plus a simple bug, `git commit`, `git push`. The push output streams in real time.
72- **0:25 – 0:34 Gates run.** Cut to the PR or commit page. The 15-pattern secret scanner flags the API key. The AI security review posts a finding. Rulesets show a pass/fail summary. Required checks list updates.
73- **0:35 – 0:44 AI review comments.** Open the pull request. AI reviewer has left inline comments on the bug. Show a comment thread. Highlight the `requireAiApproval` badge on the branch protection rule.
74- **0:45 – 0:54 Merge and deploy webhook.** Developer fixes the key and the bug, force-pushes the branch, AI review re-runs and approves. Click Merge. Cut to terminal tailing the deploy webhook receiver; a POST arrives with the HMAC signature.
75- **0:55 – 1:00 Status page goes green.** Cut to `/status`. All checks green. Fade to the Gluecron wordmark and the URL `https://gluecron.fly.dev`.
76
77---
78
79## 5. Press kit
80
81**What is Gluecron (5-bullet cheatsheet):**
82
83- Gluecron is a self-hostable GitHub alternative built on Bun, Hono, Drizzle ORM, and Neon PostgreSQL, with git Smart HTTP served from the same process.
84- AI code review, AI security review, and a 15-pattern secret scanner are enabled by default; AI review can block merges through branch protection.
85- Repository rulesets enforce commit, branch, tag, blocked-path, file-size, and force-push policies at push time, independent of AI features.
86- Ships with organizations and teams, CODEOWNERS with team resolution, 2FA/TOTP, WebAuthn passkeys, OIDC SSO, merge queues, required checks, an npm-protocol package registry, and `gh-pages` static hosting.
87- Deploys via the included `fly.toml` on Fly.io or the included `Dockerfile` on any container host; AI features degrade gracefully when `ANTHROPIC_API_KEY` is absent.
88
89**Screenshot targets:**
90
91- Landing page (`/`) — logged-out hero view.
92- Repository file tree — a representative repo with directories, a README rendered below, branch switcher visible.
93- Pull request with an inline AI review comment thread visible on a diff hunk.
94- `/status` page showing all system checks green.
95- `/admin/autopilot` tick table showing the most recent autopilot ticks with mirror sync, merge-queue, digest, and advisory-rescan rows.
96
97---
98
99## 6. Changelog seed
100
101### 2026-04-21 — Public launch
102
103- Git Smart HTTP hosting with clone, push, and fetch over subprocess-backed git, plus SSH keys, personal access tokens, and an OAuth 2.0 provider.
104- Pull requests with inline review, draft PRs, merge queues, required-check matrices, and AI code review that can block merges via `requireAiApproval` branch protection.
105- Push-time policy enforcement: repository rulesets for commit, branch, tag, blocked-path, file-size, and force-push rules, plus a 15-pattern secret scanner and AI security review on every push.
106- Workflow runner that auto-discovers `.gluecron/workflows/*.yml` on push and executes steps as Bun subprocesses with per-step timeouts and size-capped logs; commit status API for external CI.
107- Collaboration surface: issues with labels and milestones, discussions, wikis with revision history, projects/kanban, gists, reactions, mentions, notifications, and closing-keyword auto-close on PR merge.
108- Platform layer: organizations and teams with team-based CODEOWNERS resolution, 2FA/TOTP with recovery codes, WebAuthn passkeys, OIDC SSO (Okta, Azure AD, Auth0, Google Workspace), an app marketplace with scoped install tokens, an npm-protocol package registry, `gh-pages` static hosting, and protected environments.
109- Autopilot background ticker covering mirror sync, merge-queue processing, weekly email digests, and advisory rescans; outbound HMAC-signed webhooks on push/issue/PR/star and an inbound GateTest callback.
110- Observability and admin: `/healthz`, `/readyz`, `/metrics`, request-ID tracing, rate limiting, per-repo and personal audit logs, traffic analytics, org-wide insights, and a site admin panel with registration lock, site banner, and read-only mode flags.
Addedsrc/__tests__/help-page.test.ts+16−0View fileUnifiedSplit
@@ -0,0 +1,16 @@
1/**
2 * Smoke test for the public /help quickstart page. Doesn't stub the DB —
3 * the route itself doesn't touch the DB, and softAuth tolerates a missing
4 * session cookie, so this works in the sandbox.
5 */
6
7import { test, expect } from "bun:test";
8import app from "../app";
9
10test("/help returns 200 with HTML body containing Getting started", async () => {
11 const res = await app.request("/help");
12 expect(res.status).toBe(200);
13 const body = await res.text();
14 expect(body).toContain("<html");
15 expect(body).toContain("Getting started");
16});
Addedsrc/__tests__/observability.test.ts+93−0View fileUnifiedSplit
@@ -0,0 +1,93 @@
1/**
2 * Observability layer (src/lib/observability.ts).
3 *
4 * `reportError` MUST never throw, regardless of env configuration or whether
5 * the configured webhook is reachable. These tests pin that contract.
6 */
7
8import { afterEach, beforeEach, describe, expect, it } from "bun:test";
9import { reportError } from "../lib/observability";
10
11const origFetch = globalThis.fetch;
12const origWebhook = process.env.ERROR_WEBHOOK_URL;
13const origSentry = process.env.SENTRY_DSN;
14
15interface CapturedCall {
16 url: string;
17 init: RequestInit;
18}
19
20function installFetch(
21 impl: (url: string, init: RequestInit) => Promise<Response>
22): CapturedCall[] {
23 const calls: CapturedCall[] = [];
24 // @ts-expect-error — override global fetch
25 globalThis.fetch = async (
26 input: RequestInfo | URL,
27 init: RequestInit = {}
28 ): Promise<Response> => {
29 const url = String(input);
30 calls.push({ url, init });
31 return impl(url, init);
32 };
33 return calls;
34}
35
36function restore(): void {
37 globalThis.fetch = origFetch;
38 if (origWebhook === undefined) delete process.env.ERROR_WEBHOOK_URL;
39 else process.env.ERROR_WEBHOOK_URL = origWebhook;
40 if (origSentry === undefined) delete process.env.SENTRY_DSN;
41 else process.env.SENTRY_DSN = origSentry;
42}
43
44describe("lib/observability — reportError", () => {
45 beforeEach(() => {
46 delete process.env.ERROR_WEBHOOK_URL;
47 delete process.env.SENTRY_DSN;
48 });
49
50 afterEach(() => {
51 restore();
52 });
53
54 it("does not throw when no env vars are set (logs only)", () => {
55 expect(() => reportError(new Error("boom"))).not.toThrow();
56 expect(() => reportError(new Error("boom"), { path: "/x" })).not.toThrow();
57 // Non-Error inputs must also be safe.
58 expect(() => reportError("string error")).not.toThrow();
59 expect(() => reportError({ weird: true })).not.toThrow();
60 expect(() => reportError(undefined)).not.toThrow();
61 });
62
63 it("does not throw when ERROR_WEBHOOK_URL is set but fetch rejects", async () => {
64 process.env.ERROR_WEBHOOK_URL = "http://127.0.0.1:1/unreachable";
65 const calls = installFetch(async () => {
66 throw new Error("ECONNREFUSED");
67 });
68
69 expect(() =>
70 reportError(new Error("prod bug"), { requestId: "r1", path: "/p", method: "GET" })
71 ).not.toThrow();
72
73 // Give the fire-and-forget promise a tick to run and its .catch to execute.
74 await new Promise((r) => setTimeout(r, 10));
75
76 expect(calls.length).toBe(1);
77 expect(calls[0]!.url).toBe("http://127.0.0.1:1/unreachable");
78 expect(calls[0]!.init.method).toBe("POST");
79 const body = JSON.parse(String(calls[0]!.init.body));
80 expect(body.message).toBe("prod bug");
81 expect(body.context).toEqual({ requestId: "r1", path: "/p", method: "GET" });
82 expect(typeof body.timestamp).toBe("string");
83 });
84
85 it("does not throw when fetch itself throws synchronously", () => {
86 process.env.ERROR_WEBHOOK_URL = "http://example.test/hook";
87 // @ts-expect-error — override to throw synchronously
88 globalThis.fetch = () => {
89 throw new Error("synchronous boom");
90 };
91 expect(() => reportError(new Error("err"))).not.toThrow();
92 });
93});
Modifiedsrc/app.tsx+10−1View fileUnifiedSplit
@@ -3,6 +3,7 @@ import { logger } from "hono/logger";
33import { cors } from "hono/cors";
44import { compress } from "hono/compress";
55import { Layout } from "./views/layout";
6import { reportError } from "./lib/observability";
67import { requestContext } from "./middleware/request-context";
78import { rateLimit } from "./middleware/rate-limit";
89import gitRoutes from "./routes/git";
@@ -25,6 +26,7 @@ import contributorRoutes from "./routes/contributors";
2526import healthRoutes from "./routes/health-probe";
2627import healthDashboardRoutes from "./routes/health";
2728import statusRoutes from "./routes/status";
29import helpRoutes from "./routes/help";
2830import seoRoutes from "./routes/seo";
2931import { platformStatus } from "./routes/platform-status";
3032import insightRoutes from "./routes/insights";
@@ -210,6 +212,9 @@ app.route("/api/platform-status", platformStatus);
210212// Public /status — human-readable platform health page
211213app.route("/", statusRoutes);
212214
215// /help — quickstart + API cheatsheet
216app.route("/", helpRoutes);
217
213218// SEO: robots.txt + sitemap.xml
214219app.route("/", seoRoutes);
215220
@@ -298,7 +303,11 @@ app.notFound((c) => {
298303
299304// Global error handler
300305app.onError((err, c) => {
301 console.error("[error]", err);
306 reportError(err, {
307 requestId: c.get("requestId"),
308 path: c.req.path,
309 method: c.req.method,
310 });
302311 return c.html(
303312 <Layout title="Error">
304313 <div class="empty-state">
Addedsrc/lib/import-helper.ts+67−0View fileUnifiedSplit
@@ -0,0 +1,67 @@
1/**
2 * Small helpers for the GitHub import flow (/import).
3 *
4 * Kept in its own file so we can stay inside the rule that says we may
5 * only edit src/routes/import.tsx and add a helper here. No DB or git
6 * process coupling — this is pure parsing/normalization.
7 */
8
9export interface ParsedGithubUrl {
10 owner: string;
11 repo: string;
12}
13
14/**
15 * Parse a GitHub URL into { owner, repo }. Accepts:
16 * - https://github.com/foo/bar
17 * - https://github.com/foo/bar.git
18 * - http://github.com/foo/bar/
19 * - git@github.com:foo/bar.git
20 * - github.com/foo/bar
21 * - foo/bar
22 *
23 * Returns null if the URL cannot be parsed.
24 */
25export function parseGithubUrl(raw: string): ParsedGithubUrl | null {
26 const input = (raw || "").trim();
27 if (!input) return null;
28
29 // SSH form: git@github.com:owner/repo(.git)?
30 const ssh = input.match(/^git@github\.com:([^/]+)\/([^/\s]+?)(?:\.git)?\/?$/i);
31 if (ssh) return { owner: ssh[1], repo: stripDotGit(ssh[2]) };
32
33 // HTTP(S) / bare host form
34 const http = input.match(
35 /^(?:https?:\/\/)?(?:www\.)?github\.com\/([^/\s]+)\/([^/\s?#]+?)(?:\.git)?\/?(?:[?#].*)?$/i
36 );
37 if (http) return { owner: http[1], repo: stripDotGit(http[2]) };
38
39 // owner/repo shorthand
40 const short = input.match(/^([^/\s]+)\/([^/\s]+?)(?:\.git)?$/);
41 if (short) return { owner: short[1], repo: stripDotGit(short[2]) };
42
43 return null;
44}
45
46function stripDotGit(name: string): string {
47 return name.replace(/\.git$/i, "");
48}
49
50/**
51 * Repository names on gluecron follow GitHub's rough rules: letters,
52 * digits, hyphens, underscores, dots. We normalize by replacing anything
53 * else with a hyphen so an imported repo is always addressable.
54 */
55export function sanitizeRepoName(name: string): string {
56 const cleaned = name.replace(/[^A-Za-z0-9._-]/g, "-").replace(/^-+|-+$/g, "");
57 return cleaned || "imported-repo";
58}
59
60/**
61 * Build the clone URL that `git clone --bare --mirror` will use. When a
62 * token is supplied we inject it so private repos are reachable.
63 */
64export function buildCloneUrl(cloneUrl: string, token: string | null): string {
65 if (!token) return cloneUrl;
66 return cloneUrl.replace("https://github.com/", `https://${token}@github.com/`);
67}
Addedsrc/lib/observability.ts+134−0View fileUnifiedSplit
@@ -0,0 +1,134 @@
1/**
2 * Minimal, dependency-free observability layer.
3 *
4 * - `reportError` NEVER throws. Production paths must not break when a webhook
5 * is misconfigured or unreachable.
6 * - Always logs to stderr with a `[error]` prefix.
7 * - Optionally fans out to:
8 * - `ERROR_WEBHOOK_URL` — generic JSON POST (fire-and-forget)
9 * - `SENTRY_DSN` — Sentry envelope endpoint (fire-and-forget)
10 *
11 * No SDKs. Only `fetch` (built into Bun).
12 */
13
14interface SentryFrame {
15 function?: string;
16 filename?: string;
17 lineno?: number;
18 colno?: number;
19}
20
21function toErr(err: unknown): { message: string; stack?: string; type: string } {
22 if (err instanceof Error) {
23 return { message: err.message, stack: err.stack, type: err.name || "Error" };
24 }
25 try {
26 return { message: typeof err === "string" ? err : JSON.stringify(err), type: "NonError" };
27 } catch {
28 return { message: String(err), type: "NonError" };
29 }
30}
31
32function parseStack(stack?: string): SentryFrame[] {
33 if (!stack) return [];
34 const frames: SentryFrame[] = [];
35 for (const raw of stack.split("\n").slice(1)) {
36 const m = raw.trim().match(/^at\s+(?:(.+?)\s+\()?(.+?):(\d+):(\d+)\)?$/);
37 if (!m) continue;
38 frames.push({
39 function: m[1] || undefined,
40 filename: m[2],
41 lineno: Number(m[3]),
42 colno: Number(m[4]),
43 });
44 }
45 return frames.reverse();
46}
47
48function randomEventId(): string {
49 const b = new Uint8Array(16);
50 crypto.getRandomValues(b);
51 return Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
52}
53
54function parseDsn(dsn: string): { url: string; headers: Record<string, string> } | null {
55 try {
56 const u = new URL(dsn);
57 const key = u.username;
58 const projectId = u.pathname.replace(/^\/+/, "");
59 if (!key || !projectId) return null;
60 return {
61 url: `${u.protocol}//${u.host}/api/${projectId}/envelope/`,
62 headers: {
63 "Content-Type": "application/x-sentry-envelope",
64 "X-Sentry-Auth": `Sentry sentry_version=7, sentry_key=${key}, sentry_client=gluecron/1.0`,
65 },
66 };
67 } catch {
68 return null;
69 }
70}
71
72function safeLog(msg: string, e: unknown): void {
73 try {
74 console.error(msg, e);
75 } catch {
76 /* ignore */
77 }
78}
79
80function fireAndForget(
81 url: string,
82 init: RequestInit,
83 label: string
84): void {
85 try {
86 void fetch(url, init).catch((e) => safeLog(`[error] ${label} failed:`, e));
87 } catch (e) {
88 safeLog(`[error] ${label} threw:`, e);
89 }
90}
91
92export function reportError(err: unknown, context?: Record<string, unknown>): void {
93 const { message, stack, type } = toErr(err);
94 const timestamp = new Date().toISOString();
95
96 safeLog("[error]", err);
97
98 const webhookUrl = process.env.ERROR_WEBHOOK_URL;
99 if (webhookUrl) {
100 fireAndForget(
101 webhookUrl,
102 {
103 method: "POST",
104 headers: { "Content-Type": "application/json" },
105 body: JSON.stringify({ timestamp, message, stack, context, env: process.env.NODE_ENV }),
106 },
107 "observability webhook"
108 );
109 }
110
111 const dsn = process.env.SENTRY_DSN;
112 if (dsn) {
113 const parsed = parseDsn(dsn);
114 if (!parsed) return;
115 const eventId = randomEventId();
116 const event = {
117 event_id: eventId,
118 timestamp,
119 platform: "node",
120 level: "error",
121 message,
122 exception: {
123 values: [{ type, value: message, stacktrace: { frames: parseStack(stack) } }],
124 },
125 extra: context,
126 environment: process.env.NODE_ENV,
127 };
128 const body =
129 `${JSON.stringify({ event_id: eventId, sent_at: timestamp })}\n` +
130 `${JSON.stringify({ type: "event" })}\n` +
131 `${JSON.stringify(event)}\n`;
132 fireAndForget(parsed.url, { method: "POST", headers: parsed.headers, body }, "sentry report");
133 }
134}
Modifiedsrc/routes/dashboard.tsx+44−6View fileUnifiedSplit
@@ -192,12 +192,50 @@ dashboard.get("/dashboard", requireAuth, async (c) => {
192192 {/* ─── Repo Grid ─── */}
193193 <h2 style="font-size: 18px; margin-bottom: 16px">Your Repositories</h2>
194194 {repos.length === 0 ? (
195 <div class="empty-state">
196 <h2>No repositories yet</h2>
197 <p>Create your first repository to see the command center in action.</p>
198 <a href="/new" class="btn btn-primary" style="margin-top: 16px">
199 Create repository
200 </a>
195 <div class="empty-state" style="text-align:left;padding:24px">
196 <div style="text-align:center;margin-bottom:20px">
197 <h2 style="margin-bottom:6px">Get started</h2>
198 <p style="color:var(--text-muted);font-size:14px;margin:0">
199 Ship safer code with AI-native hosting, automated CI, and push-time gates. Pick a path:
200 </p>
201 </div>
202 <div class="panel" style="margin-bottom:20px;text-align:left">
203 <div class="panel-item" style="justify-content:space-between;padding:16px;gap:12px">
204 <div style="flex:1">
205 <div style="font-size:15px;font-weight:600">Create a new repository</div>
206 <div style="font-size:13px;color:var(--text-muted);margin-top:2px">
207 Start from scratch with green-ecosystem defaults.
208 </div>
209 </div>
210 <a href="/new" class="btn btn-primary">Create repo</a>
211 </div>
212 <div class="panel-item" style="justify-content:space-between;padding:16px;gap:12px">
213 <div style="flex:1">
214 <div style="font-size:15px;font-weight:600">Import from GitHub</div>
215 <div style="font-size:13px;color:var(--text-muted);margin-top:2px">
216 Mirror an existing repo — history, branches, tags.
217 </div>
218 </div>
219 <a href="/import" class="btn">Import repo</a>
220 </div>
221 <div class="panel-item" style="justify-content:space-between;padding:16px;gap:12px">
222 <div style="flex:1">
223 <div style="font-size:15px;font-weight:600">Browse public repos</div>
224 <div style="font-size:13px;color:var(--text-muted);margin-top:2px">
225 See what others are building, fork what you like.
226 </div>
227 </div>
228 <a href="/explore" class="btn">Browse</a>
229 </div>
230 </div>
231 <div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:14px 16px">
232 <div style="font-size:12px;color:var(--text-muted);margin-bottom:6px;text-transform:uppercase;letter-spacing:0.5px">
233 Push an existing project (preview)
234 </div>
235 <pre style="margin:0;font-size:12px;overflow-x:auto;color:var(--text-muted)"><code># Once you create a repo, you'll see your real clone URL here.
236git remote add gluecron http://localhost:3000/{user.username}/<your-repo>.git
237git push -u gluecron main</code></pre>
238 </div>
201239 </div>
202240 ) : (
203241 <div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: 16px; margin-bottom: 32px">
Addedsrc/routes/help.tsx+305−0View fileUnifiedSplit
@@ -0,0 +1,305 @@
1/**
2 * /help — public quickstart + API cheatsheet for owners migrating their
3 * products onto gluecron. Covers the first five minutes (register, clone,
4 * push), integration surfaces (SSH, import, webhooks, tokens), and the
5 * AI-native extras (gates + AI review). Linked from the landing page nav.
6 *
7 * Uses softAuth so the nav bar renders with the signed-in user's session
8 * cookie when present; the page itself is reachable without auth.
9 */
10
11import { Hono } from "hono";
12import { Layout } from "../views/layout";
13import { softAuth } from "../middleware/auth";
14import type { AuthEnv } from "../middleware/auth";
15
16const help = new Hono<AuthEnv>();
17help.use("*", softAuth);
18
19help.get("/help", (c) => {
20 const user = c.get("user");
21
22 return c.html(
23 <Layout title="Help — gluecron" user={user}>
24 <div style="max-width: 860px; margin: 0 auto; padding: 24px 16px">
25 <h1 style="margin: 0 0 8px; font-size: 28px">Help & quickstart</h1>
26 <p style="color: var(--text-muted); margin-bottom: 24px">
27 Everything an owner migrating a product onto gluecron needs in one
28 page. If something's unclear, open an issue — link at the bottom.
29 </p>
30
31 <nav
32 class="panel"
33 style="margin-bottom: 32px; padding: 12px 16px; font-size: 13px"
34 >
35 <strong
36 style="display: block; margin-bottom: 6px; font-size: 12px; text-transform: uppercase; color: var(--text-muted)"
37 >
38 On this page
39 </strong>
40 <a href="#getting-started">Getting started</a> ·{" "}
41 <a href="#git-https">Git over HTTPS</a> ·{" "}
42 <a href="#git-ssh">Git over SSH</a> ·{" "}
43 <a href="#import">Importing from GitHub</a> ·{" "}
44 <a href="#webhooks">Webhooks</a> ·{" "}
45 <a href="#tokens">Personal access tokens</a> ·{" "}
46 <a href="#gates">Gates & AI review</a> ·{" "}
47 <a href="#shortcuts">Keyboard shortcuts</a> ·{" "}
48 <a href="#api">API</a>
49 </nav>
50
51 <section id="getting-started" style="margin-bottom: 32px">
52 <h2 style="margin-bottom: 12px; font-size: 20px">Getting started</h2>
53 <div class="panel">
54 <div class="panel-item">
55 <div>
56 <strong>1. Register an account.</strong>{" "}
57 Head to <a href="/register">/register</a>, pick a username, and
58 set a password. Usernames are your public handle and appear in
59 every repo URL.
60 </div>
61 </div>
62 <div class="panel-item">
63 <div>
64 <strong>2. Verify your email.</strong>{" "}
65 We send a one-time link the first time you sign in. Verified
66 addresses can receive issue, PR, and gate-run notifications.
67 </div>
68 </div>
69 <div class="panel-item">
70 <div>
71 <strong>3. Create your first repo.</strong>{" "}
72 From the dashboard hit <strong>New repository</strong>, or
73 visit <a href="/new">/new</a>. Pick public or private, add a
74 README, and you're ready to clone.
75 </div>
76 </div>
77 </div>
78 </section>
79
80 <section id="git-https" style="margin-bottom: 32px">
81 <h2 style="margin-bottom: 12px; font-size: 20px">Git over HTTPS</h2>
82 <p style="color: var(--text-muted); margin-bottom: 12px">
83 HTTPS works out of the box. Authenticate with your account
84 password or, better, a personal access token.
85 </p>
86 <div class="panel">
87 <div class="panel-item">
88 <div style="width: 100%">
89 <strong>Clone</strong>
90 <pre
91 style="margin: 6px 0 0; padding: 10px; background: var(--bg-muted, #0d1117); border-radius: 4px; font-size: 12px; overflow-x: auto"
92 >
93{`git clone https://<your-host>/<owner>/<repo>.git`}
94 </pre>
95 </div>
96 </div>
97 <div class="panel-item">
98 <div style="width: 100%">
99 <strong>Push</strong>
100 <pre
101 style="margin: 6px 0 0; padding: 10px; background: var(--bg-muted, #0d1117); border-radius: 4px; font-size: 12px; overflow-x: auto"
102 >
103{`git push origin main`}
104 </pre>
105 </div>
106 </div>
107 <div class="panel-item">
108 <div style="width: 100%">
109 <strong>Pull</strong>
110 <pre
111 style="margin: 6px 0 0; padding: 10px; background: var(--bg-muted, #0d1117); border-radius: 4px; font-size: 12px; overflow-x: auto"
112 >
113{`git pull origin main`}
114 </pre>
115 </div>
116 </div>
117 </div>
118 </section>
119
120 <section id="git-ssh" style="margin-bottom: 32px">
121 <h2 style="margin-bottom: 12px; font-size: 20px">Git over SSH</h2>
122 <p style="color: var(--text-muted); margin-bottom: 12px">
123 SSH avoids typing credentials and is recommended for day-to-day
124 work.
125 </p>
126 <div class="panel">
127 <div class="panel-item">
128 <div>
129 <strong>1. Add your key.</strong>{" "}
130 Copy your public key (usually{" "}
131 <code>~/.ssh/id_ed25519.pub</code>) and paste it into{" "}
132 <a href="/settings/keys">/settings/keys</a>. Keys take effect
133 immediately.
134 </div>
135 </div>
136 <div class="panel-item">
137 <div style="width: 100%">
138 <strong>2. Clone using the SSH URL.</strong>
139 <pre
140 style="margin: 6px 0 0; padding: 10px; background: var(--bg-muted, #0d1117); border-radius: 4px; font-size: 12px; overflow-x: auto"
141 >
142{`git clone git@<your-host>:<owner>/<repo>.git`}
143 </pre>
144 </div>
145 </div>
146 <div class="panel-item">
147 <div>
148 <strong>3. Rotate or revoke</strong> any key from the same
149 settings page — useful when a laptop walks off.
150 </div>
151 </div>
152 </div>
153 </section>
154
155 <section id="import" style="margin-bottom: 32px">
156 <h2 style="margin-bottom: 12px; font-size: 20px">
157 Importing from GitHub
158 </h2>
159 <div class="panel">
160 <div class="panel-item">
161 <div>
162 Visit <a href="/import">/import</a>, paste the source URL, and
163 gluecron will mirror the repository — full history, branches,
164 and tags. The mirror is a one-time copy; subsequent pushes
165 land on gluecron, not the source. Private sources need a PAT
166 on the source side.
167 </div>
168 </div>
169 </div>
170 </section>
171
172 <section id="webhooks" style="margin-bottom: 32px">
173 <h2 style="margin-bottom: 12px; font-size: 20px">Webhooks</h2>
174 <p style="color: var(--text-muted); margin-bottom: 12px">
175 Per-repo webhooks live at{" "}
176 <code>/:owner/:repo/settings/webhooks</code>. Register a URL, pick
177 events (push, issue, pr, star), and set a secret.
178 </p>
179 <div class="panel">
180 <div class="panel-item">
181 <div>
182 <strong>HMAC signature.</strong>{" "}
183 Every delivery includes{" "}
184 <code>X-Gluecron-Signature: sha256=<hex></code>.{" "}
185 Compute HMAC-SHA256 over the raw request body using your
186 secret and compare in constant time.
187 </div>
188 </div>
189 <div class="panel-item">
190 <div style="width: 100%">
191 <strong>Payload shape.</strong>
192 <pre
193 style="margin: 6px 0 0; padding: 10px; background: var(--bg-muted, #0d1117); border-radius: 4px; font-size: 12px; overflow-x: auto"
194 >
195{`{
196 "event": "push",
197 "repo": { "owner": "acme", "name": "api" },
198 "ref": "refs/heads/main",
199 "before": "<sha>",
200 "after": "<sha>",
201 "commits": [ /* ... */ ],
202 "sender": { "username": "kit" }
203}`}
204 </pre>
205 </div>
206 </div>
207 <div class="panel-item">
208 <div>
209 Deliveries are retried with exponential backoff; inspect the
210 last N attempts from the webhook's settings page.
211 </div>
212 </div>
213 </div>
214 </section>
215
216 <section id="tokens" style="margin-bottom: 32px">
217 <h2 style="margin-bottom: 12px; font-size: 20px">
218 Personal access tokens
219 </h2>
220 <p style="color: var(--text-muted); margin-bottom: 12px">
221 Tokens authenticate CLI clients, CI jobs, and scripts. Create
222 them at <a href="/settings/tokens">/settings/tokens</a>; the value
223 is shown once, so copy it immediately. Tokens start with{" "}
224 <code>glc_</code>.
225 </p>
226 <div class="panel">
227 <div class="panel-item">
228 <div style="width: 100%">
229 <strong>Example: list your repos via the API.</strong>
230 <pre
231 style="margin: 6px 0 0; padding: 10px; background: var(--bg-muted, #0d1117); border-radius: 4px; font-size: 12px; overflow-x: auto"
232 >
233{`curl -H "Authorization: Bearer glc_your_token_here" \\
234 https://<your-host>/api/v2/repos`}
235 </pre>
236 </div>
237 </div>
238 <div class="panel-item">
239 <div>
240 Tokens can also authenticate <code>git</code> over HTTPS — use
241 the token as the password in place of your account password.
242 </div>
243 </div>
244 </div>
245 </section>
246
247 <section id="gates" style="margin-bottom: 32px">
248 <h2 style="margin-bottom: 12px; font-size: 20px">
249 Gates & AI review
250 </h2>
251 <div class="panel">
252 <div class="panel-item">
253 <div>
254 Every push to the default branch (usually <code>main</code>)
255 triggers a gate run: GateTest scans the diff for secrets,
256 dependency advisories, and policy violations, while the AI
257 reviewer reads the patch and comments on any PRs that touch
258 the same files. Failing gates block the push by default;
259 results appear on the commit page and in the repo's{" "}
260 <em>Gate runs</em> tab. Configure gate policy per-repo in
261 <strong> Settings → Gates</strong>.
262 </div>
263 </div>
264 </div>
265 </section>
266
267 <section id="shortcuts" style="margin-bottom: 32px">
268 <h2 style="margin-bottom: 12px; font-size: 20px">
269 Keyboard shortcuts
270 </h2>
271 <div class="panel">
272 <div class="panel-item">
273 <div>
274 gluecron ships a full keyboard-first mode — see{" "}
275 <a href="/shortcuts">/shortcuts</a> for the complete cheat
276 sheet. Press <code>?</code> on any page to pop the overlay.
277 </div>
278 </div>
279 </div>
280 </section>
281
282 <section id="api" style="margin-bottom: 32px">
283 <h2 style="margin-bottom: 12px; font-size: 20px">API</h2>
284 <div class="panel">
285 <div class="panel-item">
286 <div>
287 Full REST + GraphQL reference lives at{" "}
288 <a href="/api/docs">/api/docs</a>. The GraphQL explorer is at{" "}
289 <a href="/api/graphql">/api/graphql</a>.
290 </div>
291 </div>
292 </div>
293 </section>
294
295 <p
296 style="color: var(--text-muted); font-size: 13px; margin-top: 40px; padding-top: 16px; border-top: 1px solid var(--border)"
297 >
298 Something missing? Open an issue on gluecron's source repo.
299 </p>
300 </div>
301 </Layout>
302 );
303});
304
305export default help;
Modifiedsrc/routes/import.tsx+119−34View fileUnifiedSplit
@@ -7,9 +7,9 @@
77 */
88
99import { Hono } from "hono";
10import { eq } from "drizzle-orm";
10import { and, eq } from "drizzle-orm";
1111import { db } from "../db";
12import { repositories, users } from "../db/schema";
12import { repositories } from "../db/schema";
1313import { Layout } from "../views/layout";
1414import { softAuth, requireAuth } from "../middleware/auth";
1515import { requireAdmin } from "../middleware/admin";
@@ -17,6 +17,11 @@ import type { AuthEnv } from "../middleware/auth";
1717import { config } from "../lib/config";
1818import { mkdir } from "fs/promises";
1919import { join } from "path";
20import {
21 parseGithubUrl,
22 sanitizeRepoName,
23 buildCloneUrl,
24} from "../lib/import-helper";
2025
2126const importRoutes = new Hono<AuthEnv>();
2227
@@ -42,6 +47,34 @@ importRoutes.get("/import", requireAuth, requireAdmin, async (c) => {
4247 const error = c.req.query("error");
4348 const imported = c.req.query("imported");
4449
50 // Inline progress banner: the clone subprocess can take 30+s for big
51 // repos, so give the user visible feedback while the POST is in flight.
52 // Pure client-side — no extra routes, no websockets, no polling.
53 const progressScript = `
54 (function () {
55 var forms = document.querySelectorAll('form[data-import-form]');
56 var banner = document.getElementById('import-progress');
57 if (!banner) return;
58 forms.forEach(function (form) {
59 form.addEventListener('submit', function () {
60 // Validate non-empty required fields before showing progress.
61 var req = form.querySelectorAll('[required]');
62 for (var i = 0; i < req.length; i++) {
63 if (!req[i].value || !req[i].value.trim()) return;
64 }
65 banner.style.display = 'block';
66 var btns = form.querySelectorAll('button[type="submit"]');
67 btns.forEach(function (b) {
68 b.disabled = true;
69 b.textContent = 'Importing…';
70 });
71 // Scroll banner into view so user sees progress above the fold.
72 try { banner.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch (_) {}
73 });
74 });
75 })();
76 `;
77
4578 return c.html(
4679 <Layout title="Import from GitHub" user={user}>
4780 <div style="max-width: 700px">
@@ -58,18 +91,36 @@ importRoutes.get("/import", requireAuth, requireAdmin, async (c) => {
5891 Successfully imported {decodeURIComponent(imported)} repositories.
5992 </div>
6093 )}
94 <div style="margin-top: 10px">
95 <a href={`/${user.username}`} class="btn btn-primary" style="margin-right: 8px">
96 View my repositories
97 </a>
98 <a href="/explore" class="btn">Explore</a>
99 </div>
61100 </div>
62101 )}
63102 {error && (
64103 <div class="auth-error">{decodeURIComponent(error)}</div>
65104 )}
66105
106 <div
107 id="import-progress"
108 role="status"
109 aria-live="polite"
110 style="display: none; background: var(--bg-secondary); border: 1px solid var(--border); border-left: 3px solid #f0b429; border-radius: var(--radius); padding: 14px 16px; margin-bottom: 20px; font-size: 14px"
111 >
112 <strong>Import in progress…</strong>
113 <div style="color: var(--text-muted); margin-top: 4px">
114 Cloning from GitHub. Large repositories can take 30+ seconds — don't close this tab.
115 </div>
116 </div>
117
67118 <div style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; margin-bottom: 24px">
68119 <h3 style="margin-bottom: 12px">Option 1: Import by username</h3>
69120 <p style="font-size: 13px; color: var(--text-muted); margin-bottom: 12px">
70121 Import all public repositories from a GitHub user or organization.
71122 </p>
72 <form method="POST" action="/import/github/user">
123 <form method="POST" action="/import/github/user" data-import-form>
73124 <div style="display: flex; gap: 8px">
74125 <input
75126 type="text"
@@ -88,9 +139,9 @@ importRoutes.get("/import", requireAuth, requireAdmin, async (c) => {
88139 <div style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; margin-bottom: 24px">
89140 <h3 style="margin-bottom: 12px">Option 2: Import single repo</h3>
90141 <p style="font-size: 13px; color: var(--text-muted); margin-bottom: 12px">
91 Import a specific repository by URL.
142 Import a specific repository by URL (https, ssh, or owner/repo).
92143 </p>
93 <form method="POST" action="/import/github/repo">
144 <form method="POST" action="/import/github/repo" data-import-form>
94145 <div style="display: flex; gap: 8px">
95146 <input
96147 type="text"
@@ -112,7 +163,7 @@ importRoutes.get("/import", requireAuth, requireAdmin, async (c) => {
112163 Use a GitHub personal access token to import private repositories too.
113164 Generate one at github.com → Settings → Developer settings → Personal access tokens.
114165 </p>
115 <form method="POST" action="/import/github/user">
166 <form method="POST" action="/import/github/user" data-import-form>
116167 <div class="form-group">
117168 <input
118169 type="text"
@@ -126,6 +177,7 @@ importRoutes.get("/import", requireAuth, requireAdmin, async (c) => {
126177 <input
127178 type="password"
128179 name="github_token"
180 required
129181 placeholder="ghp_xxxxxxxxxxxx (GitHub personal access token)"
130182 style="padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px; font-family: var(--font-mono); width: 100%"
131183 />
@@ -136,6 +188,7 @@ importRoutes.get("/import", requireAuth, requireAdmin, async (c) => {
136188 </form>
137189 </div>
138190 </div>
191 <script dangerouslySetInnerHTML={{ __html: progressScript }} />
139192 </Layout>
140193 );
141194});
@@ -190,18 +243,26 @@ importRoutes.post("/import/github/user", requireAuth, requireAdmin, async (c) =>
190243 // Import each repo
191244 let imported = 0;
192245 let skipped = 0;
246 let failed = 0;
193247
194248 for (const ghRepo of repos) {
195 // Check if already exists
249 const targetName = sanitizeRepoName(ghRepo.name);
250
251 // Check uniqueness in THIS user's namespace (owner+name is the
252 // real unique key — the previous check ignored ownerId and
253 // could skip repos other users happened to share a name with).
196254 const [existing] = await db
197255 .select()
198256 .from(repositories)
199257 .where(
200 eq(repositories.name, ghRepo.name)
258 and(
259 eq(repositories.ownerId, user.id),
260 eq(repositories.name, targetName)
261 )
201262 )
202263 .limit(1);
203264
204 if (existing && existing.ownerId === user.id) {
265 if (existing) {
205266 skipped++;
206267 continue;
207268 }
@@ -210,13 +271,15 @@ importRoutes.post("/import/github/user", requireAuth, requireAdmin, async (c) =>
210271 await importSingleRepo(user, ghRepo, githubToken);
211272 imported++;
212273 } catch (err) {
274 failed++;
213275 console.error(`[import] failed to import ${ghRepo.full_name}:`, err);
214276 }
215277 }
216278
217 return c.redirect(
218 `/import?success=Import+complete&imported=${imported}+imported%2C+${skipped}+skipped+(already+exist)`
219 );
279 const summary =
280 `${imported}+imported%2C+${skipped}+skipped` +
281 (failed > 0 ? `%2C+${failed}+failed` : "");
282 return c.redirect(`/import?success=Import+complete&imported=${summary}`);
220283 } catch (err) {
221284 console.error("[import] error:", err);
222285 return c.redirect(
@@ -236,15 +299,37 @@ importRoutes.post("/import/github/repo", requireAuth, requireAdmin, async (c) =>
236299 return c.redirect("/import?error=Repository+URL+is+required");
237300 }
238301
239 // Parse GitHub URL
240 const match = repoUrl.match(
241 /github\.com\/([^/]+)\/([^/.]+)/
242 );
243 if (!match) {
244 return c.redirect("/import?error=Invalid+GitHub+URL");
302 const parsed = parseGithubUrl(repoUrl);
303 if (!parsed) {
304 return c.redirect(
305 "/import?error=" +
306 encodeURIComponent(
307 "Invalid GitHub URL. Use https://github.com/owner/repo or owner/repo."
308 )
309 );
245310 }
246311
247 const [, ghOwner, ghRepo] = match;
312 const { owner: ghOwner, repo: ghRepo } = parsed;
313
314 // Guard against double-import before we spin up a clone subprocess.
315 const targetName = sanitizeRepoName(ghRepo);
316 const [existing] = await db
317 .select()
318 .from(repositories)
319 .where(
320 and(
321 eq(repositories.ownerId, user.id),
322 eq(repositories.name, targetName)
323 )
324 )
325 .limit(1);
326 if (existing) {
327 return c.redirect(
328 `/import?error=${encodeURIComponent(
329 `You already have a repository named "${targetName}". Delete it first, or rename on GitHub.`
330 )}`
331 );
332 }
248333
249334 try {
250335 // Fetch repo info
@@ -259,16 +344,18 @@ importRoutes.post("/import/github/repo", requireAuth, requireAdmin, async (c) =>
259344 );
260345
261346 if (!res.ok) {
262 return c.redirect("/import?error=Repository+not+found+on+GitHub");
347 return c.redirect(
348 `/import?error=${encodeURIComponent(
349 `Repository not found on GitHub (${res.status}). Check the URL and that it's public.`
350 )}`
351 );
263352 }
264353
265354 const ghRepoData: GitHubRepo = await res.json();
266355
267356 await importSingleRepo(user, ghRepoData, null);
268357
269 return c.redirect(
270 `/${user.username}/${ghRepoData.name}`
271 );
358 return c.redirect(`/${user.username}/${sanitizeRepoName(ghRepoData.name)}`);
272359 } catch (err) {
273360 console.error("[import] error:", err);
274361 return c.redirect(
@@ -284,24 +371,18 @@ async function importSingleRepo(
284371 ghRepo: GitHubRepo,
285372 token: string | null
286373): Promise<void> {
374 const safeName = sanitizeRepoName(ghRepo.name);
287375 const destPath = join(
288376 config.gitReposPath,
289377 user.username,
290 `${ghRepo.name}.git`
378 `${safeName}.git`
291379 );
292380
293381 // Ensure parent directory exists
294382 await mkdir(join(config.gitReposPath, user.username), { recursive: true });
295383
296384 // Clone bare from GitHub (with token if provided for private repos)
297 let cloneUrl = ghRepo.clone_url;
298 if (token) {
299 // Inject token into URL for private repo access
300 cloneUrl = cloneUrl.replace(
301 "https://github.com/",
302 `https://${token}@github.com/`
303 );
304 }
385 const cloneUrl = buildCloneUrl(ghRepo.clone_url, token);
305386
306387 console.log(`[import] cloning ${ghRepo.full_name} -> ${destPath}`);
307388
@@ -317,12 +398,16 @@ async function importSingleRepo(
317398 const exitCode = await proc.exited;
318399
319400 if (exitCode !== 0) {
320 throw new Error(`git clone failed: ${stderr}`);
401 // Never echo the token back in an error message.
402 const sanitized = token
403 ? stderr.replaceAll(token, "***")
404 : stderr;
405 throw new Error(`git clone failed: ${sanitized.slice(0, 400)}`);
321406 }
322407
323408 // Insert into database
324409 await db.insert(repositories).values({
325 name: ghRepo.name,
410 name: safeName,
326411 ownerId: user.id,
327412 description: ghRepo.description,
328413 isPrivate: ghRepo.private,
Modifiedsrc/routes/onboarding.tsx+114−115View fileUnifiedSplit
@@ -1,5 +1,8 @@
11/**
22 * Onboarding flow — guided setup for new users.
3 *
4 * Goal: get a fresh user from 0 to first repo in <60 seconds.
5 * Headline + 1-line value prop + 3 concrete next-step CTAs + skip-to-dashboard.
36 */
47
58import { Hono } from "hono";
@@ -52,139 +55,135 @@ onboardingRoutes.get("/getting-started", softAuth, requireAuth, async (c) => {
5255 hasTokens = (tokens?.count ?? 0) > 0;
5356 } catch { /* DB may not be ready */ }
5457
55 const steps = [
56 { label: "Create account", completed: true, active: false },
57 { label: "Create a repository", completed: repoCount > 0, active: repoCount === 0 },
58 { label: "Push your code", completed: false, active: repoCount > 0 },
59 { label: "Set up SSH key", completed: hasKeys, active: !hasKeys && repoCount > 0 },
60 { label: "Create API token", completed: hasTokens, active: !hasTokens && hasKeys },
61 ];
62
63 const activeStep = steps.findIndex((s) => s.active);
58 const firstRun = repoCount === 0;
6459
6560 return c.html(
6661 <Layout title="Getting Started" user={user}>
67 <Container maxWidth={700}>
68 <WelcomeHero title="Welcome to gluecron" subtitle="Let's get you set up in a few steps." />
69
70 <div style="display:flex;justify-content:center;margin-bottom:40px">
71 <StepIndicator steps={steps} />
72 </div>
73
74 {/* Step 1: Create repository */}
75 {activeStep <= 1 && repoCount === 0 && (
76 <StepCard
77 number={1}
78 title="Create your first repository"
79 description="A repository contains all your project files, including the revision history."
80 active
81 >
82 <Spacer size={12} />
83 <LinkButton href="/new" variant="primary">Create repository</LinkButton>
84 </StepCard>
62 <Container maxWidth={760}>
63 {/* ─── Welcome headline + 1-line value prop ─── */}
64 <WelcomeHero
65 title={firstRun ? `Welcome, ${user.username}` : "Finish setting up"}
66 subtitle="Ship safer code with AI-native hosting, automated CI, and push-time gates."
67 />
68
69 {/* ─── Three concrete next-step CTAs — the 60-second path ─── */}
70 {firstRun && (
71 <div class="panel" style="margin-bottom:20px">
72 <div class="panel-item" style="flex-direction:column;align-items:stretch;gap:4px;padding:16px">
73 <div style="display:flex;justify-content:space-between;align-items:center;gap:12px">
74 <div style="flex:1">
75 <div style="font-size:15px;font-weight:600">Create a new repository</div>
76 <div style="font-size:13px;color:var(--text-muted);margin-top:2px">
77 Start from scratch. Green-ecosystem defaults, branch protection, labels, CODEOWNERS — all wired on day one.
78 </div>
79 </div>
80 <a href="/new" class="btn btn-primary">Create repo</a>
81 </div>
82 </div>
83 <div class="panel-item" style="flex-direction:column;align-items:stretch;gap:4px;padding:16px">
84 <div style="display:flex;justify-content:space-between;align-items:center;gap:12px">
85 <div style="flex:1">
86 <div style="font-size:15px;font-weight:600">Import from GitHub</div>
87 <div style="font-size:13px;color:var(--text-muted);margin-top:2px">
88 Mirror an existing repo by URL. History, branches, and tags come across on the first sync.
89 </div>
90 </div>
91 <a href="/import" class="btn">Import repo</a>
92 </div>
93 </div>
94 <div class="panel-item" style="flex-direction:column;align-items:stretch;gap:4px;padding:16px">
95 <div style="display:flex;justify-content:space-between;align-items:center;gap:12px">
96 <div style="flex:1">
97 <div style="font-size:15px;font-weight:600">Browse public repos</div>
98 <div style="font-size:13px;color:var(--text-muted);margin-top:2px">
99 See what others are building. Fork or star without leaving the platform.
100 </div>
101 </div>
102 <a href="/explore" class="btn">Browse</a>
103 </div>
104 </div>
105 </div>
85106 )}
86107
87 {/* Step 2: Push code */}
88 {repoCount > 0 && (
89 <StepCard
90 number={2}
91 title="Push your code"
92 description="Connect your local repository and push your first commit."
93 >
94 <Spacer size={12} />
95 <CopyBlock text={`git remote add gluecron http://localhost:3000/${user.username}/your-repo.git\ngit push -u gluecron main`} label="Commands" />
96 </StepCard>
108 {/* ─── Existing users: show remaining setup as a compact checklist ─── */}
109 {!firstRun && (
110 <div class="panel" style="margin-bottom:20px">
111 <div class="panel-item" style="justify-content:space-between;padding:14px 16px">
112 <div>
113 <div style="font-size:14px;font-weight:600">
114 {"✓"} You have {repoCount} repositor{repoCount === 1 ? "y" : "ies"}
115 </div>
116 <div style="font-size:12px;color:var(--text-muted);margin-top:2px">
117 Push code, open issues, review PRs.
118 </div>
119 </div>
120 <a href="/dashboard" class="btn btn-sm">Open dashboard</a>
121 </div>
122 <div class="panel-item" style="justify-content:space-between;padding:14px 16px">
123 <div>
124 <div style="font-size:14px;font-weight:600">
125 {hasKeys ? "✓ SSH key added" : "Add an SSH key"}
126 </div>
127 <div style="font-size:12px;color:var(--text-muted);margin-top:2px">
128 {hasKeys ? "Push without passwords." : "Push without entering a password every time."}
129 </div>
130 </div>
131 {!hasKeys && <a href="/settings/keys" class="btn btn-sm">Add key</a>}
132 </div>
133 <div class="panel-item" style="justify-content:space-between;padding:14px 16px">
134 <div>
135 <div style="font-size:14px;font-weight:600">
136 {hasTokens ? "✓ API token ready" : "Create an API token"}
137 </div>
138 <div style="font-size:12px;color:var(--text-muted);margin-top:2px">
139 {hasTokens ? "Use it for CI, CLI, and automation." : "Authenticate scripts, CI, and the CLI."}
140 </div>
141 </div>
142 {!hasTokens && <a href="/settings/tokens" class="btn btn-sm">Create token</a>}
143 </div>
144 </div>
97145 )}
98146
99 {/* Step 3: SSH key */}
100 <StepCard
101 number={3}
102 title={hasKeys ? "SSH key added \u2713" : "Add an SSH key"}
103 description={hasKeys ? "Your SSH key is configured." : "SSH keys let you push code securely without entering your password."}
104 completed={hasKeys}
105 >
106 {!hasKeys && (
107 <>
108 <Spacer size={12} />
109 <CopyBlock text={`ssh-keygen -t ed25519 -C "your@email.com"\ncat ~/.ssh/id_ed25519.pub`} label="Generate & copy key" />
110 <Spacer size={12} />
111 <LinkButton href="/settings/keys" variant="primary" size="sm">Add SSH key</LinkButton>
112 </>
113 )}
114 </StepCard>
115
116 {/* Step 4: API token */}
117 <StepCard
118 number={4}
119 title={hasTokens ? "API token created \u2713" : "Create an API token"}
120 description={hasTokens ? "You have an API token configured." : "API tokens let you automate workflows and integrate with CI/CD."}
121 completed={hasTokens}
122 >
123 {!hasTokens && (
124 <>
125 <Spacer size={12} />
126 <Text size={14} muted>
127 Use tokens to authenticate with the gluecron API for scripting and automation.
128 </Text>
129 <Spacer size={12} />
130 <LinkButton href="/settings/tokens" variant="primary" size="sm">Create token</LinkButton>
131 </>
132 )}
133 </StepCard>
147 {/* ─── Push snippet (only once the user has at least one repo) ─── */}
148 {!firstRun && (
149 <Card style="padding:16px;margin-bottom:20px">
150 <h3 style="font-size:14px;margin:0 0 8px 0">Push an existing project</h3>
151 <CopyBlock
152 text={`git remote add gluecron http://localhost:3000/${user.username}/your-repo.git\ngit push -u gluecron main`}
153 label="Commands"
154 />
155 </Card>
156 )}
134157
135 {/* All done */}
158 {/* ─── All done celebration ─── */}
136159 {repoCount > 0 && hasKeys && hasTokens && (
137 <Card style="text-align:center;padding:40px 0;border-color:var(--green);margin-top:24px;background:rgba(63,185,80,0.05)">
138 <div style="font-size:48px;margin-bottom:12px">🎉</div>
139 <h2>You're all set!</h2>
140 <Text size={14} muted style="display:block;margin-top:8px">You've completed the setup. Start building something great.</Text>
141 <Flex gap={12} justify="center" style="margin-top:20px">
142 <LinkButton href="/" variant="primary">Go to dashboard</LinkButton>
143 <LinkButton href="/api/docs">Explore the API</LinkButton>
160 <Card style="text-align:center;padding:32px 0;border-color:var(--green);margin-bottom:20px;background:rgba(63,185,80,0.05)">
161 <div style="font-size:40px;margin-bottom:8px">🎉</div>
162 <h2 style="margin:0">You're all set.</h2>
163 <Text size={13} muted style="display:block;margin-top:6px">
164 Setup complete. Start building.
165 </Text>
166 <Flex gap={12} justify="center" style="margin-top:16px">
167 <LinkButton href="/dashboard" variant="primary">Open dashboard</LinkButton>
144168 <LinkButton href="/explore">Discover repos</LinkButton>
145169 </Flex>
146170 </Card>
147171 )}
148172
149 <div style="text-align:center;padding:32px 0">
150 <Text size={13} muted>
151 Need help? Check the <a href="/api/docs">API documentation</a> or press <Kbd>?</Kbd> for keyboard shortcuts.
152 </Text>
173 {/* ─── Skip-to-dashboard + help ─── */}
174 <div style="text-align:center;padding:16px 0 32px 0">
175 <a href="/dashboard" style="font-size:13px;color:var(--text-muted);text-decoration:underline">
176 Skip to dashboard {"→"}
177 </a>
178 <div style="margin-top:12px">
179 <Text size={12} muted>
180 Need help? See the <a href="/api/docs">API docs</a> or press <Kbd>?</Kbd> for shortcuts.
181 </Text>
182 </div>
153183 </div>
154184 </Container>
155185 </Layout>
156186 );
157187});
158188
159const StepCard = ({
160 number,
161 title,
162 description,
163 active,
164 completed,
165 children,
166}: {
167 number: number;
168 title: string;
169 description: string;
170 active?: boolean;
171 completed?: boolean;
172 children?: any;
173}) => (
174 <Card style={`border-color:${completed ? "var(--green)" : active ? "var(--accent)" : "var(--border)"};padding:20px;margin-bottom:16px;${completed ? "opacity:0.7;" : ""}`}>
175 <Flex gap={12} align="flex-start">
176 <div
177 style={`width:28px;height:28px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:13px;font-weight:600;flex-shrink:0;${completed ? "background:var(--green);color:#fff" : "background:var(--bg-tertiary);color:var(--text-muted)"}`}
178 >
179 {completed ? "\u2713" : number}
180 </div>
181 <div style="flex:1">
182 <h3 style="font-size:16px;margin-bottom:4px">{title}</h3>
183 <Text size={14} muted>{description}</Text>
184 {children}
185 </div>
186 </Flex>
187 </Card>
188);
189
190189export default onboardingRoutes;