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