Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

agent-marketplace.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

agent-marketplace.tsxBlame749 lines · 1 contributor
055ebc4Claude1/**
2 * Block K10 — Agent Marketplace.
3 *
4 * Publisher-curated directory of installable K-agents. Each listing is a
5 * thin pointer to an existing agent app (via `app_bots`) so installs reuse
6 * the mature K2 / Block H agent-identity flow.
7 *
8 * ROUTES
9 * GET /marketplace/agents public directory
10 * GET /marketplace/agents/:slug detail + install form (auth'd)
11 * POST /marketplace/agents/:slug/install auth; install into a repo owned by user
12 * POST /marketplace/agents/:slug/uninstall auth; uninstall
13 * GET /settings/agent-listings publisher dashboard
14 * POST /settings/agent-listings create listing (unpublished)
15 * POST /settings/agent-listings/:id/publish site-admin; publish
16 * POST /settings/agent-listings/:id/unpublish site-admin; unpublish
17 * GET /admin/marketplace/agents site-admin; all listings
18 *
19 * DATA
20 * marketplace_agent_listings (migration 0037). `sql\`...\`` raw queries
21 * here so this file works before the schema.ts edit lands — matches the
22 * `agents.tsx` defensive pattern.
23 */
24
25import { Hono } from "hono";
26import { and, eq, sql } from "drizzle-orm";
27import { db } from "../db";
28import { apps, appBots, appInstallations, repositories, users } from "../db/schema";
29import {
30 ensureAgentApp,
31 installAgentForRepo,
32 uninstallAgent,
33 AGENT_PERMISSIONS,
34} from "../lib/agent-identity";
35import { isSiteAdmin } from "../lib/admin";
36import { Layout } from "../views/layout";
37import { softAuth, requireAuth } from "../middleware/auth";
38import type { AuthEnv } from "../middleware/auth";
39
40// ---------------------------------------------------------------------------
41// Pure helpers (unit-testable)
42// ---------------------------------------------------------------------------
43
44export const ALLOWED_LISTING_KINDS = [
45 "triage",
46 "fix",
47 "review",
48 "heal_bot",
49 "deploy_watch",
50 "custom",
51] as const;
52
53export type ListingKind = (typeof ALLOWED_LISTING_KINDS)[number];
54
55export const SLUG_RE = /^[a-z][a-z0-9-]{2,48}$/;
56export const TAGLINE_MAX = 200;
57export const DESCRIPTION_MAX = 5000;
58export const PRICING_MAX_CENTS = 100_000; // $1000/mo hard cap
59
60export interface ListingFormInput {
61 slug?: string;
62 name?: string;
63 tagline?: string;
64 description?: string;
65 kind?: string;
66 homepage_url?: string;
67 icon_url?: string;
68 pricing_cents_per_month?: string;
69}
70
71export interface ParsedListing {
72 slug: string;
73 name: string;
74 tagline: string;
75 description: string;
76 kind: ListingKind;
77 homepageUrl: string | null;
78 iconUrl: string | null;
79 pricingCentsPerMonth: number;
80}
81
82export type ParseListingResult =
83 | { ok: true; data: ParsedListing }
84 | { ok: false; error: string };
85
86export function parseListingForm(input: ListingFormInput): ParseListingResult {
87 const slug = String(input.slug ?? "").trim();
88 if (!SLUG_RE.test(slug)) {
89 return {
90 ok: false,
91 error: "slug must match ^[a-z][a-z0-9-]{2,48}$",
92 };
93 }
94
95 const name = String(input.name ?? "").trim();
96 if (!name || name.length > 100) {
97 return { ok: false, error: "name is required and must be ≤100 chars" };
98 }
99
100 const tagline = String(input.tagline ?? "").trim();
101 if (!tagline) return { ok: false, error: "tagline is required" };
102 if (tagline.length > TAGLINE_MAX) {
103 return { ok: false, error: `tagline must be ≤${TAGLINE_MAX} chars` };
104 }
105
106 const description = String(input.description ?? "").slice(0, DESCRIPTION_MAX);
107
108 const kindRaw = String(input.kind ?? "").trim();
109 if (!(ALLOWED_LISTING_KINDS as readonly string[]).includes(kindRaw)) {
110 return {
111 ok: false,
112 error: `kind must be one of: ${ALLOWED_LISTING_KINDS.join(", ")}`,
113 };
114 }
115 const kind = kindRaw as ListingKind;
116
117 const homepageUrl = normaliseUrl(input.homepage_url);
118 const iconUrl = normaliseUrl(input.icon_url);
119
120 const pricingRaw = Number.parseInt(
121 String(input.pricing_cents_per_month ?? "0"),
122 10
123 );
124 if (!Number.isFinite(pricingRaw) || pricingRaw < 0) {
125 return { ok: false, error: "pricing must be a non-negative integer" };
126 }
127 const pricingCentsPerMonth = Math.min(pricingRaw, PRICING_MAX_CENTS);
128
129 return {
130 ok: true,
131 data: {
132 slug,
133 name,
134 tagline,
135 description,
136 kind,
137 homepageUrl,
138 iconUrl,
139 pricingCentsPerMonth,
140 },
141 };
142}
143
144function normaliseUrl(raw: unknown): string | null {
145 if (typeof raw !== "string") return null;
146 const v = raw.trim();
147 if (!v) return null;
148 if (!/^https?:\/\//i.test(v)) return null;
149 if (v.length > 500) return null;
150 return v;
151}
152
153// ---------------------------------------------------------------------------
154// Raw-SQL DB helpers (work before schema.ts gets the Drizzle table added)
155// ---------------------------------------------------------------------------
156
157interface ListingRow {
158 id: string;
159 slug: string;
160 name: string;
161 tagline: string;
162 description: string;
163 publisherUserId: string | null;
164 appBotId: string;
165 kind: string;
166 homepageUrl: string | null;
167 iconUrl: string | null;
168 pricingCentsPerMonth: number;
169 published: boolean;
170 installCount: number;
171 createdAt: Date;
172}
173
174function coerceRow(r: Record<string, unknown>): ListingRow {
175 return {
176 id: String(r.id),
177 slug: String(r.slug),
178 name: String(r.name),
179 tagline: String(r.tagline),
180 description: String(r.description ?? ""),
181 publisherUserId: (r.publisher_user_id as string | null) ?? null,
182 appBotId: String(r.app_bot_id),
183 kind: String(r.kind),
184 homepageUrl: (r.homepage_url as string | null) ?? null,
185 iconUrl: (r.icon_url as string | null) ?? null,
186 pricingCentsPerMonth: Number(r.pricing_cents_per_month ?? 0),
187 published: !!r.published,
188 installCount: Number(r.install_count ?? 0),
189 createdAt: (r.created_at as Date) ?? new Date(0),
190 };
191}
192
193async function listPublishedListings(params: {
194 kind?: string;
195 q?: string;
196 limit?: number;
197}): Promise<ListingRow[]> {
198 try {
199 const limit = Math.max(1, Math.min(params.limit ?? 50, 200));
200 const kindFilter = params.kind
201 ? sql`AND kind = ${params.kind}`
202 : sql``;
203 const qFilter =
204 params.q && params.q.length >= 2
205 ? sql`AND (name ILIKE ${"%" + params.q + "%"} OR tagline ILIKE ${"%" + params.q + "%"})`
206 : sql``;
207 const rows = (await db.execute(sql`
208 SELECT * FROM marketplace_agent_listings
209 WHERE published = true
210 ${kindFilter}
211 ${qFilter}
212 ORDER BY install_count DESC, created_at DESC
213 LIMIT ${limit}
214 `)) as unknown as Array<Record<string, unknown>>;
215 return Array.isArray(rows) ? rows.map(coerceRow) : [];
216 } catch (err) {
217 console.error("[agent-marketplace] listPublishedListings:", err);
218 return [];
219 }
220}
221
222async function getListingBySlug(slug: string): Promise<ListingRow | null> {
223 try {
224 const rows = (await db.execute(sql`
225 SELECT * FROM marketplace_agent_listings WHERE slug = ${slug} LIMIT 1
226 `)) as unknown as Array<Record<string, unknown>>;
227 const row = Array.isArray(rows) ? rows[0] : undefined;
228 return row ? coerceRow(row) : null;
229 } catch (err) {
230 console.error("[agent-marketplace] getListingBySlug:", err);
231 return null;
232 }
233}
234
235async function getListingById(id: string): Promise<ListingRow | null> {
236 try {
237 const rows = (await db.execute(sql`
238 SELECT * FROM marketplace_agent_listings WHERE id = ${id} LIMIT 1
239 `)) as unknown as Array<Record<string, unknown>>;
240 const row = Array.isArray(rows) ? rows[0] : undefined;
241 return row ? coerceRow(row) : null;
242 } catch (err) {
243 console.error("[agent-marketplace] getListingById:", err);
244 return null;
245 }
246}
247
248async function listListingsForPublisher(
249 userId: string
250): Promise<ListingRow[]> {
251 try {
252 const rows = (await db.execute(sql`
253 SELECT * FROM marketplace_agent_listings
254 WHERE publisher_user_id = ${userId}
255 ORDER BY created_at DESC
256 LIMIT 100
257 `)) as unknown as Array<Record<string, unknown>>;
258 return Array.isArray(rows) ? rows.map(coerceRow) : [];
259 } catch (err) {
260 console.error("[agent-marketplace] listListingsForPublisher:", err);
261 return [];
262 }
263}
264
265async function listAllListings(): Promise<ListingRow[]> {
266 try {
267 const rows = (await db.execute(sql`
268 SELECT * FROM marketplace_agent_listings
269 ORDER BY published DESC, created_at DESC
270 LIMIT 500
271 `)) as unknown as Array<Record<string, unknown>>;
272 return Array.isArray(rows) ? rows.map(coerceRow) : [];
273 } catch (err) {
274 console.error("[agent-marketplace] listAllListings:", err);
275 return [];
276 }
277}
278
279async function insertListing(params: {
280 parsed: ParsedListing;
281 publisherUserId: string;
282 appBotId: string;
283}): Promise<ListingRow | null> {
284 try {
285 const rows = (await db.execute(sql`
286 INSERT INTO marketplace_agent_listings
287 (slug, name, tagline, description, publisher_user_id, app_bot_id,
288 kind, homepage_url, icon_url, pricing_cents_per_month, published)
289 VALUES (
290 ${params.parsed.slug},
291 ${params.parsed.name},
292 ${params.parsed.tagline},
293 ${params.parsed.description},
294 ${params.publisherUserId},
295 ${params.appBotId},
296 ${params.parsed.kind},
297 ${params.parsed.homepageUrl},
298 ${params.parsed.iconUrl},
299 ${params.parsed.pricingCentsPerMonth},
300 false
301 )
302 RETURNING *
303 `)) as unknown as Array<Record<string, unknown>>;
304 const row = Array.isArray(rows) ? rows[0] : undefined;
305 return row ? coerceRow(row) : null;
306 } catch (err) {
307 console.error("[agent-marketplace] insertListing:", err);
308 return null;
309 }
310}
311
312async function setPublished(id: string, published: boolean): Promise<boolean> {
313 try {
314 await db.execute(sql`
315 UPDATE marketplace_agent_listings
316 SET published = ${published}, updated_at = now()
317 WHERE id = ${id}
318 `);
319 return true;
320 } catch (err) {
321 console.error("[agent-marketplace] setPublished:", err);
322 return false;
323 }
324}
325
326async function bumpInstallCount(id: string, delta: 1 | -1): Promise<void> {
327 try {
328 await db.execute(sql`
329 UPDATE marketplace_agent_listings
330 SET install_count = GREATEST(0, install_count + ${delta}),
331 updated_at = now()
332 WHERE id = ${id}
333 `);
334 } catch (err) {
335 console.error("[agent-marketplace] bumpInstallCount:", err);
336 }
337}
338
339async function getBotAppSlug(appBotId: string): Promise<string | null> {
340 try {
341 const [row] = await db
342 .select({ slug: apps.slug })
343 .from(appBots)
344 .innerJoin(apps, eq(apps.id, appBots.appId))
345 .where(eq(appBots.id, appBotId))
346 .limit(1);
347 return row?.slug ?? null;
348 } catch (err) {
349 console.error("[agent-marketplace] getBotAppSlug:", err);
350 return null;
351 }
352}
353
354async function userOwnsRepo(
355 userId: string,
356 repoId: string
357): Promise<boolean> {
358 try {
359 const [row] = await db
360 .select({ id: repositories.id })
361 .from(repositories)
362 .where(
363 and(eq(repositories.id, repoId), eq(repositories.ownerId, userId))
364 )
365 .limit(1);
366 return !!row;
367 } catch {
368 return false;
369 }
370}
371
372async function listOwnedRepos(userId: string) {
373 try {
374 const rows = await db
375 .select({
376 id: repositories.id,
377 name: repositories.name,
378 ownerId: repositories.ownerId,
379 })
380 .from(repositories)
381 .where(eq(repositories.ownerId, userId))
382 .limit(100);
383 return rows;
384 } catch {
385 return [];
386 }
387}
388
389async function isInstalledForRepo(
390 appBotId: string,
391 repoId: string
392): Promise<boolean> {
393 try {
394 const rows = await db
395 .select({ id: appInstallations.id })
396 .from(appInstallations)
397 .innerJoin(appBots, eq(appBots.appId, appInstallations.appId))
398 .where(
399 and(
400 eq(appBots.id, appBotId),
401 eq(appInstallations.targetType, "repository"),
402 eq(appInstallations.targetId, repoId)
403 )
404 )
405 .limit(1);
406 return rows.length > 0;
407 } catch {
408 return false;
409 }
410}
411
412// ---------------------------------------------------------------------------
413// Router
414// ---------------------------------------------------------------------------
415
416const app = new Hono<{ Variables: AuthEnv }>();
417
418// Public directory
419app.get("/marketplace/agents", softAuth, async (c) => {
420 const kind = c.req.query("kind") || undefined;
421 const q = c.req.query("q") || undefined;
422 const listings = await listPublishedListings({ kind, q });
423 return c.html(
424 <Layout title="Agent Marketplace" user={c.get("user") ?? null}>
425 <div class="page-wrap">
426 <h1>Agent Marketplace</h1>
427 <p class="muted">
428 Installable autonomous agents — triage, fix, review, heal, watch.
429 </p>
430 <form method="get" class="search-form" style="margin: 16px 0">
431 <input
432 type="search"
433 name="q"
434 value={q ?? ""}
435 placeholder="Search listings…"
436 class="input"
437 />
438 <select name="kind" class="input">
439 <option value="">All kinds</option>
440 {ALLOWED_LISTING_KINDS.map((k) => (
441 <option value={k} selected={kind === k}>
442 {k}
443 </option>
444 ))}
445 </select>
446 <button type="submit" class="btn">Search</button>
447 </form>
448 {listings.length === 0 ? (
449 <div class="empty-state">
450 <p>No published agents yet.</p>
451 </div>
452 ) : (
453 <div class="card-grid">
454 {listings.map((l) => (
455 <a href={`/marketplace/agents/${l.slug}`} class="card">
456 <h3>{l.name}</h3>
457 <p class="muted">{l.tagline}</p>
458 <div class="pill-row">
459 <span class="pill">{l.kind}</span>
460 <span class="pill">{l.installCount} installs</span>
461 {l.pricingCentsPerMonth > 0 && (
462 <span class="pill">${(l.pricingCentsPerMonth / 100).toFixed(2)}/mo</span>
463 )}
464 </div>
465 </a>
466 ))}
467 </div>
468 )}
469 </div>
470 </Layout>
471 );
472});
473
474// Detail
475app.get("/marketplace/agents/:slug", softAuth, async (c) => {
476 const slug = c.req.param("slug");
477 const listing = await getListingBySlug(slug);
478 if (!listing || !listing.published) {
479 return c.notFound();
480 }
481 const user = c.get("user") ?? null;
482 const repos = user ? await listOwnedRepos(user.id) : [];
483 return c.html(
484 <Layout title={listing.name} user={user}>
485 <div class="page-wrap">
486 <h1>{listing.name}</h1>
487 <p class="muted">{listing.tagline}</p>
488 <div class="pill-row">
489 <span class="pill">{listing.kind}</span>
490 <span class="pill">{listing.installCount} installs</span>
491 {listing.pricingCentsPerMonth > 0 && (
492 <span class="pill">${(listing.pricingCentsPerMonth / 100).toFixed(2)}/mo</span>
493 )}
494 {listing.homepageUrl && (
495 <a class="pill" href={listing.homepageUrl} rel="noreferrer nofollow">
496 Homepage ↗
497 </a>
498 )}
499 </div>
500 {listing.description && (
501 <pre class="listing-description">{listing.description}</pre>
502 )}
503 {user ? (
504 repos.length === 0 ? (
505 <p class="muted">You have no repositories to install into.</p>
506 ) : (
507 <form
508 method="post"
509 action={`/marketplace/agents/${listing.slug}/install`}
510 class="install-form"
511 >
512 <label>
513 Install into repo:
514 <select name="repo_id" class="input" required>
515 {repos.map((r) => (
516 <option value={r.id}>{r.name}</option>
517 ))}
518 </select>
519 </label>
520 <button type="submit" class="btn btn-primary">
521 Install
522 </button>
523 </form>
524 )
525 ) : (
526 <p>
527 <a href={`/login?next=/marketplace/agents/${listing.slug}`}>
528 Log in
529 </a>{" "}
530 to install.
531 </p>
532 )}
533 </div>
534 </Layout>
535 );
536});
537
538// Install
539app.post("/marketplace/agents/:slug/install", requireAuth, async (c) => {
540 const user = c.get("user")!;
541 const slug = c.req.param("slug");
542 const listing = await getListingBySlug(slug);
543 if (!listing || !listing.published) {
544 return c.text("listing not found", 404);
545 }
546 const body = await c.req.parseBody();
547 const repoId = String(body.repo_id ?? "");
548 if (!repoId) return c.text("repo_id required", 400);
549 if (!(await userOwnsRepo(user.id, repoId))) {
550 return c.text("forbidden", 403);
551 }
552 const appSlug = await getBotAppSlug(listing.appBotId);
553 if (!appSlug) return c.text("listing references missing app", 500);
554
555 const install = await installAgentForRepo(
556 appSlug,
557 repoId,
558 user.id,
559 AGENT_PERMISSIONS
560 );
561 if (!install) return c.text("install failed", 500);
562 await bumpInstallCount(listing.id, 1);
563 return c.redirect(`/marketplace/agents/${slug}`);
564});
565
566// Uninstall
567app.post("/marketplace/agents/:slug/uninstall", requireAuth, async (c) => {
568 const user = c.get("user")!;
569 const slug = c.req.param("slug");
570 const listing = await getListingBySlug(slug);
571 if (!listing) return c.text("listing not found", 404);
572 const body = await c.req.parseBody();
573 const repoId = String(body.repo_id ?? "");
574 if (!repoId) return c.text("repo_id required", 400);
575 if (!(await userOwnsRepo(user.id, repoId))) {
576 return c.text("forbidden", 403);
577 }
578 const appSlug = await getBotAppSlug(listing.appBotId);
579 if (!appSlug) return c.text("listing references missing app", 500);
580 const ok = await uninstallAgent(appSlug, repoId);
581 if (ok) await bumpInstallCount(listing.id, -1);
582 return c.redirect(`/marketplace/agents/${slug}`);
583});
584
585// Publisher dashboard
586app.get("/settings/agent-listings", requireAuth, async (c) => {
587 const user = c.get("user")!;
588 const mine = await listListingsForPublisher(user.id);
589 return c.html(
590 <Layout title="My Agent Listings" user={user}>
591 <div class="page-wrap">
592 <h1>My Agent Listings</h1>
593 <section style="margin-bottom: 24px">
594 <h2>Create new listing</h2>
595 <form method="post" action="/settings/agent-listings" class="create-form">
596 <label>Slug<input name="slug" required class="input" placeholder="my-agent" /></label>
597 <label>Name<input name="name" required class="input" /></label>
598 <label>Tagline<input name="tagline" required class="input" maxLength={TAGLINE_MAX} /></label>
599 <label>Kind
600 <select name="kind" class="input" required>
601 {ALLOWED_LISTING_KINDS.map((k) => (
602 <option value={k}>{k}</option>
603 ))}
604 </select>
605 </label>
606 <label>Description<textarea name="description" class="input" rows={4} maxLength={DESCRIPTION_MAX} /></label>
607 <label>Homepage URL<input name="homepage_url" class="input" placeholder="https://…" /></label>
608 <label>Icon URL<input name="icon_url" class="input" placeholder="https://…" /></label>
609 <label>Pricing (cents / month)<input name="pricing_cents_per_month" type="number" min="0" value="0" class="input" /></label>
610 <button type="submit" class="btn btn-primary">Create</button>
611 </form>
612 </section>
613 <section>
614 <h2>Existing listings</h2>
615 {mine.length === 0 ? (
616 <p class="muted">You haven't published any agents yet.</p>
617 ) : (
618 <table class="table">
619 <thead><tr><th>Slug</th><th>Name</th><th>Kind</th><th>Installs</th><th>Published</th></tr></thead>
620 <tbody>
621 {mine.map((l) => (
622 <tr>
623 <td><code>{l.slug}</code></td>
624 <td>{l.name}</td>
625 <td>{l.kind}</td>
626 <td>{l.installCount}</td>
627 <td>{l.published ? "yes" : "no — awaiting review"}</td>
628 </tr>
629 ))}
630 </tbody>
631 </table>
632 )}
633 </section>
634 </div>
635 </Layout>
636 );
637});
638
639app.post("/settings/agent-listings", requireAuth, async (c) => {
640 const user = c.get("user")!;
641 const body = (await c.req.parseBody()) as ListingFormInput;
642 const parsed = parseListingForm(body);
643 if (!parsed.ok) return c.text(parsed.error, 400);
644
645 // Ensure the agent app for this listing exists (idempotent).
646 const appRow = await ensureAgentApp(
647 parsed.data.slug,
648 parsed.data.name,
649 AGENT_PERMISSIONS
650 );
651 if (!appRow) return c.text("could not bootstrap agent app", 500);
652
653 const [bot] = await db
654 .select({ id: appBots.id })
655 .from(appBots)
656 .where(eq(appBots.appId, appRow.id))
657 .limit(1);
658 if (!bot) return c.text("no bot row for app", 500);
659
660 const listing = await insertListing({
661 parsed: parsed.data,
662 publisherUserId: user.id,
663 appBotId: bot.id,
664 });
665 if (!listing) return c.text("listing insert failed (slug taken?)", 400);
666 return c.redirect("/settings/agent-listings");
667});
668
669app.post("/settings/agent-listings/:id/publish", requireAuth, async (c) => {
670 const user = c.get("user")!;
671 if (!(await isSiteAdmin(user.id))) {
672 return c.text("forbidden", 403);
673 }
674 const id = c.req.param("id");
675 const listing = await getListingById(id);
676 if (!listing) return c.text("not found", 404);
677 await setPublished(id, true);
678 return c.redirect("/admin/marketplace/agents");
679});
680
681app.post("/settings/agent-listings/:id/unpublish", requireAuth, async (c) => {
682 const user = c.get("user")!;
683 if (!(await isSiteAdmin(user.id))) {
684 return c.text("forbidden", 403);
685 }
686 const id = c.req.param("id");
687 const listing = await getListingById(id);
688 if (!listing) return c.text("not found", 404);
689 await setPublished(id, false);
690 return c.redirect("/admin/marketplace/agents");
691});
692
693app.get("/admin/marketplace/agents", requireAuth, async (c) => {
694 const user = c.get("user")!;
695 if (!(await isSiteAdmin(user.id))) {
696 return c.text("forbidden", 403);
697 }
698 const listings = await listAllListings();
699 return c.html(
700 <Layout title="Admin — Agent Marketplace" user={user}>
701 <div class="page-wrap">
702 <h1>Admin — Agent Marketplace</h1>
703 <table class="table">
704 <thead>
705 <tr>
706 <th>Slug</th>
707 <th>Name</th>
708 <th>Kind</th>
709 <th>Installs</th>
710 <th>Published</th>
711 <th>Actions</th>
712 </tr>
713 </thead>
714 <tbody>
715 {listings.map((l) => (
716 <tr>
717 <td><code>{l.slug}</code></td>
718 <td>{l.name}</td>
719 <td>{l.kind}</td>
720 <td>{l.installCount}</td>
721 <td>{l.published ? "yes" : "no"}</td>
722 <td>
723 <form
724 method="post"
725 action={`/settings/agent-listings/${l.id}/${l.published ? "unpublish" : "publish"}`}
726 style="display: inline"
727 >
728 <button type="submit" class="btn">
729 {l.published ? "Unpublish" : "Publish"}
730 </button>
731 </form>
732 </td>
733 </tr>
734 ))}
735 </tbody>
736 </table>
737 </div>
738 </Layout>
739 );
740});
741
742export default app;
743
744// Explicit named exports for tests / external wiring.
745export {
746 listPublishedListings as _listPublishedListings,
747 getListingBySlug as _getListingBySlug,
748 isInstalledForRepo as _isInstalledForRepo,
749};