Skip to content
424 linesCodeBlameRaw
1import { BookOpen, Box, Trash2 } from "lucide-react";
2import { Link, data, redirect, useLocation, useNavigation } from "react-router";
3
4import { ECOSYSTEMS, type Ecosystem, type PackageGrantee, type PackageRole, type PackageSettings, type PackageVersion, PACKAGE_RESTORE_DAYS } from "@g1t/contracts";
5
6import type { Route } from "./+types/package";
7import { Markdown } from "../../components/markdown";
8import { PackageIcon } from "../../components/package-icon";
9import { PackageSettingsSkeleton, PackageSettingsTab, type SettingsOutcome } from "../../components/package-settings";
10import { ConfirmDialog } from "../../components/repo-lifecycle";
11import { CopyLine, ErrorText, TimeAgo } from "../../components/ui";
12import { Badge } from "../../components/ui/badge";
13import { Button } from "../../components/ui/button";
14import { Card } from "../../components/ui/card";
15import { Hint } from "../../components/ui/hint";
16import { Loading } from "../../components/ui/skeleton";
17import { TabStrip } from "../../components/ui/tab-strip";
18import { page } from "../../lib/meta";
19import { ECOSYSTEM_LABEL, formatBytes, installCommands, shortDigest } from "../../lib/packages";
20import { packages } from "../../lib/services.server";
21import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server";
22
23function 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
28type Tab = "overview" | "settings";
29
30function tabOf(search: string): Tab {
31 return new URLSearchParams(search).get("tab") === "settings" ? "settings" : "overview";
32}
33
34export function meta({ params, ...args }: Route.MetaArgs) {
35 return page(args, { title: `${params["*"]} · Packages · ${params.owner} · g1t` });
36}
37
38export 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. */
58function 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
67function roleOf(form: FormData): PackageRole | null {
68 const role = String(form.get("role") ?? "");
69 return role === "read" || role === "write" || role === "admin" ? role : null;
70}
71
72export 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
151export 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
228function 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
326function 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. */
418function 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}