Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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