Skip to content

g1t/apps/web/app/routes/workspace/package.tsx

367 lines15,952 bytesCodeBlame
1import { BookOpen, Box, Trash2 } from "lucide-react";
2import { Form, Link, data, redirect } from "react-router";
3
4import { ECOSYSTEMS, type Ecosystem, type PackageVersion } from "@g1t/contracts";
5
6import type { Route } from "./+types/package";
7import { Markdown } from "../../components/markdown";
8import { PackageIcon } from "../../components/package-icon";
9import { ConfirmDialog } from "../../components/repo-lifecycle";
10import { Button, CopyLine, ErrorText, SubmitButton, TimeAgo } from "../../components/ui";
11import { Badge } from "../../components/ui/badge";
12import { Hint } from "../../components/ui/hint";
13import { page } from "../../lib/meta";
14import { ECOSYSTEM_LABEL, formatBytes, installCommands, shortDigest } from "../../lib/packages";
15import { packages } from "../../lib/services.server";
16import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server";
17
18function ecosystemOf(value: string | undefined): Ecosystem {
19 if (value && (ECOSYSTEMS as readonly string[]).includes(value)) return value as Ecosystem;
20 throw data(null, { status: 404 });
21}
22
23export function meta({ params, ...args }: Route.MetaArgs) {
24 return page(args, { title: `${params["*"]} · Packages · ${params.owner} · g1t` });
25}
26
27export async function loader({ params, context }: Route.LoaderArgs) {
28 const viewer = getViewer(context);
29 const ecosystem = ecosystemOf(params.ecosystem);
30 const name = params["*"] ?? "";
31 const found = await packages.get(params.owner, ecosystem, name, viewer);
32 // Not found and not allowed look the same.
33 if (!found.ok) throw data(null, { status: 404 });
34 return { detail: found.value, username: viewer?.username ?? "you" };
35}
36
37type Outcome = { error: string | null; message: string | null };
38
39export async function action({ request, params, context }: Route.ActionArgs): Promise<Outcome | Response> {
40 assertSameOrigin(request);
41 const user = requireUser(context, request);
42 const ecosystem = ecosystemOf(params.ecosystem);
43 const name = params["*"] ?? "";
44 const form = await request.formData();
45 const intent = String(form.get("intent") ?? "");
46 const fail = (message: string): Outcome => ({ error: message, message: null });
47 if (intent === "delete-version") {
48 const version = String(form.get("version") ?? "");
49 const done = await packages.deleteVersion(user, params.owner, ecosystem, name, version, "web");
50 return done.ok ? { error: null, message: `Deleted ${shortDigest(version)} and its tags.` } : fail(done.error.message);
51 }
52 if (intent === "delete-package") {
53 const done = await packages.deletePackage(user, params.owner, ecosystem, name, "web");
54 if (!done.ok) return fail(done.error.message);
55 return redirect(`/${params.owner}/-/packages`);
56 }
57 if (intent === "visibility") {
58 const visibility = form.get("visibility") === "public" ? "public" : "private";
59 const done = await packages.set(user, params.owner, ecosystem, name, { visibility }, "web");
60 return done.ok ? { error: null, message: `It is ${visibility} now.` } : fail(done.error.message);
61 }
62 if (intent === "link") {
63 const repo = String(form.get("repo") ?? "").trim();
64 const done = repo
65 ? await packages.set(user, params.owner, ecosystem, name, { link: repo }, "web")
66 : await packages.set(user, params.owner, ecosystem, name, { unlink: true }, "web");
67 return done.ok ? { error: null, message: repo ? `Linked to ${repo}.` : "Unlinked." } : fail(done.error.message);
68 }
69 return fail("That is not something this page does.");
70}
71
72export default function PackagePage({ loaderData, actionData }: Route.ComponentProps) {
73 const { detail, username } = loaderData;
74 const { package: pkg, versions, tags, permissions } = detail;
75 const outcome = actionData as Outcome | undefined;
76 // Maven names a version to fetch; the others install their newest without one.
77 const latest = tags.find((tag) => tag.tag === "latest")?.tag ?? tags[0]?.tag ?? (pkg.ecosystem === "maven" ? pkg.latest : null);
78 const commands = installCommands(pkg, latest, username);
79 // npm and Composer versions are numbers; images are digests and tags.
80 const npm = pkg.ecosystem !== "container";
81 // Composer's versions are the repository's tags and branches: they
82 // change in git, not here.
83 const fromGit = pkg.ecosystem === "composer";
84 // Signatures and attestations hang off the images they describe.
85 const images = versions.filter((version) => !version.subject);
86 const attached = (digest: string) => versions.filter((version) => version.subject === digest);
87 return (
88 <div className="space-y-8">
89 <header className="space-y-3">
90 <Link to={`/${pkg.workspace}/-/packages`} className="text-sm text-muted hover:text-fg">
91 Packages
92 </Link>
93 <div className="flex flex-wrap items-center gap-3">
94 <PackageIcon ecosystem={pkg.ecosystem} size={32} />
95 <h1 className="text-2xl font-semibold tracking-tight">{pkg.name}</h1>
96 <Badge>{ECOSYSTEM_LABEL[pkg.ecosystem]}</Badge>
97 <Badge>{pkg.visibility === "private" ? "Private" : "Public"}</Badge>
98 </div>
99 <p className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted tabular-nums">
100 {pkg.repo && (
101 <Link to={`/${pkg.repo.namespace}/${pkg.repo.name}`} className="inline-flex items-center gap-1 hover:text-fg">
102 <Box size={13} />
103 {pkg.repo.namespace}/{pkg.repo.name}
104 </Link>
105 )}
106 <span>{formatBytes(pkg.size)}</span>
107 <span>
108 {pkg.downloads.toLocaleString("en-US")}{" "}
109 {npm ? (pkg.downloads === 1 ? "download" : "downloads") : pkg.downloads === 1 ? "pull" : "pulls"}
110 </span>
111 <span>
112 Updated <TimeAgo at={pkg.updated_at} />
113 </span>
114 </p>
115 {pkg.description && <p className="max-w-2xl text-sm text-fg-soft">{pkg.description}</p>}
116 </header>
117
118 {outcome?.error && <ErrorText>{outcome.error}</ErrorText>}
119 {outcome?.message && <p className="text-sm text-success">{outcome.message}</p>}
120
121 <section className="space-y-2">
122 <h2 className="text-sm font-semibold">{npm ? "Install it" : "Pull it"}</h2>
123 {commands.registry && <CopyLine prompt text={commands.registry} />}
124 {pkg.visibility === "private" && <CopyLine prompt text={commands.login} />}
125 <CopyLine prompt text={commands.install} />
126 {commands.registry && pkg.visibility === "private" && pkg.ecosystem === "cargo" && (
127 <p className="text-xs text-faint">
128 <code className="font-mono">cargo login</code> asks for an{" "}
129 <Link to="/settings/tokens" className="text-muted hover:text-fg">
130 access token
131 </Link>{" "}
132 with <code className="font-mono">packages:read</code>.
133 </p>
134 )}
135 {pkg.visibility === "private" && commands.login.includes("YOUR_TOKEN") && (
136 <p className="text-xs text-faint">
137 Put an{" "}
138 <Link to="/settings/tokens" className="text-muted hover:text-fg">
139 access token
140 </Link>{" "}
141 with <code className="font-mono">packages:read</code> in place of YOUR_TOKEN.
142 </p>
143 )}
144 </section>
145
146 {detail.readme && (
147 <section className="overflow-hidden rounded-xl border border-line">
148 <h2 className="flex items-center gap-2 border-b border-line bg-surface px-4 py-2.5 text-sm font-medium">
149 <BookOpen size={15} className="text-faint" />
150 README
151 </h2>
152 <div className="p-6">
153 <Markdown
154 source={detail.readme}
155 repo={pkg.repo ? { namespace: pkg.repo.namespace, name: pkg.repo.name } : undefined}
156 />
157 </div>
158 </section>
159 )}
160
161 <section className="space-y-3">
162 <h2 className="text-sm font-semibold">
163 Versions <span className="font-normal text-faint">{images.length}</span>
164 </h2>
165 {fromGit && (
166 <p className="text-xs text-faint">
167 Each tag of {pkg.repo ? `${pkg.repo.namespace}/${pkg.repo.name}` : "its repository"} that reads as a version, and each
168 branch as <code className="font-mono">dev-</code>, from its composer.json. Push a tag to publish one; delete it to take it away.
169 </p>
170 )}
171 {images.length === 0 ? (
172 <p className="text-sm text-muted">No versions are left.</p>
173 ) : (
174 <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
175 {images.map((version) => (
176 <VersionRow
177 key={version.id}
178 version={version}
179 attached={attached(version.digest)}
180 canDelete={permissions.delete && !fromGit}
181 npm={npm}
182 />
183 ))}
184 </ul>
185 )}
186 </section>
187
188 {permissions.admin && <Settings detail={detail} />}
189 </div>
190 );
191}
192
193function VersionRow({
194 version,
195 attached,
196 canDelete,
197 npm,
198}: {
199 version: PackageVersion;
200 attached: PackageVersion[];
201 canDelete: boolean;
202 npm: boolean;
203}) {
204 return (
205 <li className="flex flex-wrap items-start gap-x-4 gap-y-2 px-4 py-3">
206 <div className="min-w-0 grow space-y-1">
207 <div className="flex flex-wrap items-center gap-1.5">
208 {npm && <span className="font-mono text-sm font-medium">{version.version}</span>}
209 {npm ? (
210 version.tags.map((tag) => (
211 <Badge key={tag} tone={tag === "latest" ? "accent" : "neutral"} className="font-mono">
212 {tag}
213 </Badge>
214 ))
215 ) : version.tags.length > 0 ? (
216 version.tags.map((tag) => (
217 <Badge key={tag} tone={tag === "latest" ? "accent" : "neutral"} className="font-mono">
218 {tag}
219 </Badge>
220 ))
221 ) : (
222 <span className="text-xs text-faint">Untagged</span>
223 )}
224 {!npm && (
225 <Hint label={version.digest}>
226 <code className="font-mono text-xs text-muted">{shortDigest(version.digest)}</code>
227 </Hint>
228 )}
229 {version.deprecated && (
230 <Hint label={version.deprecated}>
231 <Badge tone="neutral">Deprecated</Badge>
232 </Hint>
233 )}
234 {version.symbols && (
235 <Hint label="A symbol package (.snupkg) was pushed: debuggers load its PDBs from the feed's symbol server.">
236 <Badge tone="neutral">Symbols</Badge>
237 </Hint>
238 )}
239 </div>
240 {version.deprecated && <p className="text-xs text-muted">{version.deprecated}</p>}
241 <p className="flex flex-wrap gap-x-3 text-xs text-faint tabular-nums">
242 <span>{formatBytes(version.size)}</span>
243 {version.downloads != null && (
244 <span>
245 {version.downloads.toLocaleString("en-US")} {version.downloads === 1 ? "download" : "downloads"}
246 </span>
247 )}
248 {version.platforms.length > 0 && <span>{version.platforms.join(", ")}</span>}
249 {attached.length > 0 && (
250 <Hint label={attached.map((a) => a.artifact_type ?? a.media_type ?? "artifact").join(", ")}>
251 <span>
252 {attached.length} attached ({attached.map((a) => artifactWord(a)).join(", ")})
253 </span>
254 </Hint>
255 )}
256 <span>
257 {version.published_by ? `${version.published_by} · ` : ""}
258 <TimeAgo at={version.published_at} />
259 </span>
260 </p>
261 </div>
262 {canDelete && (
263 <ConfirmDialog
264 intent="delete-version"
265 fields={{ version: npm ? version.version : version.digest }}
266 title={`Delete ${npm ? version.version : (version.tags[0] ?? shortDigest(version.digest))}?`}
267 description={
268 npm
269 ? "Anyone installing this version gets an error from then on."
270 : "Anyone pulling it by this tag or digest gets an error from then on."
271 }
272 submit="Delete version"
273 busy="Deleting…"
274 trigger={(open) => (
275 <button type="button" onClick={open} aria-label="Delete version" className="rounded-md p-1.5 text-faint hover:bg-raised hover:text-danger">
276 <Trash2 size={14} />
277 </button>
278 )}
279 >
280 <li>Its tags go with it.</li>
281 <li>Files no other version uses are removed within a day.</li>
282 </ConfirmDialog>
283 )}
284 </li>
285 );
286}
287
288/** "signature", "SBOM", "attestation", or what the artifact says it is. */
289function artifactWord(version: PackageVersion): string {
290 const type = version.artifact_type ?? version.media_type ?? "";
291 if (/signature|cosign|notary/i.test(type)) return "signature";
292 if (/spdx|cyclonedx|sbom/i.test(type)) return "SBOM";
293 if (/in-toto|attestation|provenance/i.test(type)) return "attestation";
294 return "artifact";
295}
296
297function Settings({ detail }: { detail: Route.ComponentProps["loaderData"]["detail"] }) {
298 const { package: pkg } = detail;
299 return (
300 <section className="space-y-4 rounded-xl border border-line bg-surface p-5">
301 <h2 className="text-sm font-semibold">Settings</h2>
302 {pkg.repo ? (
303 <p className="text-sm text-muted">
304 Linked to{" "}
305 <Link to={`/${pkg.repo.namespace}/${pkg.repo.name}`} className="text-fg-soft hover:text-fg">
306 {pkg.repo.namespace}/{pkg.repo.name}
307 </Link>
308 : it has the repository's visibility and roles.
309 </p>
310 ) : (
311 <Form method="post" className="flex flex-wrap items-center gap-2 text-sm">
312 <input type="hidden" name="intent" value="visibility" />
313 <span className="text-muted">Visibility</span>
314 <select
315 name="visibility"
316 defaultValue={pkg.visibility}
317 className="h-8 rounded-md border border-line bg-bg px-2 text-sm"
318 aria-label="Visibility"
319 >
320 <option value="private">Private: workspace members</option>
321 <option value="public">Public: anyone can pull</option>
322 </select>
323 <SubmitButton variant="quiet" match={{ intent: "visibility" }} pending="Saving…">
324 Save
325 </SubmitButton>
326 </Form>
327 )}
328 <Form method="post" className="flex flex-wrap items-center gap-2 text-sm">
329 <input type="hidden" name="intent" value="link" />
330 <label className="text-muted" htmlFor="link-repo">
331 Repository
332 </label>
333 <input
334 id="link-repo"
335 name="repo"
336 defaultValue={pkg.repo?.name ?? ""}
337 placeholder="none"
338 className="h-8 w-56 rounded-md border border-line bg-bg px-2 font-mono text-sm placeholder:text-faint"
339 />
340 <SubmitButton variant="quiet" match={{ intent: "link" }} pending="Saving…">
341 {pkg.repo ? "Change link" : "Link"}
342 </SubmitButton>
343 <span className="text-xs text-faint">Empty to unlink. A linked package takes the repository's access.</span>
344 </Form>
345 {detail.permissions.delete && (
346 <div className="border-t border-line pt-4">
347 <ConfirmDialog
348 intent="delete-package"
349 title={`Delete ${pkg.name}?`}
350 description="Every version and tag goes, and anyone pulling it gets an error."
351 confirm={pkg.name}
352 submit="Delete package"
353 busy="Deleting…"
354 trigger={(open) => (
355 <Button type="button" variant="danger" onClick={open}>
356 Delete package
357 </Button>
358 )}
359 >
360 <li>{detail.versions.length} versions and their tags.</li>
361 <li>The name can be pushed again afterwards.</li>
362 </ConfirmDialog>
363 </div>
364 )}
365 </section>
366 );
367}