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.
| 5f8508b | 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 | ||
| 8 | import type { Package, PackageVersion, PackageTag } from "../db/schema"; | |
| 9 | ||
| 10 | export 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 | */ | |
| 20 | export 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). */ | |
| 53 | export 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>". */ | |
| 61 | export 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 | */ | |
| 80 | export 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. */ | |
| 100 | export 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 | ||
| 25a91a6 | 110 | // 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(":"); | |
| 5f8508b | 114 | 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 | ||
| 131 | function 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 | */ | |
| 152 | export 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 | */ | |
| 224 | export function tarballFilename( | |
| 225 | parsed: ParsedPackageName, | |
| 226 | version: string | |
| 227 | ): string { | |
| 228 | return `${parsed.name}-${version}.tgz`; | |
| 229 | } |