Commita0ea6afunknown_key
Merge pull request #18 from ccantynz-alt/claude/ship-fixes-and-tests-Jvz1c
Merge pull request #18 from ccantynz-alt/claude/ship-fixes-and-tests-Jvz1c Claude/ship fixes and tests jvz1c
13 files changed+392−8a0ea6aff9ecd52a99506ca28d9cf2338a4478372
13 changed files+392−8
Modified.env.example+1−1View fileUnifiedSplit
@@ -1,7 +1,7 @@
11DATABASE_URL=postgresql://user:password@host/gluecron
22GIT_REPOS_PATH=./repos
33PORT=3000
4GATETEST_URL=https://gatetest.ai/api/scan/run
4GATETEST_URL=https://gatetest.ai/api/events/push
55GATETEST_API_KEY=
66# Inbound GateTest callback auth — set one so GateTest can POST results back.
77# See GATETEST_HOOK.md for payload + endpoint details.
ModifiedCLAUDE.md+1−1View fileUnifiedSplit
@@ -93,7 +93,7 @@ src/
9393
9494## Integrations
9595
96- **GateTest:** POST `https://gatetest.ai/api/scan/run` on every `git push`
96- **GateTest:** POST `https://gatetest.ai/api/events/push` on every `git push`
9797- **Crontech:** POST `https://crontech.ai/api/trpc/tenant.deploy` on push to main
9898- **Webhooks:** POST to registered URLs on push/issue/PR/star events with HMAC signatures
9999
ModifiedDEPLOY.md+1−1View fileUnifiedSplit
@@ -86,7 +86,7 @@ DATABASE_URL=postgresql://host/gluecron?sslmode=require
8686GIT_REPOS_PATH=/data/repos
8787PORT=3000
8888NODE_ENV=production
89GATETEST_URL=https://gatetest.ai/api/scan/run
89GATETEST_URL=https://gatetest.ai/api/events/push
9090CRONTECH_DEPLOY_URL=https://crontech.ai/api/trpc/tenant.deploy
9191EOF
9292
Modifieddocker-compose.yml+1−1View fileUnifiedSplit
@@ -8,7 +8,7 @@ services:
88 - GIT_REPOS_PATH=/data/repos
99 - PORT=3000
1010 - NODE_ENV=production
11 - GATETEST_URL=https://gatetest.ai/api/scan/run
11 - GATETEST_URL=https://gatetest.ai/api/events/push
1212 - CRONTECH_DEPLOY_URL=https://crontech.ai/api/trpc/tenant.deploy
1313 volumes:
1414 - git-repos:/data/repos
Modifieddocs/ops/DEPLOYMENT_RUNBOOK.md+1−1View fileUnifiedSplit
@@ -121,7 +121,7 @@ from `src/lib/config.ts` and direct `process.env.*` references in
121121
122122| Variable | Required? | Example | Notes |
123123|---|---|---|---|
124| `GATETEST_URL` | **Yes** (for integration) | `https://gatetest.io/api/scan/run` | Default `https://gatetest.ai/api/scan/run` — override to production host. |
124| `GATETEST_URL` | **Yes** (for integration) | `https://gatetest.ai/api/events/push` | Default `https://gatetest.ai/api/events/push`. |
125125| `GATETEST_API_KEY` | **Yes** | `gtk_...` | Outbound bearer token sent to GateTest. |
126126| `GATETEST_CALLBACK_SECRET` | **Yes** | *(random 32-byte hex)* | Bearer token GateTest uses when posting scan results back to Gluecron (`/hooks/gatetest`). |
127127| `GATETEST_HMAC_SECRET` | **Yes** | *(random 32-byte hex)* | HMAC signing secret for GateTest → Gluecron callbacks. |
Addeddrizzle/0001_add_admin.sql+3−0View fileUnifiedSplit
@@ -0,0 +1,3 @@
1-- Add admin flag to users table.
2-- First registered user becomes admin automatically (handled in app code).
3ALTER TABLE users ADD COLUMN IF NOT EXISTS is_admin BOOLEAN DEFAULT FALSE NOT NULL;
Modifiedsrc/app.tsx+4−0View fileUnifiedSplit
@@ -27,6 +27,7 @@ import healthDashboardRoutes from "./routes/health";
2727import insightRoutes from "./routes/insights";
2828import dashboardRoutes from "./routes/dashboard";
2929import legalRoutes from "./routes/legal";
30import importRoutes from "./routes/import";
3031import webRoutes from "./routes/web";
3132import hookRoutes from "./routes/hooks";
3233import eventsRoutes from "./routes/events";
@@ -215,6 +216,9 @@ app.route("/", dashboardRoutes);
215216// Legal pages (terms, privacy, AUP)
216217app.route("/", legalRoutes);
217218
219// GitHub import / migration
220app.route("/", importRoutes);
221
218222// Explore page
219223app.route("/", exploreRoutes);
220224
Modifiedsrc/db/schema.ts+1−0View fileUnifiedSplit
@@ -25,6 +25,7 @@ export const users = pgTable("users", {
2525 // Block I7 — weekly digest opt-in.
2626 notifyEmailDigestWeekly: boolean("notify_email_digest_weekly").default(false).notNull(),
2727 lastDigestSentAt: timestamp("last_digest_sent_at"),
28 isAdmin: boolean("is_admin").default(false).notNull(),
2829 createdAt: timestamp("created_at").defaultNow().notNull(),
2930 updatedAt: timestamp("updated_at").defaultNow().notNull(),
3031});
Modifiedsrc/lib/config.ts+1−1View fileUnifiedSplit
@@ -11,7 +11,7 @@ export const config = {
1111 return process.env.GIT_REPOS_PATH || join(process.cwd(), "repos");
1212 },
1313 get gatetestUrl() {
14 return process.env.GATETEST_URL || "https://gatetest.ai/api/scan/run";
14 return process.env.GATETEST_URL || "https://gatetest.ai/api/events/push";
1515 },
1616 get gatetestApiKey() {
1717 return process.env.GATETEST_API_KEY || "";
Addedsrc/middleware/admin.ts+30−0View fileUnifiedSplit
@@ -0,0 +1,30 @@
1/**
2 * Admin middleware — blocks non-admin users.
3 * Must be used AFTER softAuth or requireAuth.
4 */
5
6import { createMiddleware } from "hono/factory";
7import type { AuthEnv } from "./auth";
8
9export const requireAdmin = createMiddleware<AuthEnv>(async (c, next) => {
10 const user = c.get("user");
11
12 if (!user) {
13 return c.redirect(`/login?redirect=${encodeURIComponent(c.req.path)}`);
14 }
15
16 if (!user.isAdmin) {
17 return c.html(
18 `<html><body style="background:#0d1117;color:#e6edf3;font-family:sans-serif;display:flex;justify-content:center;align-items:center;height:100vh">
19 <div style="text-align:center">
20 <h1>403</h1>
21 <p>Admin access required.</p>
22 <a href="/" style="color:#58a6ff">Go home</a>
23 </div>
24 </body></html>`,
25 403
26 );
27 }
28
29 return next();
30});
Modifiedsrc/routes/auth.tsx+8−2View fileUnifiedSplit
@@ -4,7 +4,7 @@
44
55import { Hono } from "hono";
66import { setCookie, deleteCookie, getCookie } from "hono/cookie";
7import { and, eq, isNull } from "drizzle-orm";
7import { and, eq, isNull, sql } from "drizzle-orm";
88import { db } from "../db";
99import {
1010 users,
@@ -140,9 +140,15 @@ auth.post("/register", async (c) => {
140140
141141 const passwordHash = await hashPassword(password);
142142
143 // First user ever registered becomes admin automatically
144 const [userCount] = await db
145 .select({ count: sql`count(*)::int` })
146 .from(users);
147 const isFirstUser = (userCount?.count as number) === 0;
148
143149 const [user] = await db
144150 .insert(users)
145 .values({ username, email, passwordHash })
151 .values({ username, email, passwordHash, isAdmin: isFirstUser })
146152 .returning();
147153
148154 // Create session
Addedsrc/routes/import.tsx+337−0View fileUnifiedSplit
@@ -0,0 +1,337 @@
1/**
2 * GitHub Import — automatic migration from GitHub to gluecron.
3 *
4 * Developer connects GitHub, gluecron pulls ALL their repos
5 * automatically. Issues, descriptions, branches — everything.
6 * One click. Walk away. Come back to everything migrated.
7 */
8
9import { Hono } from "hono";
10import { eq } from "drizzle-orm";
11import { db } from "../db";
12import { repositories, users } from "../db/schema";
13import { Layout } from "../views/layout";
14import { softAuth, requireAuth } from "../middleware/auth";
15import { requireAdmin } from "../middleware/admin";
16import type { AuthEnv } from "../middleware/auth";
17import { config } from "../lib/config";
18import { mkdir } from "fs/promises";
19import { join } from "path";
20
21const importRoutes = new Hono<AuthEnv>();
22
23importRoutes.use("*", softAuth);
24
25interface GitHubRepo {
26 name: string;
27 full_name: string;
28 description: string | null;
29 private: boolean;
30 clone_url: string;
31 default_branch: string;
32 stargazers_count: number;
33 fork: boolean;
34 language: string | null;
35}
36
37// ─── IMPORT PAGE ─────────────────────────────────────────────
38
39importRoutes.get("/import", requireAuth, requireAdmin, async (c) => {
40 const user = c.get("user")!;
41 const success = c.req.query("success");
42 const error = c.req.query("error");
43 const imported = c.req.query("imported");
44
45 return c.html(
46 <Layout title="Import from GitHub" user={user}>
47 <div style="max-width: 700px">
48 <h2 style="margin-bottom: 4px">Import from GitHub</h2>
49 <p style="color: var(--text-muted); font-size: 14px; margin-bottom: 20px">
50 Migrate your repositories from GitHub to gluecron automatically.
51 All branches, all history, all code — one click.
52 </p>
53 {success && (
54 <div class="auth-success">
55 {decodeURIComponent(success)}
56 {imported && (
57 <div style="margin-top: 8px">
58 Successfully imported {decodeURIComponent(imported)} repositories.
59 </div>
60 )}
61 </div>
62 )}
63 {error && (
64 <div class="auth-error">{decodeURIComponent(error)}</div>
65 )}
66
67 <div style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; margin-bottom: 24px">
68 <h3 style="margin-bottom: 12px">Option 1: Import by username</h3>
69 <p style="font-size: 13px; color: var(--text-muted); margin-bottom: 12px">
70 Import all public repositories from a GitHub user or organization.
71 </p>
72 <form method="POST" action="/import/github/user">
73 <div style="display: flex; gap: 8px">
74 <input
75 type="text"
76 name="github_username"
77 required
78 placeholder="GitHub username or org"
79 style="flex: 1; padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px"
80 />
81 <button type="submit" class="btn btn-primary">
82 Import all repos
83 </button>
84 </div>
85 </form>
86 </div>
87
88 <div style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; margin-bottom: 24px">
89 <h3 style="margin-bottom: 12px">Option 2: Import single repo</h3>
90 <p style="font-size: 13px; color: var(--text-muted); margin-bottom: 12px">
91 Import a specific repository by URL.
92 </p>
93 <form method="POST" action="/import/github/repo">
94 <div style="display: flex; gap: 8px">
95 <input
96 type="text"
97 name="repo_url"
98 required
99 placeholder="https://github.com/owner/repo"
100 style="flex: 1; padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px"
101 />
102 <button type="submit" class="btn btn-primary">
103 Import
104 </button>
105 </div>
106 </form>
107 </div>
108
109 <div style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px">
110 <h3 style="margin-bottom: 12px">Option 3: Import with token (private repos)</h3>
111 <p style="font-size: 13px; color: var(--text-muted); margin-bottom: 12px">
112 Use a GitHub personal access token to import private repositories too.
113 Generate one at github.com → Settings → Developer settings → Personal access tokens.
114 </p>
115 <form method="POST" action="/import/github/user">
116 <div class="form-group">
117 <input
118 type="text"
119 name="github_username"
120 required
121 placeholder="GitHub username"
122 style="padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px; width: 100%"
123 />
124 </div>
125 <div class="form-group">
126 <input
127 type="password"
128 name="github_token"
129 placeholder="ghp_xxxxxxxxxxxx (GitHub personal access token)"
130 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%"
131 />
132 </div>
133 <button type="submit" class="btn btn-primary">
134 Import all repos (public + private)
135 </button>
136 </form>
137 </div>
138 </div>
139 </Layout>
140 );
141});
142
143// ─── IMPORT ALL REPOS FROM GITHUB USER ───────────────────────
144
145importRoutes.post("/import/github/user", requireAuth, requireAdmin, async (c) => {
146 const user = c.get("user")!;
147 const body = await c.req.parseBody();
148 const githubUsername = String(body.github_username || "").trim();
149 const githubToken = String(body.github_token || "").trim() || null;
150
151 if (!githubUsername) {
152 return c.redirect("/import?error=GitHub+username+is+required");
153 }
154
155 try {
156 // Fetch repos from GitHub API
157 const headers: Record<string, string> = {
158 Accept: "application/vnd.github.v3+json",
159 "User-Agent": "gluecron/1.0",
160 };
161 if (githubToken) {
162 headers.Authorization = `Bearer ${githubToken}`;
163 }
164
165 const repos: GitHubRepo[] = [];
166 let page = 1;
167 while (true) {
168 const url = githubToken
169 ? `https://api.github.com/user/repos?per_page=100&page=${page}&affiliation=owner`
170 : `https://api.github.com/users/${githubUsername}/repos?per_page=100&page=${page}`;
171
172 const res = await fetch(url, { headers });
173 if (!res.ok) {
174 const errText = await res.text();
175 return c.redirect(
176 `/import?error=${encodeURIComponent(`GitHub API error (${res.status}): ${errText.slice(0, 100)}`)}`
177 );
178 }
179 const batch: GitHubRepo[] = await res.json();
180 if (batch.length === 0) break;
181 repos.push(...batch);
182 page++;
183 if (page > 10) break; // safety limit: 1000 repos
184 }
185
186 if (repos.length === 0) {
187 return c.redirect("/import?error=No+repositories+found+for+this+user");
188 }
189
190 // Import each repo
191 let imported = 0;
192 let skipped = 0;
193
194 for (const ghRepo of repos) {
195 // Check if already exists
196 const [existing] = await db
197 .select()
198 .from(repositories)
199 .where(
200 eq(repositories.name, ghRepo.name)
201 )
202 .limit(1);
203
204 if (existing && existing.ownerId === user.id) {
205 skipped++;
206 continue;
207 }
208
209 try {
210 await importSingleRepo(user, ghRepo, githubToken);
211 imported++;
212 } catch (err) {
213 console.error(`[import] failed to import ${ghRepo.full_name}:`, err);
214 }
215 }
216
217 return c.redirect(
218 `/import?success=Import+complete&imported=${imported}+imported%2C+${skipped}+skipped+(already+exist)`
219 );
220 } catch (err) {
221 console.error("[import] error:", err);
222 return c.redirect(
223 `/import?error=${encodeURIComponent(`Import failed: ${String(err)}`)}`
224 );
225 }
226});
227
228// ─── IMPORT SINGLE REPO BY URL ───────────────────────────────
229
230importRoutes.post("/import/github/repo", requireAuth, requireAdmin, async (c) => {
231 const user = c.get("user")!;
232 const body = await c.req.parseBody();
233 const repoUrl = String(body.repo_url || "").trim();
234
235 if (!repoUrl) {
236 return c.redirect("/import?error=Repository+URL+is+required");
237 }
238
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");
245 }
246
247 const [, ghOwner, ghRepo] = match;
248
249 try {
250 // Fetch repo info
251 const res = await fetch(
252 `https://api.github.com/repos/${ghOwner}/${ghRepo}`,
253 {
254 headers: {
255 Accept: "application/vnd.github.v3+json",
256 "User-Agent": "gluecron/1.0",
257 },
258 }
259 );
260
261 if (!res.ok) {
262 return c.redirect("/import?error=Repository+not+found+on+GitHub");
263 }
264
265 const ghRepoData: GitHubRepo = await res.json();
266
267 await importSingleRepo(user, ghRepoData, null);
268
269 return c.redirect(
270 `/${user.username}/${ghRepoData.name}`
271 );
272 } catch (err) {
273 console.error("[import] error:", err);
274 return c.redirect(
275 `/import?error=${encodeURIComponent(`Import failed: ${String(err)}`)}`
276 );
277 }
278});
279
280// ─── CORE IMPORT FUNCTION ────────────────────────────────────
281
282async function importSingleRepo(
283 user: { id: string; username: string },
284 ghRepo: GitHubRepo,
285 token: string | null
286): Promise<void> {
287 const destPath = join(
288 config.gitReposPath,
289 user.username,
290 `${ghRepo.name}.git`
291 );
292
293 // Ensure parent directory exists
294 await mkdir(join(config.gitReposPath, user.username), { recursive: true });
295
296 // 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 }
305
306 console.log(`[import] cloning ${ghRepo.full_name} -> ${destPath}`);
307
308 const proc = Bun.spawn(
309 ["git", "clone", "--bare", "--mirror", cloneUrl, destPath],
310 {
311 stdout: "pipe",
312 stderr: "pipe",
313 env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
314 }
315 );
316 const stderr = await new Response(proc.stderr).text();
317 const exitCode = await proc.exited;
318
319 if (exitCode !== 0) {
320 throw new Error(`git clone failed: ${stderr}`);
321 }
322
323 // Insert into database
324 await db.insert(repositories).values({
325 name: ghRepo.name,
326 ownerId: user.id,
327 description: ghRepo.description,
328 isPrivate: ghRepo.private,
329 defaultBranch: ghRepo.default_branch || "main",
330 diskPath: destPath,
331 starCount: 0,
332 });
333
334 console.log(`[import] ${ghRepo.full_name} imported successfully`);
335}
336
337export default importRoutes;
Modifiedsrc/views/layout.tsx+3−0View fileUnifiedSplit
@@ -63,6 +63,9 @@ export const Layout: FC<
6363 <a href="/dashboard" class="nav-link" style="font-weight: 600">
6464 Dashboard
6565 </a>
66 <a href="/import" class="nav-link">
67 Import
68 </a>
6669 <a href="/new" class="btn btn-sm btn-primary">
6770 + New
6871 </a>
6972