Skip to content
418 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 { Hint } from "../../components/ui/hint";
14import { Loading } from "../../components/ui/skeleton";
15import { TabStrip } from "../../components/ui/tab-strip";
16import { page } from "../../lib/meta";
17import { ECOSYSTEM_LABEL, formatBytes, installCommands, shortDigest } from "../../lib/packages";
18import { packages } from "../../lib/services.server";
19import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server";
20
21function 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
26type Tab = "overview" | "settings";
27
28function tabOf(search: string): Tab {
29 return new URLSearchParams(search).get("tab") === "settings" ? "settings" : "overview";
30}
31
32export function meta({ params, ...args }: Route.MetaArgs) {
33 return page(args, { title: `${params["*"]} · Packages · ${params.owner} · g1t` });
34}
35
36export 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. */
56function 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
65function roleOf(form: FormData): PackageRole | null {
66 const role = String(form.get("role") ?? "");
67 return role === "read" || role === "write" || role === "admin" ? role : null;
68}
69
70export 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
149export 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
226function 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
320function 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. */
412function 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}