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.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.

packages.tsBlame229 lines · 1 contributor
5f8508bClaude1/**
2 * npm-compatible package registry helpers (Block C2).
3 *
4 * Small, pure helpers used by both the protocol routes and the UI. Keeps
5 * the route file itself focused on wiring + DB access.
6 */
7
8import type { Package, PackageVersion, PackageTag } from "../db/schema";
9
10export type ParsedPackageName = {
11 scope: string | null; // "@acme" (with leading @) or null
12 name: string; // "foo"
13 full: string; // "@acme/foo" or "foo"
14};
15
16/**
17 * Parse an npm-style package name. Accepts both "foo" and "@scope/foo".
18 * Returns null on malformed input.
19 */
20export function parsePackageName(raw: string): ParsedPackageName | null {
21 if (!raw || typeof raw !== "string") return null;
22 // Decode %2F ("@scope%2Fname" — the npm client URL-encodes scoped names)
23 const decoded = (() => {
24 try {
25 return decodeURIComponent(raw);
26 } catch {
27 return raw;
28 }
29 })();
30
31 const trimmed = decoded.trim();
32 if (!trimmed) return null;
33
34 // Disallow slashes / whitespace / control chars in the bare name.
35 const safeSegment = /^[a-z0-9][a-z0-9._-]*$/i;
36
37 if (trimmed.startsWith("@")) {
38 const slash = trimmed.indexOf("/");
39 if (slash < 2) return null;
40 const scope = trimmed.slice(0, slash); // "@acme"
41 const name = trimmed.slice(slash + 1); // "foo"
42 const scopeBody = scope.slice(1); // "acme"
43 if (!safeSegment.test(scopeBody)) return null;
44 if (!safeSegment.test(name)) return null;
45 return { scope, name, full: `${scope}/${name}` };
46 }
47
48 if (!safeSegment.test(trimmed)) return null;
49 return { scope: null, name: trimmed, full: trimmed };
50}
51
52/** SHA1 hex of the given bytes (npm legacy shasum). */
53export function computeShasum(bytes: Uint8Array): string {
54 // Bun.CryptoHasher supports sha1 natively.
55 const h = new Bun.CryptoHasher("sha1");
56 h.update(bytes);
57 return h.digest("hex");
58}
59
60/** Subresource-Integrity format: "sha512-<base64 of sha512 digest>". */
61export function computeIntegrity(bytes: Uint8Array): string {
62 const h = new Bun.CryptoHasher("sha512");
63 h.update(bytes);
64 const digest = h.digest(); // Buffer
65 const b64 = Buffer.from(digest).toString("base64");
66 return `sha512-${b64}`;
67}
68
69/**
70 * Resolve the owner+repo that a package's metadata points to.
71 * Accepts either the object form `{ url: "...", type: "git" }` or the
72 * string shorthand. Returns null if we cannot locate a gluecron URL.
73 *
74 * Handles:
75 * - "http(s)://host/owner/repo(.git)?"
76 * - "git+https://host/owner/repo.git"
77 * - "git@host:owner/repo.git"
78 * - "github:owner/repo" → treated as "owner/repo" (still matched)
79 */
80export function resolveRepoFromPackageJson(
81 meta: unknown
82): { owner: string; repo: string } | null {
83 if (!meta || typeof meta !== "object") return null;
84 const m = meta as Record<string, unknown>;
85 const repoField = m["repository"];
86 let url: string | null = null;
87
88 if (typeof repoField === "string") {
89 url = repoField;
90 } else if (repoField && typeof repoField === "object") {
91 const u = (repoField as Record<string, unknown>)["url"];
92 if (typeof u === "string") url = u;
93 }
94
95 if (!url) return null;
96 return parseRepoUrl(url);
97}
98
99/** Lower-level helper also used by the publish route. Exported for tests. */
100export function parseRepoUrl(
101 urlRaw: string
102): { owner: string; repo: string } | null {
103 if (!urlRaw || typeof urlRaw !== "string") return null;
104 let url = urlRaw.trim();
105 if (!url) return null;
106
107 // Strip a "git+" prefix ("git+https://..."").
108 if (url.startsWith("git+")) url = url.slice(4);
109
25a91a6Claude110 // SCP-style: "user@host:owner/repo.git" — everything after the colon is
111 // the path we want. Accept either "git@host:a/b" or just "host:a/b".
112 if (!url.includes("://") && url.includes(":") && url.includes("/")) {
113 const colon = url.lastIndexOf(":");
5f8508bClaude114 const tail = url.slice(colon + 1);
115 if (tail.includes("/")) {
116 return splitOwnerRepo(tail);
117 }
118 }
119
120 // Regular URL form. Accept http(s)://host/owner/repo(.git)?
121 try {
122 const parsed = new URL(url);
123 const path = parsed.pathname.replace(/^\/+/, "");
124 return splitOwnerRepo(path);
125 } catch {
126 // Fall through to plain "owner/repo" path.
127 return splitOwnerRepo(url);
128 }
129}
130
131function splitOwnerRepo(
132 path: string
133): { owner: string; repo: string } | null {
134 const clean = path.replace(/\.git$/, "").replace(/^\/+|\/+$/g, "");
135 const parts = clean.split("/").filter(Boolean);
136 if (parts.length < 2) return null;
137 const owner = parts[parts.length - 2];
138 const repo = parts[parts.length - 1];
139 if (!owner || !repo) return null;
140 return { owner, repo };
141}
142
143/**
144 * Build an npm "packument" document (the document you get back from
145 * `GET /:name`). Shape roughly follows
146 * https://docs.npmjs.com/registry/api.
147 *
148 * The per-version objects merge the stored `package.json` metadata with the
149 * canonical `dist` block so that `npm install` knows where to fetch the
150 * tarball from.
151 */
152export function buildPackument(
153 pkg: Package,
154 versions: PackageVersion[],
155 tags: PackageTag[],
156 baseUrl: string
157): Record<string, unknown> {
158 const fullName = pkg.scope ? `${pkg.scope}/${pkg.name}` : pkg.name;
159 const base = baseUrl.replace(/\/+$/, "");
160
161 // versions map
162 const versionsOut: Record<string, Record<string, unknown>> = {};
163 const timeOut: Record<string, string> = {};
164
165 for (const v of versions) {
166 let meta: Record<string, unknown> = {};
167 try {
168 meta = v.metadata ? JSON.parse(v.metadata) : {};
169 } catch {
170 meta = {};
171 }
172 const tarballUrl = `${base}/npm/${encodeURI(fullName)}/-/${pkg.name}-${v.version}.tgz`;
173 versionsOut[v.version] = {
174 ...meta,
175 name: fullName,
176 version: v.version,
177 dist: {
178 tarball: tarballUrl,
179 shasum: v.shasum,
180 integrity: v.integrity ?? undefined,
181 },
182 ...(v.yanked ? { _yanked: true, _yankedReason: v.yankedReason } : {}),
183 };
184 const pub = v.publishedAt instanceof Date
185 ? v.publishedAt.toISOString()
186 : new Date(v.publishedAt as unknown as string).toISOString();
187 timeOut[v.version] = pub;
188 }
189
190 // dist-tags
191 const distTags: Record<string, string> = {};
192 const versionById = new Map(versions.map((v) => [v.id, v]));
193 for (const t of tags) {
194 const v = versionById.get(t.versionId);
195 if (v) distTags[t.tag] = v.version;
196 }
197 // Fallback: if no "latest" tag, use the most recently published version.
198 if (!distTags["latest"] && versions.length > 0) {
199 const sorted = [...versions].sort((a, b) => {
200 const ad = new Date(a.publishedAt as unknown as string).getTime();
201 const bd = new Date(b.publishedAt as unknown as string).getTime();
202 return bd - ad;
203 });
204 distTags["latest"] = sorted[0].version;
205 }
206
207 return {
208 _id: fullName,
209 name: fullName,
210 description: pkg.description ?? undefined,
211 "dist-tags": distTags,
212 versions: versionsOut,
213 time: timeOut,
214 homepage: pkg.homepage ?? undefined,
215 license: pkg.license ?? undefined,
216 readme: pkg.readme ?? undefined,
217 };
218}
219
220/**
221 * Tarball filename convention expected by npm clients: `<name>-<version>.tgz`.
222 * For scoped packages, the *scope* is part of the URL but NOT the filename.
223 */
224export function tarballFilename(
225 parsed: ParsedPackageName,
226 version: string
227): string {
228 return `${parsed.name}-${version}.tgz`;
229}