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

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