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

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