Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit5f8508bunknown_key

wip(BLOCK-C2): packages lib helpers (npm protocol)

wip(BLOCK-C2): packages lib helpers (npm protocol)

Agent-authored partial. Routes + tests follow in a later commit.
Claude committed on April 15, 2026Parent: e2da5c6
1 file changed+23005f8508be80bc47f3349741297dc9cc039dcd3c75
1 changed file+230−0
Addedsrc/lib/packages.ts+230−0View fileUnifiedSplit
1/**
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
110 // Shorthand "github:owner/repo" — we accept any "host:owner/repo" form and
111 // treat the bit after ':' as "owner/repo" if it contains a slash.
112 if (/^[a-z0-9_-]+:/i.test(url) && !url.startsWith("http")) {
113 const colon = url.indexOf(":");
114 const tail = url.slice(colon + 1);
115 // SCP-style git@host:owner/repo.git
116 if (tail.includes("/")) {
117 return splitOwnerRepo(tail);
118 }
119 }
120
121 // Regular URL form. Accept http(s)://host/owner/repo(.git)?
122 try {
123 const parsed = new URL(url);
124 const path = parsed.pathname.replace(/^\/+/, "");
125 return splitOwnerRepo(path);
126 } catch {
127 // Fall through to plain "owner/repo" path.
128 return splitOwnerRepo(url);
129 }
130}
131
132function splitOwnerRepo(
133 path: string
134): { owner: string; repo: string } | null {
135 const clean = path.replace(/\.git$/, "").replace(/^\/+|\/+$/g, "");
136 const parts = clean.split("/").filter(Boolean);
137 if (parts.length < 2) return null;
138 const owner = parts[parts.length - 2];
139 const repo = parts[parts.length - 1];
140 if (!owner || !repo) return null;
141 return { owner, repo };
142}
143
144/**
145 * Build an npm "packument" document (the document you get back from
146 * `GET /:name`). Shape roughly follows
147 * https://docs.npmjs.com/registry/api.
148 *
149 * The per-version objects merge the stored `package.json` metadata with the
150 * canonical `dist` block so that `npm install` knows where to fetch the
151 * tarball from.
152 */
153export function buildPackument(
154 pkg: Package,
155 versions: PackageVersion[],
156 tags: PackageTag[],
157 baseUrl: string
158): Record<string, unknown> {
159 const fullName = pkg.scope ? `${pkg.scope}/${pkg.name}` : pkg.name;
160 const base = baseUrl.replace(/\/+$/, "");
161
162 // versions map
163 const versionsOut: Record<string, Record<string, unknown>> = {};
164 const timeOut: Record<string, string> = {};
165
166 for (const v of versions) {
167 let meta: Record<string, unknown> = {};
168 try {
169 meta = v.metadata ? JSON.parse(v.metadata) : {};
170 } catch {
171 meta = {};
172 }
173 const tarballUrl = `${base}/npm/${encodeURI(fullName)}/-/${pkg.name}-${v.version}.tgz`;
174 versionsOut[v.version] = {
175 ...meta,
176 name: fullName,
177 version: v.version,
178 dist: {
179 tarball: tarballUrl,
180 shasum: v.shasum,
181 integrity: v.integrity ?? undefined,
182 },
183 ...(v.yanked ? { _yanked: true, _yankedReason: v.yankedReason } : {}),
184 };
185 const pub = v.publishedAt instanceof Date
186 ? v.publishedAt.toISOString()
187 : new Date(v.publishedAt as unknown as string).toISOString();
188 timeOut[v.version] = pub;
189 }
190
191 // dist-tags
192 const distTags: Record<string, string> = {};
193 const versionById = new Map(versions.map((v) => [v.id, v]));
194 for (const t of tags) {
195 const v = versionById.get(t.versionId);
196 if (v) distTags[t.tag] = v.version;
197 }
198 // Fallback: if no "latest" tag, use the most recently published version.
199 if (!distTags["latest"] && versions.length > 0) {
200 const sorted = [...versions].sort((a, b) => {
201 const ad = new Date(a.publishedAt as unknown as string).getTime();
202 const bd = new Date(b.publishedAt as unknown as string).getTime();
203 return bd - ad;
204 });
205 distTags["latest"] = sorted[0].version;
206 }
207
208 return {
209 _id: fullName,
210 name: fullName,
211 description: pkg.description ?? undefined,
212 "dist-tags": distTags,
213 versions: versionsOut,
214 time: timeOut,
215 homepage: pkg.homepage ?? undefined,
216 license: pkg.license ?? undefined,
217 readme: pkg.readme ?? undefined,
218 };
219}
220
221/**
222 * Tarball filename convention expected by npm clients: `<name>-<version>.tgz`.
223 * For scoped packages, the *scope* is part of the URL but NOT the filename.
224 */
225export function tarballFilename(
226 parsed: ParsedPackageName,
227 version: string
228): string {
229 return `${parsed.name}-${version}.tgz`;
230}
0231