| 1 | import { BookOpen, Box, Trash2 } from "lucide-react"; |
| 2 | import { Link, data, redirect, useLocation, useNavigation } from "react-router"; |
| 3 | |
| 4 | import { ECOSYSTEMS, type Ecosystem, type PackageGrantee, type PackageRole, type PackageSettings, type PackageVersion, PACKAGE_RESTORE_DAYS } from "@g1t/contracts"; |
| 5 | |
| 6 | import type { Route } from "./+types/package"; |
| 7 | import { Markdown } from "../../components/markdown"; |
| 8 | import { PackageIcon } from "../../components/package-icon"; |
| 9 | import { PackageSettingsSkeleton, PackageSettingsTab, type SettingsOutcome } from "../../components/package-settings"; |
| 10 | import { ConfirmDialog } from "../../components/repo-lifecycle"; |
| 11 | import { CopyLine, ErrorText, TimeAgo } from "../../components/ui"; |
| 12 | import { Badge } from "../../components/ui/badge"; |
| 13 | import { Hint } from "../../components/ui/hint"; |
| 14 | import { Loading } from "../../components/ui/skeleton"; |
| 15 | import { TabStrip } from "../../components/ui/tab-strip"; |
| 16 | import { page } from "../../lib/meta"; |
| 17 | import { ECOSYSTEM_LABEL, formatBytes, installCommands, shortDigest } from "../../lib/packages"; |
| 18 | import { packages } from "../../lib/services.server"; |
| 19 | import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server"; |
| 20 | |
| 21 | function ecosystemOf(value: string | undefined): Ecosystem { |
| 22 | if (value && (ECOSYSTEMS as readonly string[]).includes(value)) return value as Ecosystem; |
| 23 | throw data(null, { status: 404 }); |
| 24 | } |
| 25 | |
| 26 | type Tab = "overview" | "settings"; |
| 27 | |
| 28 | function tabOf(search: string): Tab { |
| 29 | return new URLSearchParams(search).get("tab") === "settings" ? "settings" : "overview"; |
| 30 | } |
| 31 | |
| 32 | export function meta({ params, ...args }: Route.MetaArgs) { |
| 33 | return page(args, { title: `${params["*"]} · Packages · ${params.owner} · g1t` }); |
| 34 | } |
| 35 | |
| 36 | export async function loader({ params, context, request }: Route.LoaderArgs) { |
| 37 | const viewer = getViewer(context); |
| 38 | const ecosystem = ecosystemOf(params.ecosystem); |
| 39 | const name = params["*"] ?? ""; |
| 40 | const tab = tabOf(new URL(request.url).search); |
| 41 | const found = await packages.get(params.owner, ecosystem, name, viewer); |
| 42 | // Not found and not allowed look the same. |
| 43 | if (!found.ok) throw data(null, { status: 404 }); |
| 44 | let settings: PackageSettings | null = null; |
| 45 | if (tab === "settings") { |
| 46 | // Only the package's admins have a Settings tab. |
| 47 | if (!found.value.permissions.admin) throw redirect(`/${params.owner}/-/packages/${ecosystem}/${name}`); |
| 48 | const got = await packages.settings(params.owner, ecosystem, name, viewer); |
| 49 | if (!got.ok) throw data(null, { status: 404 }); |
| 50 | settings = got.value; |
| 51 | } |
| 52 | return { detail: found.value, username: viewer?.username ?? "you", tab, settings }; |
| 53 | } |
| 54 | |
| 55 | /** Who an access change names: a person by username, or a team by slug. */ |
| 56 | function grantee(form: FormData): PackageGrantee | null { |
| 57 | const kind = String(form.get("kind") ?? ""); |
| 58 | const user = String(form.get("user") ?? (kind === "user" ? form.get("who") : "") ?? "").trim(); |
| 59 | const team = String(form.get("team") ?? (kind === "team" ? form.get("who") : "") ?? "").trim(); |
| 60 | if (user) return { user }; |
| 61 | if (team) return { team }; |
| 62 | return null; |
| 63 | } |
| 64 | |
| 65 | function roleOf(form: FormData): PackageRole | null { |
| 66 | const role = String(form.get("role") ?? ""); |
| 67 | return role === "read" || role === "write" || role === "admin" ? role : null; |
| 68 | } |
| 69 | |
| 70 | export async function action({ request, params, context }: Route.ActionArgs): Promise<SettingsOutcome | Response> { |
| 71 | assertSameOrigin(request); |
| 72 | const user = requireUser(context, request); |
| 73 | const ecosystem = ecosystemOf(params.ecosystem); |
| 74 | const name = params["*"] ?? ""; |
| 75 | const ws = params.owner; |
| 76 | const form = await request.formData(); |
| 77 | const intent = String(form.get("intent") ?? ""); |
| 78 | const fail = (message: string): SettingsOutcome => ({ error: message, message: null }); |
| 79 | const done = (result: { ok: true } | { ok: false; error: { message: string } }, message: string): SettingsOutcome => |
| 80 | result.ok ? { error: null, message } : fail(result.error.message); |
| 81 | switch (intent) { |
| 82 | case "delete-version": { |
| 83 | const version = String(form.get("version") ?? ""); |
| 84 | return done( |
| 85 | await packages.deleteVersion(user, ws, ecosystem, name, version, "web"), |
| 86 | `Deleted ${shortDigest(version)} and its tags. An admin can restore it from Settings for ${PACKAGE_RESTORE_DAYS} days.`, |
| 87 | ); |
| 88 | } |
| 89 | case "delete-package": { |
| 90 | const deleted = await packages.deletePackage(user, ws, ecosystem, name, "web"); |
| 91 | if (!deleted.ok) return fail(deleted.error.message); |
| 92 | return redirect(`/${ws}/-/packages?view=deleted`); |
| 93 | } |
| 94 | case "restore-version": { |
| 95 | const version = String(form.get("version") ?? ""); |
| 96 | const restored = await packages.restoreVersion(user, ws, ecosystem, name, version, "web"); |
| 97 | return done(restored, restored.ok ? `Restored ${shortDigest(restored.value.version)}.` : ""); |
| 98 | } |
| 99 | case "visibility": { |
| 100 | const visibility = form.get("visibility") === "public" ? "public" : "private"; |
| 101 | return done(await packages.set(user, ws, ecosystem, name, { visibility }, "web"), `It is ${visibility} now.`); |
| 102 | } |
| 103 | case "link": { |
| 104 | const repo = String(form.get("repo") ?? "").trim(); |
| 105 | if (!repo) return fail("Name a repository of the workspace to link it to."); |
| 106 | return done(await packages.set(user, ws, ecosystem, name, { link: repo }, "web"), `Linked to ${repo}.`); |
| 107 | } |
| 108 | case "unlink": |
| 109 | return done(await packages.set(user, ws, ecosystem, name, { unlink: true }, "web"), "Unlinked: it is the workspace's now."); |
| 110 | case "inherit": { |
| 111 | const on = form.get("inherit") === "on"; |
| 112 | return done( |
| 113 | await packages.set(user, ws, ecosystem, name, { inheritAccess: on }, "web"), |
| 114 | on ? "It inherits access from its repository again." : "It no longer inherits access from its repository.", |
| 115 | ); |
| 116 | } |
| 117 | case "access-add": |
| 118 | case "access-role": { |
| 119 | const who = grantee(form); |
| 120 | const role = roleOf(form); |
| 121 | if (!who) return fail("Name a person by username, or a team by its slug."); |
| 122 | if (!role) return fail("Choose a role: read, write or admin."); |
| 123 | const label = "user" in who ? who.user : `the team ${who.team}`; |
| 124 | return done(await packages.setAccess(user, ws, ecosystem, name, who, role, "web"), `${label} has the ${role} role.`); |
| 125 | } |
| 126 | case "access-remove": { |
| 127 | const who = grantee(form); |
| 128 | if (!who) return fail("Name a person by username, or a team by its slug."); |
| 129 | return done(await packages.removeAccess(user, ws, ecosystem, name, who, "web"), "Removed."); |
| 130 | } |
| 131 | case "actions-add": |
| 132 | case "actions-role": { |
| 133 | const repo = String(form.get("repo") ?? "").trim(); |
| 134 | const role = form.get("role") === "write" ? "write" : "read"; |
| 135 | if (!repo) return fail("Name a repository of the workspace."); |
| 136 | return done( |
| 137 | await packages.setActionsAccess(user, ws, ecosystem, name, repo, role, "web"), |
| 138 | `Workflows in ${repo} may ${role === "write" ? "publish" : "pull"} it.`, |
| 139 | ); |
| 140 | } |
| 141 | case "actions-remove": { |
| 142 | const repo = String(form.get("repo") ?? "").trim(); |
| 143 | return done(await packages.removeActionsAccess(user, ws, ecosystem, name, repo, "web"), `Workflows in ${repo} can no longer use it.`); |
| 144 | } |
| 145 | } |
| 146 | return fail("That is not something this page does."); |
| 147 | } |
| 148 | |
| 149 | export default function PackagePage({ loaderData, actionData }: Route.ComponentProps) { |
| 150 | const { detail, settings } = loaderData; |
| 151 | const { package: pkg, permissions } = detail; |
| 152 | const outcome = actionData as SettingsOutcome | undefined; |
| 153 | const navigation = useNavigation(); |
| 154 | const { pathname } = useLocation(); |
| 155 | // The tab being opened shows at once, its outline until its content |
| 156 | // follows; a form posted on the page keeps the page as it is meanwhile. |
| 157 | const switching = navigation.state === "loading" && !navigation.formMethod && navigation.location.pathname === pathname; |
| 158 | const going = switching && tabOf(navigation.location.search) !== loaderData.tab ? tabOf(navigation.location.search) : null; |
| 159 | const tab = going ?? loaderData.tab; |
| 160 | const base = `/${pkg.workspace}/-/packages/${pkg.ecosystem}/${pkg.name}`; |
| 161 | return ( |
| 162 | <div className="space-y-6"> |
| 163 | <header className="space-y-3"> |
| 164 | <Link to={`/${pkg.workspace}/-/packages`} className="text-sm text-muted hover:text-fg"> |
| 165 | Packages |
| 166 | </Link> |
| 167 | <div className="flex flex-wrap items-center gap-3"> |
| 168 | <PackageIcon ecosystem={pkg.ecosystem} size={32} /> |
| 169 | <h1 className="min-w-0 text-2xl font-semibold tracking-tight break-all">{pkg.name}</h1> |
| 170 | <Badge>{ECOSYSTEM_LABEL[pkg.ecosystem]}</Badge> |
| 171 | <Badge>{pkg.visibility === "private" ? "Private" : "Public"}</Badge> |
| 172 | </div> |
| 173 | <p className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted tabular-nums"> |
| 174 | {pkg.repo && ( |
| 175 | <Link to={`/${pkg.repo.namespace}/${pkg.repo.name}`} className="inline-flex items-center gap-1 hover:text-fg"> |
| 176 | <Box size={13} /> |
| 177 | {pkg.repo.namespace}/{pkg.repo.name} |
| 178 | </Link> |
| 179 | )} |
| 180 | <span>{formatBytes(pkg.size)}</span> |
| 181 | <span> |
| 182 | {pkg.downloads.toLocaleString("en-US")}{" "} |
| 183 | {pkg.ecosystem !== "container" ? (pkg.downloads === 1 ? "download" : "downloads") : pkg.downloads === 1 ? "pull" : "pulls"} |
| 184 | </span> |
| 185 | <span> |
| 186 | Updated <TimeAgo at={pkg.updated_at} /> |
| 187 | </span> |
| 188 | </p> |
| 189 | {pkg.description && <p className="max-w-2xl text-sm text-fg-soft">{pkg.description}</p>} |
| 190 | </header> |
| 191 | |
| 192 | {permissions.admin && ( |
| 193 | <TabStrip label="Package" className="gap-1 border-b border-line"> |
| 194 | {(["overview", "settings"] as const).map((name) => ( |
| 195 | <Link |
| 196 | key={name} |
| 197 | to={name === "overview" ? base : `${base}?tab=settings`} |
| 198 | preventScrollReset |
| 199 | aria-current={tab === name ? "page" : undefined} |
| 200 | data-active={tab === name || undefined} |
| 201 | className={`-mb-px border-b-2 px-3 pt-1 pb-2.5 text-sm transition-colors ${ |
| 202 | tab === name ? "border-accent font-medium text-fg" : "border-transparent text-muted hover:text-fg" |
| 203 | }`} |
| 204 | > |
| 205 | {name === "overview" ? "Overview" : "Settings"} |
| 206 | </Link> |
| 207 | ))} |
| 208 | </TabStrip> |
| 209 | )} |
| 210 | |
| 211 | {tab === "settings" ? ( |
| 212 | settings && !going ? ( |
| 213 | <PackageSettingsTab settings={settings} outcome={outcome} /> |
| 214 | ) : ( |
| 215 | <Loading label="Loading the package's settings…"> |
| 216 | <PackageSettingsSkeleton /> |
| 217 | </Loading> |
| 218 | ) |
| 219 | ) : ( |
| 220 | <Overview detail={detail} username={loaderData.username} outcome={going ? undefined : outcome} /> |
| 221 | )} |
| 222 | </div> |
| 223 | ); |
| 224 | } |
| 225 | |
| 226 | function Overview({ |
| 227 | detail, |
| 228 | username, |
| 229 | outcome, |
| 230 | }: { |
| 231 | detail: Route.ComponentProps["loaderData"]["detail"]; |
| 232 | username: string; |
| 233 | outcome: SettingsOutcome | undefined; |
| 234 | }) { |
| 235 | const { package: pkg, versions, tags, permissions } = detail; |
| 236 | // Maven names a version to fetch; the others install their newest without one. |
| 237 | const latest = tags.find((tag) => tag.tag === "latest")?.tag ?? tags[0]?.tag ?? (pkg.ecosystem === "maven" ? pkg.latest : null); |
| 238 | const commands = installCommands(pkg, latest, username); |
| 239 | // npm and Composer versions are numbers; images are digests and tags. |
| 240 | const npm = pkg.ecosystem !== "container"; |
| 241 | // Composer's versions are the repository's tags and branches: they |
| 242 | // change in git, not here. |
| 243 | const fromGit = pkg.ecosystem === "composer"; |
| 244 | // Signatures and attestations hang off the images they describe. |
| 245 | const images = versions.filter((version) => !version.subject); |
| 246 | const attached = (digest: string) => versions.filter((version) => version.subject === digest); |
| 247 | return ( |
| 248 | <div className="space-y-8"> |
| 249 | {outcome?.error && <ErrorText>{outcome.error}</ErrorText>} |
| 250 | {outcome?.message && <p className="text-sm text-success">{outcome.message}</p>} |
| 251 | |
| 252 | <section className="space-y-2"> |
| 253 | <h2 className="text-sm font-semibold">{npm ? "Install it" : "Pull it"}</h2> |
| 254 | {commands.registry && <CopyLine prompt text={commands.registry} />} |
| 255 | {pkg.visibility === "private" && <CopyLine prompt text={commands.login} />} |
| 256 | <CopyLine prompt text={commands.install} /> |
| 257 | {commands.registry && pkg.visibility === "private" && pkg.ecosystem === "cargo" && ( |
| 258 | <p className="text-xs text-faint"> |
| 259 | <code className="font-mono">cargo login</code> asks for an{" "} |
| 260 | <Link to="/settings/tokens" className="text-muted hover:text-fg"> |
| 261 | access token |
| 262 | </Link>{" "} |
| 263 | with <code className="font-mono">packages:read</code>. |
| 264 | </p> |
| 265 | )} |
| 266 | {pkg.visibility === "private" && commands.login.includes("YOUR_TOKEN") && ( |
| 267 | <p className="text-xs text-faint"> |
| 268 | Put an{" "} |
| 269 | <Link to="/settings/tokens" className="text-muted hover:text-fg"> |
| 270 | access token |
| 271 | </Link>{" "} |
| 272 | with <code className="font-mono">packages:read</code> in place of YOUR_TOKEN. |
| 273 | </p> |
| 274 | )} |
| 275 | </section> |
| 276 | |
| 277 | {detail.readme && ( |
| 278 | <section className="overflow-hidden rounded-xl border border-line"> |
| 279 | <h2 className="flex items-center gap-2 border-b border-line bg-surface px-4 py-2.5 text-sm font-medium"> |
| 280 | <BookOpen size={15} className="text-faint" /> |
| 281 | README |
| 282 | </h2> |
| 283 | <div className="p-6"> |
| 284 | <Markdown source={detail.readme} repo={pkg.repo ? { namespace: pkg.repo.namespace, name: pkg.repo.name } : undefined} /> |
| 285 | </div> |
| 286 | </section> |
| 287 | )} |
| 288 | |
| 289 | <section className="space-y-3"> |
| 290 | <h2 className="text-sm font-semibold"> |
| 291 | Versions <span className="font-normal text-faint">{images.length}</span> |
| 292 | </h2> |
| 293 | {fromGit && ( |
| 294 | <p className="text-xs text-faint"> |
| 295 | Each tag of {pkg.repo ? `${pkg.repo.namespace}/${pkg.repo.name}` : "its repository"} that reads as a version, and each branch as{" "} |
| 296 | <code className="font-mono">dev-</code>, from its composer.json. Push a tag to publish one; delete it to take it away. |
| 297 | </p> |
| 298 | )} |
| 299 | {images.length === 0 ? ( |
| 300 | <p className="text-sm text-muted">No versions are left.</p> |
| 301 | ) : ( |
| 302 | <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface"> |
| 303 | {images.map((version) => ( |
| 304 | <VersionRow |
| 305 | key={version.id} |
| 306 | version={version} |
| 307 | attached={attached(version.digest)} |
| 308 | canDelete={permissions.delete && !fromGit} |
| 309 | npm={npm} |
| 310 | pulls={!npm} |
| 311 | /> |
| 312 | ))} |
| 313 | </ul> |
| 314 | )} |
| 315 | </section> |
| 316 | </div> |
| 317 | ); |
| 318 | } |
| 319 | |
| 320 | function VersionRow({ |
| 321 | version, |
| 322 | attached, |
| 323 | canDelete, |
| 324 | npm, |
| 325 | pulls, |
| 326 | }: { |
| 327 | version: PackageVersion; |
| 328 | attached: PackageVersion[]; |
| 329 | canDelete: boolean; |
| 330 | npm: boolean; |
| 331 | /** Whether its downloads are pulls, as an image's are. */ |
| 332 | pulls: boolean; |
| 333 | }) { |
| 334 | const downloads = version.downloads ?? 0; |
| 335 | return ( |
| 336 | <li className="flex flex-wrap items-start gap-x-4 gap-y-2 px-4 py-3"> |
| 337 | <div className="min-w-0 grow space-y-1"> |
| 338 | <div className="flex flex-wrap items-center gap-1.5"> |
| 339 | {npm && <span className="font-mono text-sm font-medium">{version.version}</span>} |
| 340 | {version.tags.length > 0 |
| 341 | ? version.tags.map((tag) => ( |
| 342 | <Badge key={tag} tone={tag === "latest" ? "accent" : "neutral"} className="font-mono"> |
| 343 | {tag} |
| 344 | </Badge> |
| 345 | )) |
| 346 | : !npm && <span className="text-xs text-faint">Untagged</span>} |
| 347 | {!npm && ( |
| 348 | <Hint label={version.digest}> |
| 349 | <code className="font-mono text-xs text-muted">{shortDigest(version.digest)}</code> |
| 350 | </Hint> |
| 351 | )} |
| 352 | {version.deprecated && ( |
| 353 | <Hint label={version.deprecated}> |
| 354 | <Badge tone="neutral">Deprecated</Badge> |
| 355 | </Hint> |
| 356 | )} |
| 357 | {version.symbols && ( |
| 358 | <Hint label="A symbol package (.snupkg) was pushed: debuggers load its PDBs from the feed's symbol server."> |
| 359 | <Badge tone="neutral">Symbols</Badge> |
| 360 | </Hint> |
| 361 | )} |
| 362 | </div> |
| 363 | {version.deprecated && <p className="text-xs text-muted">{version.deprecated}</p>} |
| 364 | <p className="flex flex-wrap gap-x-3 text-xs text-faint tabular-nums"> |
| 365 | <span>{formatBytes(version.size)}</span> |
| 366 | <span> |
| 367 | {downloads.toLocaleString("en-US")} {pulls ? (downloads === 1 ? "pull" : "pulls") : downloads === 1 ? "download" : "downloads"} |
| 368 | </span> |
| 369 | {version.platforms.length > 0 && <span>{version.platforms.join(", ")}</span>} |
| 370 | {attached.length > 0 && ( |
| 371 | <Hint label={attached.map((a) => a.artifact_type ?? a.media_type ?? "artifact").join(", ")}> |
| 372 | <span> |
| 373 | {attached.length} attached ({attached.map((a) => artifactWord(a)).join(", ")}) |
| 374 | </span> |
| 375 | </Hint> |
| 376 | )} |
| 377 | <span> |
| 378 | {version.published_by ? `${version.published_by} · ` : ""} |
| 379 | <TimeAgo at={version.published_at} /> |
| 380 | </span> |
| 381 | </p> |
| 382 | </div> |
| 383 | {canDelete && ( |
| 384 | <ConfirmDialog |
| 385 | intent="delete-version" |
| 386 | fields={{ version: npm ? version.version : version.digest }} |
| 387 | title={`Delete ${npm ? version.version : (version.tags[0] ?? shortDigest(version.digest))}?`} |
| 388 | description={ |
| 389 | npm |
| 390 | ? "Anyone installing this version gets an error from then on." |
| 391 | : "Anyone pulling it by this tag or digest gets an error from then on." |
| 392 | } |
| 393 | submit="Delete version" |
| 394 | busy="Deleting…" |
| 395 | trigger={(open) => ( |
| 396 | <Hint label="Delete this version"> |
| 397 | <button type="button" onClick={open} aria-label="Delete version" className="rounded-md p-1.5 text-faint hover:bg-raised hover:text-danger"> |
| 398 | <Trash2 size={14} /> |
| 399 | </button> |
| 400 | </Hint> |
| 401 | )} |
| 402 | > |
| 403 | <li>Its tags go with it.</li> |
| 404 | <li>An admin can restore it from the package's Settings for {PACKAGE_RESTORE_DAYS} days; until then its {npm ? "version" : "digest"} cannot be published again.</li> |
| 405 | </ConfirmDialog> |
| 406 | )} |
| 407 | </li> |
| 408 | ); |
| 409 | } |
| 410 | |
| 411 | /** "signature", "SBOM", "attestation", or what the artifact says it is. */ |
| 412 | function artifactWord(version: PackageVersion): string { |
| 413 | const type = version.artifact_type ?? version.media_type ?? ""; |
| 414 | if (/signature|cosign|notary/i.test(type)) return "signature"; |
| 415 | if (/spdx|cyclonedx|sbom/i.test(type)) return "SBOM"; |
| 416 | if (/in-toto|attestation|provenance/i.test(type)) return "attestation"; |
| 417 | return "artifact"; |
| 418 | } |