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

packages.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

packages.tsxBlame442 lines · 1 contributor
25a91a6Claude1/**
2 * Packages UI — lists packages for a repo, per-package detail (Block C2).
3 *
4 * GET /:owner/:repo/packages — list of published packages
5 * GET /:owner/:repo/packages/:pkg{.+} — detail + version list + install help
6 *
7 * Packages doesn't yet have a tab in RepoNav — we render with active="code"
8 * so the page still lays out correctly.
9 */
10
11import { Hono } from "hono";
12import { and, desc, eq } from "drizzle-orm";
13import { db } from "../db";
14import {
15 packages,
16 packageVersions,
17 packageTags,
18 repositories,
19 users,
20} from "../db/schema";
21import type { Package, PackageVersion, PackageTag } from "../db/schema";
22import { Layout } from "../views/layout";
23import { RepoHeader, RepoNav } from "../views/components";
24import { softAuth } from "../middleware/auth";
25import type { AuthEnv } from "../middleware/auth";
26import { getUnreadCount } from "../lib/unread";
27import { parsePackageName } from "../lib/packages";
28
29const ui = new Hono<AuthEnv>();
30ui.use("*", softAuth);
31
32async function loadRepo(owner: string, repo: string) {
33 const [row] = await db
34 .select({
35 id: repositories.id,
36 name: repositories.name,
37 ownerId: repositories.ownerId,
38 defaultBranch: repositories.defaultBranch,
39 starCount: repositories.starCount,
40 forkCount: repositories.forkCount,
41 })
42 .from(repositories)
43 .innerJoin(users, eq(repositories.ownerId, users.id))
44 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
45 .limit(1);
46 return row || null;
47}
48
49function relTime(d: Date | string | null): string {
50 if (!d) return "";
51 const t = typeof d === "string" ? new Date(d) : d;
52 const diff = Date.now() - t.getTime();
53 const mins = Math.floor(diff / 60000);
54 if (mins < 1) return "just now";
55 if (mins < 60) return `${mins}m ago`;
56 const hrs = Math.floor(mins / 60);
57 if (hrs < 24) return `${hrs}h ago`;
58 const days = Math.floor(hrs / 24);
59 if (days < 30) return `${days}d ago`;
60 return t.toLocaleDateString();
61}
62
63function fullPkgName(pkg: { scope: string | null; name: string }): string {
64 return pkg.scope ? `${pkg.scope}/${pkg.name}` : pkg.name;
65}
66
67function humanSize(bytes: number): string {
68 if (bytes < 1024) return `${bytes} B`;
69 if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
70 return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
71}
72
73// ---------------------------------------------------------------------------
74// List page
75// ---------------------------------------------------------------------------
76
77ui.get("/:owner/:repo/packages", async (c) => {
78 const user = c.get("user");
79 const { owner, repo } = c.req.param();
80 const repoRow = await loadRepo(owner, repo);
81 if (!repoRow) return c.notFound();
82
83 let rows: (Package & { latestVersion: string | null })[] = [];
84 try {
85 const pkgs = await db
86 .select()
87 .from(packages)
88 .where(
89 and(
90 eq(packages.repositoryId, repoRow.id),
91 eq(packages.ecosystem, "npm")
92 )
93 )
94 .orderBy(desc(packages.updatedAt));
95
96 // Fetch the "latest" tag for each package.
97 const latest = await Promise.all(
98 pkgs.map(async (p) => {
99 try {
100 const [tag] = await db
101 .select({
102 version: packageVersions.version,
103 })
104 .from(packageTags)
105 .innerJoin(
106 packageVersions,
107 eq(packageTags.versionId, packageVersions.id)
108 )
109 .where(
110 and(
111 eq(packageTags.packageId, p.id),
112 eq(packageTags.tag, "latest")
113 )
114 )
115 .limit(1);
116 return tag?.version || null;
117 } catch {
118 return null;
119 }
120 })
121 );
122 rows = pkgs.map((p, i) => ({ ...p, latestVersion: latest[i] }));
123 } catch (err) {
124 console.error("[packages] ui list:", err);
125 return c.text("Service unavailable", 503);
126 }
127
128 const unread = user ? await getUnreadCount(user.id) : 0;
129 const host = new URL(c.req.url).host;
130 const registryUrl = `${new URL(c.req.url).protocol}//${host}/npm/`;
131
132 return c.html(
133 <Layout
134 title={`Packages — ${owner}/${repo}`}
135 user={user}
136 notificationCount={unread}
137 >
138 <RepoHeader
139 owner={owner}
140 repo={repo}
141 starCount={repoRow.starCount}
142 forkCount={repoRow.forkCount}
143 currentUser={user?.username || null}
144 />
145 <RepoNav owner={owner} repo={repo} active="code" />
146
147 <div style="max-width: 900px">
148 <h2 style="margin: 0 0 16px 0">Packages</h2>
149
150 {rows.length === 0 ? (
151 <div class="empty-state">
152 <p style="margin-bottom: 12px">
153 No npm packages published from this repository yet.
154 </p>
155 <div
156 style="text-align: left; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 6px; padding: 12px 16px; font-size: 13px; max-width: 640px; margin: 0 auto"
157 >
158 <strong>To publish:</strong>
159 <ol style="padding-left: 20px; margin: 8px 0 0 0; line-height: 1.6">
160 <li>
161 Create a personal access token at{" "}
162 <a href="/settings/tokens">/settings/tokens</a>.
163 </li>
164 <li>
165 Add to your <code>.npmrc</code>:
166 <pre style="background: #0b0d0f; color: #c7ccd1; padding: 8px 12px; border-radius: 4px; font-size: 12px; margin: 6px 0">
167 registry={registryUrl}
168 {"\n"}
169 //{host}/npm/:_authToken=YOUR_PAT
170 </pre>
171 </li>
172 <li>
173 In <code>package.json</code>, point{" "}
174 <code>repository.url</code> at this repo
175 (<code>
176 {`http://${host}/${owner}/${repo}.git`}
177 </code>
178 ).
179 </li>
180 <li>
181 Run <code>npm publish</code>.
182 </li>
183 </ol>
184 </div>
185 </div>
186 ) : (
187 <div class="panel" style="overflow: hidden">
188 {rows.map((p) => {
189 const fullName = fullPkgName(p);
190 return (
191 <a
192 href={`/${owner}/${repo}/packages/${encodeURIComponent(fullName)}`}
193 style="display: block; padding: 12px 16px; border-bottom: 1px solid var(--border); text-decoration: none; color: inherit"
194 >
195 <div style="display: flex; justify-content: space-between; align-items: baseline; gap: 12px">
196 <div style="flex: 1; min-width: 0">
197 <div style="font-weight: 600">{fullName}</div>
198 {p.description && (
199 <div style="font-size: 13px; color: var(--text-muted); margin-top: 2px">
200 {p.description}
201 </div>
202 )}
203 </div>
204 <div style="font-size: 12px; color: var(--text-muted); white-space: nowrap">
205 {p.latestVersion ? (
206 <span>
207 <code>{p.latestVersion}</code>
208 </span>
209 ) : (
210 <span>no versions</span>
211 )}
212 {" · "}
213 <span>{relTime(p.updatedAt)}</span>
214 </div>
215 </div>
216 </a>
217 );
218 })}
219 </div>
220 )}
221 </div>
222 </Layout>
223 );
224});
225
226// ---------------------------------------------------------------------------
227// Detail page
228// ---------------------------------------------------------------------------
229
230ui.get("/:owner/:repo/packages/:pkgName{.+}", async (c) => {
231 const user = c.get("user");
232 const { owner, repo, pkgName } = c.req.param();
233 const parsed = parsePackageName(pkgName);
234 if (!parsed) {
235 return c.text("Invalid package name", 400);
236 }
237
238 const repoRow = await loadRepo(owner, repo);
239 if (!repoRow) return c.notFound();
240
241 let pkg: Package | null = null;
242 let versions: PackageVersion[] = [];
243 let tags: PackageTag[] = [];
244 try {
245 const candidates = await db
246 .select()
247 .from(packages)
248 .where(
249 and(
250 eq(packages.repositoryId, repoRow.id),
251 eq(packages.ecosystem, "npm"),
252 eq(packages.name, parsed.name)
253 )
254 )
255 .limit(10);
256 pkg =
257 candidates.find((p) => (p.scope ?? null) === (parsed.scope ?? null)) ||
258 null;
259 if (pkg) {
260 versions = await db
261 .select()
262 .from(packageVersions)
263 .where(eq(packageVersions.packageId, pkg.id))
264 .orderBy(desc(packageVersions.publishedAt));
265 tags = await db
266 .select()
267 .from(packageTags)
268 .where(eq(packageTags.packageId, pkg.id));
269 }
270 } catch (err) {
271 console.error("[packages] ui detail:", err);
272 return c.text("Service unavailable", 503);
273 }
274
275 if (!pkg) return c.notFound();
276
277 const unread = user ? await getUnreadCount(user.id) : 0;
278 const fullName = fullPkgName(pkg);
279 const latestTag = tags.find((t) => t.tag === "latest");
280 const latestVersion =
281 latestTag && versions.find((v) => v.id === latestTag.versionId);
282 const isOwner = !!user && user.id === repoRow.ownerId;
283 const host = new URL(c.req.url).host;
284
285 return c.html(
286 <Layout
287 title={`${fullName} — ${owner}/${repo}`}
288 user={user}
289 notificationCount={unread}
290 >
291 <RepoHeader
292 owner={owner}
293 repo={repo}
294 starCount={repoRow.starCount}
295 forkCount={repoRow.forkCount}
296 currentUser={user?.username || null}
297 />
298 <RepoNav owner={owner} repo={repo} active="code" />
299
300 <div style="max-width: 900px">
301 <div style="font-size: 12px; color: var(--text-muted); margin-bottom: 6px">
302 <a href={`/${owner}/${repo}/packages`}>Packages</a>
303 {" / "}
304 <span>{fullName}</span>
305 </div>
306 <h2 style="margin: 0 0 4px 0">{fullName}</h2>
307 {pkg.description && (
308 <p style="color: var(--text-muted); margin: 0 0 16px 0">
309 {pkg.description}
310 </p>
311 )}
312
313 <div style="display: grid; grid-template-columns: 1fr 280px; gap: 24px">
314 <div>
315 <h3 style="font-size: 14px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); margin: 0 0 8px 0">
316 Install
317 </h3>
318 <pre style="background: #0b0d0f; color: #c7ccd1; padding: 10px 14px; border-radius: 6px; font-size: 13px; overflow-x: auto">
319 npm install {fullName}
320 {latestVersion ? `@${latestVersion.version}` : ""}
321 </pre>
322
323 {pkg.readme && (
324 <>
325 <h3 style="font-size: 14px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); margin: 20px 0 8px 0">
326 Readme
327 </h3>
328 <pre
329 style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 6px; padding: 12px 14px; white-space: pre-wrap; font-size: 13px; line-height: 1.5"
330 >
331 {pkg.readme}
332 </pre>
333 </>
334 )}
335
336 <h3 style="font-size: 14px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); margin: 20px 0 8px 0">
337 Versions
338 </h3>
339 {versions.length === 0 ? (
340 <div class="empty-state">
341 <p>No versions yet.</p>
342 </div>
343 ) : (
344 <div class="panel" style="overflow: hidden">
345 {versions.map((v) => (
346 <div
347 style="padding: 10px 14px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; gap: 12px"
348 >
349 <div style="flex: 1; min-width: 0">
350 <div style="font-weight: 500">
351 <code>{v.version}</code>
352 {v.yanked && (
353 <span
354 style="margin-left: 8px; font-size: 11px; color: var(--red); text-transform: uppercase"
355 >
356 yanked
357 </span>
358 )}
359 </div>
360 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px">
361 {humanSize(v.sizeBytes)} · published{" "}
362 {relTime(v.publishedAt)}
363 {v.shasum && (
364 <>
365 {" · sha1 "}
366 <code style="font-size: 11px">
367 {v.shasum.slice(0, 12)}
368 </code>
369 </>
370 )}
371 </div>
372 </div>
373 <a
374 class="btn btn-sm"
375 href={`/npm/${encodeURIComponent(fullName)}/-/${pkg.name}-${v.version}.tgz`}
376 >
377 Download
378 </a>
379 {isOwner && !v.yanked && (
380 <form
e7e240eClaude381 method="post"
25a91a6Claude382 action={`/api/packages/${owner}/${repo}/${encodeURIComponent(fullName)}/${v.version}/yank`}
383 onsubmit="return confirm('Yank this version? It will still download, but will be flagged as yanked.')"
384 style="margin: 0"
385 >
386 <button
387 type="submit"
388 class="btn btn-sm btn-danger"
389 >
390 Yank
391 </button>
392 </form>
393 )}
394 </div>
395 ))}
396 </div>
397 )}
398 </div>
399
400 <aside>
401 <div class="panel" style="padding: 12px 14px">
402 <div style="font-size: 12px; color: var(--text-muted); margin-bottom: 4px">
403 Registry
404 </div>
405 <code style="font-size: 12px">http://{host}/npm/</code>
406
407 {pkg.homepage && (
408 <>
409 <div style="font-size: 12px; color: var(--text-muted); margin: 10px 0 4px 0">
410 Homepage
411 </div>
412 <a href={pkg.homepage} style="font-size: 13px; word-break: break-all">
413 {pkg.homepage}
414 </a>
415 </>
416 )}
417 {pkg.license && (
418 <>
419 <div style="font-size: 12px; color: var(--text-muted); margin: 10px 0 4px 0">
420 License
421 </div>
422 <div style="font-size: 13px">{pkg.license}</div>
423 </>
424 )}
425 <div style="font-size: 12px; color: var(--text-muted); margin: 10px 0 4px 0">
426 Repository
427 </div>
428 <a
429 href={`/${owner}/${repo}`}
430 style="font-size: 13px; word-break: break-all"
431 >
432 {owner}/{repo}
433 </a>
434 </div>
435 </aside>
436 </div>
437 </div>
438 </Layout>
439 );
440});
441
442export default ui;