From fd28289f40cc0e6c50ab7740418dfa5493aa7482 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Tue, 14 Jul 2026 19:05:13 +0200 Subject: [PATCH] ci(workshop): add combined deployment image --- .env.example | 4 +- .github/act/workshop.md | 19 ++ .github/workflows/deployment-docs.yml | 56 ------ .github/workflows/deployment-workshop.yml | 47 +++++ .../management/{store => workshop}/index.mdx | 12 +- apps/docs/docusaurus.config.ts | 5 +- .../{store => workshop}/DetailComments.tsx | 12 +- .../{store => workshop}/DetailPage.tsx | 28 +-- .../{store => workshop}/DetailSections.tsx | 0 .../{store => workshop}/SubmitForm.tsx | 6 +- .../StoreApp.tsx => workshop/WorkshopApp.tsx} | 68 +++---- .../components/{store => workshop}/format.ts | 0 .../useStore.ts => workshop/useWorkshop.ts} | 42 ++--- .../workshop-utils.ts} | 4 +- apps/docs/src/lib/pocketbase.ts | 6 +- ...schema.spec.ts => workshop-schema.spec.ts} | 2 +- .../{store-schema.ts => workshop-schema.ts} | 10 +- apps/docs/src/pages/workshop/index.tsx | 8 +- .../boards/[name]/settings/_customCss.tsx | 4 +- .../[locale]/manage/custom-widgets/page.tsx | 4 +- .../install-from-workshop-buttons.tsx} | 16 +- .../workshop-api.ts} | 16 +- .../workshop-browser-modal.tsx} | 18 +- apps/store/README.md | 50 ----- apps/store/docker-compose.yml | 30 --- .../pb_migrations/1750000000_init_store.js | 138 -------------- apps/{store => workshop}/.gitignore | 0 apps/workshop/Dockerfile | 54 ++++++ apps/workshop/Dockerfile.dockerignore | 15 ++ apps/workshop/README.md | 80 ++++++++ apps/workshop/docker-compose.yml | 28 +++ .../pb_hooks/workshop.pb.js} | 0 .../pb_migrations/1750000000_init_workshop.js | 172 ++++++++++++++++++ .../1750000002_marketplace_view.js | 9 + apps/workshop/scripts/smoke-test.sh | 30 +++ package.json | 4 +- pnpm-lock.yaml | 49 +++-- pnpm-workspace.yaml | 4 +- scripts/ci/workshop-act.sh | 22 +++ 39 files changed, 636 insertions(+), 436 deletions(-) create mode 100644 .github/act/workshop.md delete mode 100644 .github/workflows/deployment-docs.yml create mode 100644 .github/workflows/deployment-workshop.yml rename apps/docs/docs/management/{store => workshop}/index.mdx (78%) rename apps/docs/src/components/{store => workshop}/DetailComments.tsx (93%) rename apps/docs/src/components/{store => workshop}/DetailPage.tsx (92%) rename apps/docs/src/components/{store => workshop}/DetailSections.tsx (100%) rename apps/docs/src/components/{store => workshop}/SubmitForm.tsx (98%) rename apps/docs/src/components/{store/StoreApp.tsx => workshop/WorkshopApp.tsx} (90%) rename apps/docs/src/components/{store => workshop}/format.ts (100%) rename apps/docs/src/components/{store/useStore.ts => workshop/useWorkshop.ts} (86%) rename apps/docs/src/components/{store/store-utils.ts => workshop/workshop-utils.ts} (79%) rename apps/docs/src/lib/{store-schema.spec.ts => workshop-schema.spec.ts} (96%) rename apps/docs/src/lib/{store-schema.ts => workshop-schema.ts} (84%) rename apps/nextjs/src/components/{store-install/install-from-store-buttons.tsx => workshop-install/install-from-workshop-buttons.tsx} (84%) rename apps/nextjs/src/components/{store-install/store-api.ts => workshop-install/workshop-api.ts} (55%) rename apps/nextjs/src/components/{store-install/store-browser-modal.tsx => workshop-install/workshop-browser-modal.tsx} (89%) delete mode 100644 apps/store/README.md delete mode 100644 apps/store/docker-compose.yml delete mode 100644 apps/store/pb_migrations/1750000000_init_store.js rename apps/{store => workshop}/.gitignore (100%) create mode 100644 apps/workshop/Dockerfile create mode 100644 apps/workshop/Dockerfile.dockerignore create mode 100644 apps/workshop/README.md create mode 100644 apps/workshop/docker-compose.yml rename apps/{store/pb_hooks/store.pb.js => workshop/pb_hooks/workshop.pb.js} (100%) create mode 100644 apps/workshop/pb_migrations/1750000000_init_workshop.js rename apps/{store => workshop}/pb_migrations/1750000002_marketplace_view.js (80%) create mode 100755 apps/workshop/scripts/smoke-test.sh create mode 100755 scripts/ci/workshop-act.sh diff --git a/.env.example b/.env.example index 2dd98d36e..a9a469d8c 100644 --- a/.env.example +++ b/.env.example @@ -62,8 +62,8 @@ TURBO_TELEMETRY_DISABLED=1 # UNSAFE_ENABLE_MOCK_INTEGRATION=true # Community workshop (PocketBase) base URL used by the in-app "Install from Workshop" buttons -# In production this defaults to https://store.homarr.dev — override for local dev: -# NEXT_PUBLIC_WIDGET_STORE_URL=http://localhost:8090 +# Workshop API override for local Homarr development: +# NEXT_PUBLIC_WORKSHOP_API_URL=http://localhost:8090 # Docs site workshop URL used for "Open Workshop" links # In production this defaults to https://homarr.dev/workshop # NEXT_PUBLIC_WORKSHOP_URL=http://localhost:3003/workshop diff --git a/.github/act/workshop.md b/.github/act/workshop.md new file mode 100644 index 000000000..a0746d4aa --- /dev/null +++ b/.github/act/workshop.md @@ -0,0 +1,19 @@ +# Workshop deployment with ACT + +The Workshop workflow builds the same image used on the VPS and smoke-tests both Docusaurus and PocketBase. Registry login and pushes are skipped under ACT. + +From the repository root: + +```sh +pnpm ci:act:workshop +``` + +The wrapper selects `.github/workflows/deployment-workshop.yml`, derives the active Docker socket (including Docker Desktop and OrbStack contexts), and runs only the `deploy` job using a `workflow_dispatch` event. + +To inspect the workflow without running it: + +```sh +act workflow_dispatch --list --workflows .github/workflows/deployment-workshop.yml +``` + +No GitHub or registry secrets are required for the local run. diff --git a/.github/workflows/deployment-docs.yml b/.github/workflows/deployment-docs.yml deleted file mode 100644 index eeb53d101..000000000 --- a/.github/workflows/deployment-docs.yml +++ /dev/null @@ -1,56 +0,0 @@ -name: "[Deployment] Documentation" - -on: - push: - branches: ["dev"] - paths: - - "apps/docs/**" - - "packages/definitions/src/docs/**" - - ".github/workflows/deployment-docs.yml" - workflow_dispatch: - -permissions: - contents: read - pages: write - id-token: write - -concurrency: - group: "pages" - cancel-in-progress: false - -env: - SKIP_ENV_VALIDATION: true - TURBO_API: ${{ secrets.TURBO_API }} - TURBO_TEAM: ${{ secrets.TURBO_TEAM }} - TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} - TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }} - -jobs: - build: - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@v7 - - name: Setup - uses: ./tooling/github/setup - - name: Build docs - run: pnpm turbo build --filter=@homarr/docs - env: - CI: "true" - - name: Setup Pages - uses: actions/configure-pages@v6 - - name: Upload pages artifact - uses: actions/upload-pages-artifact@v5 - with: - path: "apps/docs/build" - - deploy: - runs-on: ubuntu-latest - needs: build - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - steps: - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v5 diff --git a/.github/workflows/deployment-workshop.yml b/.github/workflows/deployment-workshop.yml new file mode 100644 index 000000000..621c4609c --- /dev/null +++ b/.github/workflows/deployment-workshop.yml @@ -0,0 +1,47 @@ +name: "[Deployment] Workshop" + +on: + push: + branches: ["dev"] + paths: + - "apps/docs/**" + - "apps/workshop/**" + workflow_dispatch: + +permissions: + contents: read + packages: write + +concurrency: + group: workshop-deployment + cancel-in-progress: true + +jobs: + deploy: + name: Build, test, and publish + runs-on: ubuntu-latest + timeout-minutes: 25 + env: + LOCAL_IMAGE: homarr/workshop:ci + REGISTRY_IMAGE: ghcr.io/homarr-labs/workshop + steps: + - name: Check out repository + uses: actions/checkout@v7 + + - name: Build combined Workshop image + run: docker build --file apps/workshop/Dockerfile --tag "$LOCAL_IMAGE" . + + - name: Smoke-test static docs and PocketBase + run: bash apps/workshop/scripts/smoke-test.sh "$LOCAL_IMAGE" + + - name: Log in to GitHub Container Registry + if: ${{ env.ACT != 'true' }} + run: echo "${{ github.token }}" | docker login ghcr.io --username "${{ github.actor }}" --password-stdin + + - name: Publish immutable and latest tags + if: ${{ env.ACT != 'true' }} + run: | + docker tag "$LOCAL_IMAGE" "$REGISTRY_IMAGE:sha-${GITHUB_SHA}" + docker tag "$LOCAL_IMAGE" "$REGISTRY_IMAGE:latest" + docker push "$REGISTRY_IMAGE:sha-${GITHUB_SHA}" + docker push "$REGISTRY_IMAGE:latest" diff --git a/apps/docs/docs/management/store/index.mdx b/apps/docs/docs/management/workshop/index.mdx similarity index 78% rename from apps/docs/docs/management/store/index.mdx rename to apps/docs/docs/management/workshop/index.mdx index 44386aed2..c187e2f99 100644 --- a/apps/docs/docs/management/store/index.mdx +++ b/apps/docs/docs/management/workshop/index.mdx @@ -11,7 +11,7 @@ The [Community Workshop](pathname:///workshop) is where Homarr users share and d **custom CSS themes** and **custom widgets**. You can browse submissions, upvote the ones you like, report abuse, and install entries directly into your own Homarr instance. -:::warn +:::warning The workshop contains community-made content. Widgets may contain unwanted or malicious content and it is your duty to check & verify safety of the content. While we try to remove malicious content as best as we can, we take no responsibility. Always review the widget JSON and all CSS before installing it on your instance and take frequent backups as prevention measures. @@ -52,10 +52,8 @@ You can install workshop entries without leaving Homarr: Alternatively, use **Copy** (or **Download** for widgets) on any submission and import it manually. -## Self-hosting the workshop backend +## Workshop deployment -The workshop is powered by a [PocketBase](https://pocketbase.io) instance (GitHub OAuth, database, -file uploads, and an admin moderation dashboard). The deployment lives in `apps/store/` with a -`docker-compose.yml`, version-controlled collection migrations, and vote-counter hooks. See the -app's `README.md` for GitHub OAuth setup and the `STORE_URL` / `NEXT_PUBLIC_WIDGET_STORE_URL` -configuration. +Workshop is powered by [PocketBase](https://pocketbase.io). The production image compiles the current Docusaurus site and serves it together with the API from one origin. The deployment lives in `apps/workshop/` with a Dockerfile, Compose configuration, version-controlled migrations, hooks, a smoke test, and ACT-local workflow instructions. + +Homarr instances use `NEXT_PUBLIC_WORKSHOP_API_URL` to reach the central API and `NEXT_PUBLIC_WORKSHOP_URL` for links to the public page. Docusaurus uses `WORKSHOP_URL` only when its development server runs separately; the combined production image always uses its own origin. diff --git a/apps/docs/docusaurus.config.ts b/apps/docs/docusaurus.config.ts index ec5cb83bd..816f25726 100644 --- a/apps/docs/docusaurus.config.ts +++ b/apps/docs/docusaurus.config.ts @@ -21,7 +21,8 @@ const config: Config = { onDuplicateRoutes: "throw", customFields: { - storeUrl: process.env.STORE_URL ?? "http://localhost:8090", + // Empty means same-origin, which is the production Workshop image shape. + workshopUrl: process.env.WORKSHOP_URL ?? "", }, future: { @@ -310,7 +311,7 @@ const config: Config = { async contentLoaded({ actions }) { actions.addRoute({ path: "/workshop/:id", - component: "@site/src/components/store/DetailPage", + component: "@site/src/components/workshop/DetailPage", exact: true, }); }, diff --git a/apps/docs/src/components/store/DetailComments.tsx b/apps/docs/src/components/workshop/DetailComments.tsx similarity index 93% rename from apps/docs/src/components/store/DetailComments.tsx rename to apps/docs/src/components/workshop/DetailComments.tsx index 477bba48b..6d79fa2e5 100644 --- a/apps/docs/src/components/store/DetailComments.tsx +++ b/apps/docs/src/components/workshop/DetailComments.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState } from "react"; import { IconCheck, IconEdit, IconSend, IconTrash, IconX } from "@tabler/icons-react"; -import type { StoreComment } from "@site/src/lib/pocketbase"; +import type { WorkshopComment } from "@site/src/lib/pocketbase"; import { getPocketBase } from "@site/src/lib/pocketbase"; import { errorMessage } from "@site/src/lib/utils"; @@ -10,7 +10,7 @@ import { Input } from "@/components/ui/input"; import { formatRelativeTime } from "./format"; -const commentAuthorName = (comment: StoreComment) => +const commentAuthorName = (comment: WorkshopComment) => comment.expand?.author?.name || comment.expand?.author?.username || "unknown"; interface CommentsSectionProps { @@ -22,7 +22,7 @@ interface CommentsSectionProps { } export const CommentsSection = ({ submissionId, pb, currentUserId, onRequireAuth, onError }: CommentsSectionProps) => { - const [rows, setRows] = useState([]); + const [rows, setRows] = useState([]); const [loading, setLoading] = useState(true); const [fetchError, setFetchError] = useState(false); const [newComment, setNewComment] = useState(""); @@ -34,7 +34,7 @@ export const CommentsSection = ({ submissionId, pb, currentUserId, onRequireAuth setLoading(true); setFetchError(false); pb.collection("comments") - .getFullList({ + .getFullList({ filter: pb.filter("submission = {:id}", { id: submissionId }), sort: "-created", expand: "author", @@ -61,7 +61,7 @@ export const CommentsSection = ({ submissionId, pb, currentUserId, onRequireAuth try { const created = await pb .collection("comments") - .create({ submission: submissionId, content: trimmed, author: userId }, { expand: "author" }); + .create({ submission: submissionId, content: trimmed, author: userId }, { expand: "author" }); setRows((prev) => [created, ...prev]); setNewComment(""); } catch (caught) { @@ -75,7 +75,7 @@ export const CommentsSection = ({ submissionId, pb, currentUserId, onRequireAuth try { const updated = await pb .collection("comments") - .update(id, { content: trimmed }, { expand: "author" }); + .update(id, { content: trimmed }, { expand: "author" }); setRows((prev) => prev.map((comment) => (comment.id === id ? updated : comment))); setEditingId(null); } catch (caught) { diff --git a/apps/docs/src/components/store/DetailPage.tsx b/apps/docs/src/components/workshop/DetailPage.tsx similarity index 92% rename from apps/docs/src/components/store/DetailPage.tsx rename to apps/docs/src/components/workshop/DetailPage.tsx index 82c3513a5..427a8ffec 100644 --- a/apps/docs/src/components/store/DetailPage.tsx +++ b/apps/docs/src/components/workshop/DetailPage.tsx @@ -15,9 +15,9 @@ import { } from "@tabler/icons-react"; import type { ClientResponseError } from "pocketbase"; -import type { StoreSubmission, StoreVote } from "@site/src/lib/pocketbase"; +import type { WorkshopSubmission, WorkshopVote } from "@site/src/lib/pocketbase"; import { getPocketBase, getSubmissionFileUrl } from "@site/src/lib/pocketbase"; -import type { SubmissionType } from "@site/src/lib/store-schema"; +import type { SubmissionType } from "@site/src/lib/workshop-schema"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -26,7 +26,7 @@ import { cn, errorMessage } from "@site/src/lib/utils"; import { CommentsSection } from "./DetailComments"; import { CodeBlock, DeleteConfirmButton, DetailSkeleton, ScreenshotGallery } from "./DetailSections"; import { formatRelativeTime } from "./format"; -import { downloadSubmissionJson, voteDelta } from "./store-utils"; +import { downloadSubmissionJson, voteDelta } from "./workshop-utils"; const typeLabels: Record = { css: "CSS", widget: "Widget" }; const typeDotColors: Record = { css: "bg-blue-500", widget: "bg-yellow-500" }; @@ -53,13 +53,13 @@ const parseSubmissionId = (pathname: string) => { const isNotFound = (caught: unknown) => typeof caught === "object" && caught !== null && "status" in caught && (caught as ClientResponseError).status === 404; -const MarketplaceDetail = ({ storeUrl }: { storeUrl: string }) => { +const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => { const location = useLocation(); const submissionId = parseSubmissionId(location.pathname); - const pb = useMemo(() => getPocketBase(storeUrl), [storeUrl]); + const pb = useMemo(() => getPocketBase(workshopUrl), [workshopUrl]); - const [submission, setSubmission] = useState(null); - const [userVote, setUserVote] = useState(); + const [submission, setSubmission] = useState(null); + const [userVote, setUserVote] = useState(); const [user, setUser] = useState(pb.authStore.record); const [loading, setLoading] = useState(true); const [notFound, setNotFound] = useState(false); @@ -120,12 +120,12 @@ const MarketplaceDetail = ({ storeUrl }: { storeUrl: string }) => { const load = async () => { try { - const record = await pb.collection("marketplace").getOne(submissionId); + const record = await pb.collection("marketplace").getOne(submissionId); if (cancelled) return; setSubmission(record); if (pb.authStore.isValid && pb.authStore.record) { - const votes = await pb.collection("votes").getFullList({ + const votes = await pb.collection("votes").getFullList({ filter: pb.filter("user = {:uid} && submission = {:sid}", { uid: pb.authStore.record.id, sid: submissionId, @@ -170,7 +170,7 @@ const MarketplaceDetail = ({ storeUrl }: { storeUrl: string }) => { setUserVote( isToggleOff ? undefined - : ({ ...(prev ?? { id: "", submission: submission.id, user: userId }), value } as StoreVote), + : ({ ...(prev ?? { id: "", submission: submission.id, user: userId }), value } as WorkshopVote), ); setSubmission((s) => (s ? { ...s, upvotes: s.upvotes + upD, downvotes: s.downvotes + downD } : s)); @@ -178,7 +178,7 @@ const MarketplaceDetail = ({ storeUrl }: { storeUrl: string }) => { if (!prev) { const created = await pb .collection("votes") - .create({ submission: submission.id, value, user: userId }); + .create({ submission: submission.id, value, user: userId }); setUserVote(created); } else if (isToggleOff) { await pb.collection("votes").delete(prev.id); @@ -347,12 +347,14 @@ const MarketplaceDetail = ({ storeUrl }: { storeUrl: string }) => { export default function MarketplaceDetailPage() { const { siteConfig } = useDocusaurusContext(); - const storeUrl = (siteConfig.customFields?.storeUrl as string | undefined) ?? "http://localhost:8090"; + const configuredWorkshopUrl = (siteConfig.customFields?.workshopUrl as string | undefined) ?? ""; return (
- }>{() => } + }> + {() => } +
); diff --git a/apps/docs/src/components/store/DetailSections.tsx b/apps/docs/src/components/workshop/DetailSections.tsx similarity index 100% rename from apps/docs/src/components/store/DetailSections.tsx rename to apps/docs/src/components/workshop/DetailSections.tsx diff --git a/apps/docs/src/components/store/SubmitForm.tsx b/apps/docs/src/components/workshop/SubmitForm.tsx similarity index 98% rename from apps/docs/src/components/store/SubmitForm.tsx rename to apps/docs/src/components/workshop/SubmitForm.tsx index df703414b..972bc78c5 100644 --- a/apps/docs/src/components/store/SubmitForm.tsx +++ b/apps/docs/src/components/workshop/SubmitForm.tsx @@ -12,8 +12,8 @@ import { IconX, } from "@tabler/icons-react"; -import type { SubmissionType } from "@site/src/lib/store-schema"; -import { validateSubmissionContent } from "@site/src/lib/store-schema"; +import type { SubmissionType } from "@site/src/lib/workshop-schema"; +import { validateSubmissionContent } from "@site/src/lib/workshop-schema"; import { Button } from "@/components/ui/button"; import { @@ -28,7 +28,7 @@ import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { cn, errorMessage } from "@/lib/utils"; -import type { SubmitInput } from "./useStore"; +import type { SubmitInput } from "./useWorkshop"; interface Props { onClose: () => void; diff --git a/apps/docs/src/components/store/StoreApp.tsx b/apps/docs/src/components/workshop/WorkshopApp.tsx similarity index 90% rename from apps/docs/src/components/store/StoreApp.tsx rename to apps/docs/src/components/workshop/WorkshopApp.tsx index 54c86edd6..36299b8de 100644 --- a/apps/docs/src/components/store/StoreApp.tsx +++ b/apps/docs/src/components/workshop/WorkshopApp.tsx @@ -20,8 +20,8 @@ import { IconX, } from "@tabler/icons-react"; -import { getSubmissionFileUrl, type StoreSubmission } from "@site/src/lib/pocketbase"; -import type { SubmissionType } from "@site/src/lib/store-schema"; +import { getSubmissionFileUrl, type WorkshopSubmission } from "@site/src/lib/pocketbase"; +import type { SubmissionType } from "@site/src/lib/workshop-schema"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -38,9 +38,9 @@ import { cn } from "@/lib/utils"; import { SubmitForm } from "./SubmitForm"; import { formatRelativeTime } from "./format"; -import { downloadSubmissionJson } from "./store-utils"; -import type { SortKey, TypeFilter } from "./useStore"; -import { useStore } from "./useStore"; +import { downloadSubmissionJson } from "./workshop-utils"; +import type { SortKey, TypeFilter } from "./useWorkshop"; +import { useWorkshop } from "./useWorkshop"; const typeDotColors: Record = { css: "bg-blue-500", widget: "bg-yellow-500" }; const typeLabels: Record = { css: "CSS", widget: "Widget" }; @@ -76,34 +76,34 @@ const stopCardNavigation = (event: React.MouseEvent) => { event.stopPropagation(); }; -export const StoreApp = ({ storeUrl }: { storeUrl: string }) => { - const store = useStore(storeUrl); +export const WorkshopApp = ({ workshopUrl }: { workshopUrl: string }) => { + const workshop = useWorkshop(workshopUrl); const [typeFilter, setTypeFilter] = useState("all"); const [sort, setSort] = useState("top"); const [search, setSearch] = useState(""); const [showSubmit, setShowSubmit] = useState(false); // Stable sort order: only re-sort when filter/sort/search or submission set changes (not vote counts) - const submissionIds = useMemo(() => store.submissions.map((s) => s.id).join(","), [store.submissions]); + const submissionIds = useMemo(() => workshop.submissions.map((s) => s.id).join(","), [workshop.submissions]); const sortedIds = useMemo(() => { const q = search.toLowerCase(); - return store.submissions + return workshop.submissions .filter((item) => { - if (typeFilter === "yours") return item.author === store.user?.id; + if (typeFilter === "yours") return item.author === workshop.user?.id; return typeFilter === "all" || item.type === typeFilter; }) .filter((item) => !q || item.title.toLowerCase().includes(q)) - .toSorted(store.sorters[sort]) + .toSorted(workshop.sorters[sort]) .map((s) => s.id); // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally keyed on submissionIds string, not the array - }, [submissionIds, store.sorters, typeFilter, sort, search, store.user?.id]); + }, [submissionIds, workshop.sorters, typeFilter, sort, search, workshop.user?.id]); const visible = useMemo(() => { - const byId = new Map(store.submissions.map((s) => [s.id, s])); - return sortedIds.map((id) => byId.get(id)).filter(Boolean) as StoreSubmission[]; - }, [store.submissions, sortedIds]); + const byId = new Map(workshop.submissions.map((s) => [s.id, s])); + return sortedIds.map((id) => byId.get(id)).filter(Boolean) as WorkshopSubmission[]; + }, [workshop.submissions, sortedIds]); - const empty = emptyState[store.submissions.length === 0 ? "none" : "filtered"]; + const empty = emptyState[workshop.submissions.length === 0 ? "none" : "filtered"]; return (
@@ -113,18 +113,18 @@ export const StoreApp = ({ storeUrl }: { storeUrl: string }) => {

Community custom widgets and CSS themes for Homarr.

- {store.user ? ( + {workshop.user ? ( <> - ) : (
-

Vote and share your creations

@@ -175,13 +175,13 @@ export const StoreApp = ({ storeUrl }: { storeUrl: string }) => {
- {store.error && ( + {workshop.error && (
- {store.error} + {workshop.error}
)} - {store.loading && ( + {workshop.loading && (
{Array.from({ length: 6 }, (_, i) => ( @@ -189,14 +189,14 @@ export const StoreApp = ({ storeUrl }: { storeUrl: string }) => {
)} - {!store.loading && visible.length === 0 && ( + {!workshop.loading && visible.length === 0 && (

{empty.title}

{empty.hint}

- {store.user && store.submissions.length === 0 && ( + {workshop.user && workshop.submissions.length === 0 && ( @@ -209,12 +209,12 @@ export const StoreApp = ({ storeUrl }: { storeUrl: string }) => { ))}
@@ -223,7 +223,7 @@ export const StoreApp = ({ storeUrl }: { storeUrl: string }) => { setShowSubmit(false)} onSubmit={async (input) => { - if (await store.submit(input)) setShowSubmit(false); + if (await workshop.submit(input)) setShowSubmit(false); }} /> )} @@ -232,8 +232,8 @@ export const StoreApp = ({ storeUrl }: { storeUrl: string }) => { }; interface SubmissionCardProps { - submission: StoreSubmission; - pb: ReturnType["pb"]; + submission: WorkshopSubmission; + pb: ReturnType["pb"]; userVote?: 1 | -1; currentUserId?: string; onVote: (submissionId: string, value: 1 | -1) => void; diff --git a/apps/docs/src/components/store/format.ts b/apps/docs/src/components/workshop/format.ts similarity index 100% rename from apps/docs/src/components/store/format.ts rename to apps/docs/src/components/workshop/format.ts diff --git a/apps/docs/src/components/store/useStore.ts b/apps/docs/src/components/workshop/useWorkshop.ts similarity index 86% rename from apps/docs/src/components/store/useStore.ts rename to apps/docs/src/components/workshop/useWorkshop.ts index 8bd873fa0..42978f7b8 100644 --- a/apps/docs/src/components/store/useStore.ts +++ b/apps/docs/src/components/workshop/useWorkshop.ts @@ -1,13 +1,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import dayjs from "dayjs"; -import type { StoreComment, StoreSubmission, StoreVote } from "@site/src/lib/pocketbase"; +import type { WorkshopComment, WorkshopSubmission, WorkshopVote } from "@site/src/lib/pocketbase"; import { getPocketBase } from "@site/src/lib/pocketbase"; -import type { SubmissionType } from "@site/src/lib/store-schema"; -import { schemaVersionByType, validateSubmissionContent } from "@site/src/lib/store-schema"; +import type { SubmissionType } from "@site/src/lib/workshop-schema"; +import { schemaVersionByType, validateSubmissionContent } from "@site/src/lib/workshop-schema"; import { errorMessage } from "@site/src/lib/utils"; -import { voteDelta } from "./store-utils"; +import { voteDelta } from "./workshop-utils"; export type SortKey = "top" | "new"; export type TypeFilter = "all" | "yours" | SubmissionType; @@ -21,13 +21,13 @@ export interface SubmitInput { } export interface CommentActions { - fetch: (submissionId: string) => Promise; - add: (submissionId: string, content: string) => Promise; - update: (commentId: string, content: string) => Promise; + fetch: (submissionId: string) => Promise; + add: (submissionId: string, content: string) => Promise; + update: (commentId: string, content: string) => Promise; delete: (commentId: string) => Promise; } -const sorters: Record number> = { +const sorters: Record number> = { top: (a, b) => b.upvotes - b.downvotes - (a.upvotes - a.downvotes), new: (a, b) => dayjs(b.created).valueOf() - dayjs(a.created).valueOf(), }; @@ -35,10 +35,10 @@ const sorters: Record numbe const isNotFoundError = (error: unknown) => typeof error === "object" && error !== null && "status" in error && error.status === 404; -export const useStore = (storeUrl: string) => { - const pb = useMemo(() => getPocketBase(storeUrl), [storeUrl]); - const [submissions, setSubmissions] = useState([]); - const [votes, setVotes] = useState>({}); +export const useWorkshop = (workshopUrl: string) => { + const pb = useMemo(() => getPocketBase(workshopUrl), [workshopUrl]); + const [submissions, setSubmissions] = useState([]); + const [votes, setVotes] = useState>({}); const [user, setUser] = useState(pb.authStore.record); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -49,7 +49,7 @@ export const useStore = (storeUrl: string) => { setVotes({}); return; } - const rows = await pb.collection("votes").getFullList({ + const rows = await pb.collection("votes").getFullList({ filter: pb.filter("user = {:id}", { id: pb.authStore.record.id }), }); setVotes(Object.fromEntries(rows.map((row) => [row.submission, row]))); @@ -59,7 +59,7 @@ export const useStore = (storeUrl: string) => { setLoading(true); setError(null); try { - setSubmissions(await pb.collection("marketplace").getFullList({ sort: "-created" })); + setSubmissions(await pb.collection("marketplace").getFullList({ sort: "-created" })); await refreshVotes(); } catch (caught) { setError(errorMessage(caught, "Failed to load the workshop")); @@ -162,7 +162,7 @@ export const useStore = (storeUrl: string) => { next[submissionId] = { ...(prev ?? { id: "", submission: submissionId, user: userId, created: "", updated: "" }), value, - } as StoreVote; + } as WorkshopVote; return next; }); setSubmissions((s) => @@ -176,7 +176,7 @@ export const useStore = (storeUrl: string) => { if (!existing) { const created = await pb .collection("votes") - .create({ submission: submissionId, value, user: userId }); + .create({ submission: submissionId, value, user: userId }); setVotes((current) => ({ ...current, [submissionId]: created })); } else if (isToggleOff) { try { @@ -186,13 +186,13 @@ export const useStore = (storeUrl: string) => { } } else { try { - const updated = await pb.collection("votes").update(existing.id, { value }); + const updated = await pb.collection("votes").update(existing.id, { value }); setVotes((current) => ({ ...current, [submissionId]: updated })); } catch (caught) { if (!isNotFoundError(caught)) throw caught; const created = await pb .collection("votes") - .create({ submission: submissionId, value, user: userId }); + .create({ submission: submissionId, value, user: userId }); setVotes((current) => ({ ...current, [submissionId]: created })); } } @@ -268,7 +268,7 @@ export const useStore = (storeUrl: string) => { const fetchComments = useCallback( (submissionId: string) => - pb.collection("comments").getFullList({ + pb.collection("comments").getFullList({ filter: pb.filter("submission = {:id}", { id: submissionId }), sort: "-created", expand: "author", @@ -283,7 +283,7 @@ export const useStore = (storeUrl: string) => { try { return await pb .collection("comments") - .create({ submission: submissionId, content, author: userId }, { expand: "author" }); + .create({ submission: submissionId, content, author: userId }, { expand: "author" }); } catch (caught) { setError(errorMessage(caught, "Failed to post comment")); return null; @@ -295,7 +295,7 @@ export const useStore = (storeUrl: string) => { const updateComment = useCallback( async (commentId: string, content: string) => { try { - return await pb.collection("comments").update(commentId, { content }, { expand: "author" }); + return await pb.collection("comments").update(commentId, { content }, { expand: "author" }); } catch (caught) { setError(errorMessage(caught, "Failed to update comment")); return null; diff --git a/apps/docs/src/components/store/store-utils.ts b/apps/docs/src/components/workshop/workshop-utils.ts similarity index 79% rename from apps/docs/src/components/store/store-utils.ts rename to apps/docs/src/components/workshop/workshop-utils.ts index 4691536c5..b9129384a 100644 --- a/apps/docs/src/components/store/store-utils.ts +++ b/apps/docs/src/components/workshop/workshop-utils.ts @@ -1,6 +1,6 @@ -import type { StoreSubmission } from "@site/src/lib/pocketbase"; +import type { WorkshopSubmission } from "@site/src/lib/pocketbase"; -export const downloadSubmissionJson = (submission: StoreSubmission) => { +export const downloadSubmissionJson = (submission: WorkshopSubmission) => { const url = URL.createObjectURL(new Blob([submission.content], { type: "application/json" })); Object.assign(document.createElement("a"), { href: url, diff --git a/apps/docs/src/lib/pocketbase.ts b/apps/docs/src/lib/pocketbase.ts index b825624b3..dd1d9dcb8 100644 --- a/apps/docs/src/lib/pocketbase.ts +++ b/apps/docs/src/lib/pocketbase.ts @@ -14,7 +14,7 @@ export const getPocketBase = (url: string): PocketBase => { export const getSubmissionFileUrl = (baseUrl: string, submissionId: string, filename: string) => `${baseUrl}/api/files/submissions/${submissionId}/${encodeURIComponent(filename)}`; -export interface StoreSubmission { +export interface WorkshopSubmission { id: string; collectionId: string; collectionName: string; @@ -34,14 +34,14 @@ export interface StoreSubmission { created: string; } -export interface StoreVote { +export interface WorkshopVote { id: string; submission: string; user: string; value: 1 | -1; } -export interface StoreComment { +export interface WorkshopComment { id: string; submission: string; author: string; diff --git a/apps/docs/src/lib/store-schema.spec.ts b/apps/docs/src/lib/workshop-schema.spec.ts similarity index 96% rename from apps/docs/src/lib/store-schema.spec.ts rename to apps/docs/src/lib/workshop-schema.spec.ts index bc5f4483b..41009e165 100644 --- a/apps/docs/src/lib/store-schema.spec.ts +++ b/apps/docs/src/lib/workshop-schema.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { validateSubmissionContent } from "./store-schema"; +import { validateSubmissionContent } from "./workshop-schema"; const validWidget = { $schema: "homarr-custom-widget-v2", diff --git a/apps/docs/src/lib/store-schema.ts b/apps/docs/src/lib/workshop-schema.ts similarity index 84% rename from apps/docs/src/lib/store-schema.ts rename to apps/docs/src/lib/workshop-schema.ts index c5a6b1fc0..98c404837 100644 --- a/apps/docs/src/lib/store-schema.ts +++ b/apps/docs/src/lib/workshop-schema.ts @@ -7,7 +7,7 @@ export const CSS_SCHEMA_VERSION = "homarr-custom-css-v1"; export const MAX_CSS_LENGTH = 16384; -export type StoreValidationResult = { success: true; data: unknown } | { success: false; error: string }; +export type WorkshopValidationResult = { success: true; data: unknown } | { success: false; error: string }; const authTypes = ["none", "bearer", "basic", "apiKeyHeader", "apiKeyQuery"] as const; const methods = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const; @@ -38,7 +38,7 @@ const widgetSchema = z message: "displayConfig.type must match displayType", }); -const validateWidgetContent = (raw: string): StoreValidationResult => { +const validateWidgetContent = (raw: string): WorkshopValidationResult => { let parsed: unknown; try { parsed = JSON.parse(raw); @@ -54,7 +54,7 @@ const validateWidgetContent = (raw: string): StoreValidationResult => { return { success: true, data: result.data }; }; -const validateCssContent = (raw: string): StoreValidationResult => { +const validateCssContent = (raw: string): WorkshopValidationResult => { if (raw.trim().length === 0) { return { success: false, error: "CSS cannot be empty" }; } @@ -67,12 +67,12 @@ const validateCssContent = (raw: string): StoreValidationResult => { export const submissionValidators = { widget: validateWidgetContent, css: validateCssContent, -} satisfies Record StoreValidationResult>; +} satisfies Record WorkshopValidationResult>; export const schemaVersionByType = { widget: WIDGET_SCHEMA_VERSION, css: CSS_SCHEMA_VERSION, } satisfies Record; -export const validateSubmissionContent = (type: SubmissionType, raw: string): StoreValidationResult => +export const validateSubmissionContent = (type: SubmissionType, raw: string): WorkshopValidationResult => submissionValidators[type](raw); diff --git a/apps/docs/src/pages/workshop/index.tsx b/apps/docs/src/pages/workshop/index.tsx index 5c9ea695a..099acbcc7 100644 --- a/apps/docs/src/pages/workshop/index.tsx +++ b/apps/docs/src/pages/workshop/index.tsx @@ -3,17 +3,17 @@ import BrowserOnly from "@docusaurus/BrowserOnly"; import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; import Layout from "@theme/Layout"; -export default function StorePage() { +export default function WorkshopPage() { const { siteConfig } = useDocusaurusContext(); - const storeUrl = (siteConfig.customFields?.storeUrl as string | undefined) ?? "http://localhost:8090"; + const configuredWorkshopUrl = (siteConfig.customFields?.workshopUrl as string | undefined) ?? ""; return (
}> {() => { - const { StoreApp } = require("@site/src/components/store/StoreApp"); - return ; + const { WorkshopApp } = require("@site/src/components/workshop/WorkshopApp"); + return ; }}
diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_customCss.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_customCss.tsx index 37226d7fd..36daed28b 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_customCss.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_customCss.tsx @@ -11,7 +11,7 @@ import { IconInfoCircle } from "@tabler/icons-react"; import { useForm } from "@homarr/form"; import { useI18n, useScopedI18n } from "@homarr/translation/client"; -import { InstallCssFromStoreButton } from "~/components/store-install/install-from-store-buttons"; +import { InstallCssFromWorkshopButton } from "~/components/workshop-install/install-from-workshop-buttons"; import type { Board } from "../../_types"; import { useSavePartialSettingsMutation } from "./_shared"; import classes from "./customcss.module.css"; @@ -47,7 +47,7 @@ export const CustomCssSettingsContent = ({ board }: Props) => { - form.setFieldValue("customCss", css)} /> + form.setFieldValue("customCss", css)} /> diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/page.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/page.tsx index f307a24ef..3fb31a7cb 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/page.tsx @@ -9,7 +9,7 @@ import { Link } from "@homarr/ui"; import { ManagePageLayout } from "~/components/manage/manage-page-layout"; import { MobileAffixButton } from "~/components/manage/mobile-affix-button"; -import { InstallWidgetFromStoreButton } from "~/components/store-install/install-from-store-buttons"; +import { InstallWidgetFromWorkshopButton } from "~/components/workshop-install/install-from-workshop-buttons"; import { CustomWidgetBetaBanner } from "./_beta-banner"; import { ImportCustomWidgetButton } from "./_custom-widget-actions"; import { CustomWidgetList } from "./_custom-widget-list"; @@ -29,7 +29,7 @@ export default async function CustomWidgetsPage() { title={t("page.list.title")} primaryAction={ - + }> {t("action.create")} diff --git a/apps/nextjs/src/components/store-install/install-from-store-buttons.tsx b/apps/nextjs/src/components/workshop-install/install-from-workshop-buttons.tsx similarity index 84% rename from apps/nextjs/src/components/store-install/install-from-store-buttons.tsx rename to apps/nextjs/src/components/workshop-install/install-from-workshop-buttons.tsx index 6be0e050f..855c7ee36 100644 --- a/apps/nextjs/src/components/store-install/install-from-store-buttons.tsx +++ b/apps/nextjs/src/components/workshop-install/install-from-workshop-buttons.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import { Button } from "@mantine/core"; import { useDisclosure } from "@mantine/hooks"; -import { IconBuildingStore } from "@tabler/icons-react"; +import { IconUsersGroup } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; @@ -12,7 +12,7 @@ import { useScopedI18n } from "@homarr/translation/client"; import { customWidgetImportSchema } from "@homarr/validation/custom-widget"; import { MobileAffixButton } from "~/components/manage/mobile-affix-button"; -import { StoreBrowserModal } from "./store-browser-modal"; +import { WorkshopBrowserModal } from "./workshop-browser-modal"; function parseWidgetContent(content: string) { let parsed: unknown; @@ -28,7 +28,7 @@ function parseWidgetContent(content: string) { return { ok: true as const, data: result.data }; } -export const InstallWidgetFromStoreButton = () => { +export const InstallWidgetFromWorkshopButton = () => { const t = useScopedI18n("customWidget.workshop"); const [opened, { open, close }] = useDisclosure(false); const [pendingId, setPendingId] = useState(null); @@ -59,10 +59,10 @@ export const InstallWidgetFromStoreButton = () => { return ( <> - } onClick={open}> + } onClick={open}> {t("action.installFromWorkshop")} - { ); }; -export const InstallCssFromStoreButton = ({ onSelect }: { onSelect: (css: string) => void }) => { +export const InstallCssFromWorkshopButton = ({ onSelect }: { onSelect: (css: string) => void }) => { const t = useScopedI18n("customWidget.workshop"); const [opened, { open, close }] = useDisclosure(false); return ( <> - - - `${STORE_URL}/api/files/submissions/${submission.id}/${encodeURIComponent(filename)}`; +export const getWorkshopFileUrl = (submission: WorkshopSubmission, filename: string) => + `${WORKSHOP_API_URL}/api/files/submissions/${submission.id}/${encodeURIComponent(filename)}`; -export const fetchStoreSubmissions = async ( +export const fetchWorkshopSubmissions = async ( type: "css" | "widget", page = 1, perPage = 50, @@ -41,7 +41,7 @@ export const fetchStoreSubmissions = async ( sort: "-upvotes", filter: `(type='${type}')`, }); - const response = await fetch(`${STORE_URL}/api/collections/marketplace/records?${params}`); - if (!response.ok) throw new Error(`Store request failed (${response.status})`); + const response = await fetch(`${WORKSHOP_API_URL}/api/collections/marketplace/records?${params}`); + if (!response.ok) throw new Error(`Workshop request failed (${response.status})`); return response.json() as Promise; }; diff --git a/apps/nextjs/src/components/store-install/store-browser-modal.tsx b/apps/nextjs/src/components/workshop-install/workshop-browser-modal.tsx similarity index 89% rename from apps/nextjs/src/components/store-install/store-browser-modal.tsx rename to apps/nextjs/src/components/workshop-install/workshop-browser-modal.tsx index 295a10898..1ee2da459 100644 --- a/apps/nextjs/src/components/store-install/store-browser-modal.tsx +++ b/apps/nextjs/src/components/workshop-install/workshop-browser-modal.tsx @@ -23,8 +23,8 @@ import { import { IconChevronLeft, IconChevronRight, IconExternalLink } from "@tabler/icons-react"; import { useQuery } from "@tanstack/react-query"; -import type { StoreSubmission } from "./store-api"; -import { fetchStoreSubmissions, getStoreFileUrl } from "./store-api"; +import type { WorkshopSubmission } from "./workshop-api"; +import { fetchWorkshopSubmissions, getWorkshopFileUrl } from "./workshop-api"; const WORKSHOP_URL = process.env.NEXT_PUBLIC_WORKSHOP_URL || "https://homarr.dev/workshop"; @@ -32,22 +32,22 @@ interface Props { type: "css" | "widget"; opened: boolean; onClose: () => void; - onSelect: (submission: StoreSubmission) => void; + onSelect: (submission: WorkshopSubmission) => void; actionLabel: string; pendingId?: string | null; } -const displayName = (submission: StoreSubmission) => submission.authorName; +const displayName = (submission: WorkshopSubmission) => submission.authorName; -export const StoreBrowserModal = ({ type, opened, onClose, onSelect, actionLabel, pendingId }: Props) => { +export const WorkshopBrowserModal = ({ type, opened, onClose, onSelect, actionLabel, pendingId }: Props) => { const [page, setPage] = useState(1); const handleClose = () => { setPage(1); onClose(); }; const { data, isLoading, error } = useQuery({ - queryKey: ["store-submissions", type, page], - queryFn: () => fetchStoreSubmissions(type, page), + queryKey: ["workshop-submissions", type, page], + queryFn: () => fetchWorkshopSubmissions(type, page), enabled: opened, }); @@ -121,7 +121,7 @@ const SubmissionCard = ({ pending, onSelect, }: { - submission: StoreSubmission; + submission: WorkshopSubmission; actionLabel: string; pending: boolean; onSelect: () => void; @@ -136,7 +136,7 @@ const SubmissionCard = ({ {screenshots.length > 0 && ( {submission.title} users -> Options -> OAuth2**. `PB_ADMIN_EMAIL` and `PB_ADMIN_PASSWORD` are used by the PocketBase image to bootstrap the first superuser for the admin UI. - -## Collections - -| Collection | Purpose | -| ------------- | ---------------------------------------------------------------------------------------------- | -| `users` | Built-in auth collection, GitHub OAuth2 enabled | -| `submissions` | `type` (`css`/`widget`), title, description, content, screenshot, counters, version, changelog | -| `votes` | One row per (submission, user); `value` is `1` or `-1` | -| `reports` | Abuse flags, moderation-only (not publicly readable) | - -Submissions are auto-published. Moderate reported entries from the admin UI. - -## Schema versions - -- Custom widgets store Homarr's `homarr-custom-widget-v2` JSON in `content`. -- Custom CSS stores the raw stylesheet in `content`, tagged `homarr-custom-css-v1`. - -`content` is validated client-side against these schemas before submission and before -install (see `apps/docs/src/lib/store-schema.ts`). diff --git a/apps/store/docker-compose.yml b/apps/store/docker-compose.yml deleted file mode 100644 index ea2a47c6a..000000000 --- a/apps/store/docker-compose.yml +++ /dev/null @@ -1,30 +0,0 @@ -services: - pocketbase: - image: ghcr.io/muchobien/pocketbase:0.26.2 - container_name: homarr-store - restart: unless-stopped - environment: - PB_HOST: 0.0.0.0 - PB_PORT: 8090 - PB_ADMIN_EMAIL: ${PB_ADMIN_EMAIL:-} - PB_ADMIN_PASSWORD: ${PB_ADMIN_PASSWORD:-} - GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-} - GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-} - ports: - - "${PB_EXPOSE_PORT:-8090}:8090" - command: - - --migrationsDir - - /pb_migrations - volumes: - - pb_data:/pb_data - - ./pb_migrations:/pb_migrations - - ./pb_hooks:/pb_hooks - healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8090/api/health"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 20s - -volumes: - pb_data: diff --git a/apps/store/pb_migrations/1750000000_init_store.js b/apps/store/pb_migrations/1750000000_init_store.js deleted file mode 100644 index 6fed0c42f..000000000 --- a/apps/store/pb_migrations/1750000000_init_store.js +++ /dev/null @@ -1,138 +0,0 @@ -/// - -migrate( - (app) => { - const users = app.findCollectionByNameOrId("users"); - - const githubClientId = $os.getenv("GITHUB_CLIENT_ID"); - const githubClientSecret = $os.getenv("GITHUB_CLIENT_SECRET"); - if (githubClientId && githubClientSecret) { - users.oauth2.enabled = true; - users.oauth2.providers = [ - { - name: "github", - clientId: githubClientId, - clientSecret: githubClientSecret, - }, - ]; - } - app.save(users); - - const submissions = new Collection({ - type: "base", - name: "submissions", - listRule: "", - viewRule: "", - createRule: "@request.auth.id != ''", - updateRule: "author = @request.auth.id", - deleteRule: "author = @request.auth.id", - fields: [ - { name: "type", type: "select", required: true, maxSelect: 1, values: ["css", "widget"] }, - { name: "title", type: "text", required: true, min: 3, max: 100 }, - { name: "description", type: "text", max: 2000 }, - { name: "schemaVersion", type: "text", required: true, max: 100 }, - { name: "content", type: "text", required: true, max: 1000000 }, - { - name: "screenshots", - type: "file", - maxSelect: 5, - maxSize: 5242880, - mimeTypes: ["image/png", "image/jpeg", "image/webp", "image/gif"], - }, - { name: "author", type: "relation", required: true, maxSelect: 1, collectionId: users.id, cascadeDelete: true }, - { name: "upvotes", type: "number", onlyInt: true }, - { name: "downvotes", type: "number", onlyInt: true }, - { name: "version", type: "number", onlyInt: true }, - { name: "changelog", type: "text", max: 2000 }, - { name: "created", type: "autodate", onCreate: true }, - { name: "updated", type: "autodate", onCreate: true, onUpdate: true }, - ], - }); - app.save(submissions); - - const votes = new Collection({ - type: "base", - name: "votes", - listRule: "user = @request.auth.id", - viewRule: "user = @request.auth.id", - createRule: "@request.auth.id != ''", - updateRule: "user = @request.auth.id", - deleteRule: "user = @request.auth.id", - fields: [ - { - name: "submission", - type: "relation", - required: true, - maxSelect: 1, - collectionId: submissions.id, - cascadeDelete: true, - }, - { name: "user", type: "relation", required: true, maxSelect: 1, collectionId: users.id, cascadeDelete: true }, - { name: "value", type: "number", required: true, onlyInt: true, min: -1, max: 1 }, - { name: "created", type: "autodate", onCreate: true }, - { name: "updated", type: "autodate", onCreate: true, onUpdate: true }, - ], - }); - votes.addIndex("idx_votes_unique_user_submission", true, "submission, user", ""); - app.save(votes); - - const reports = new Collection({ - type: "base", - name: "reports", - listRule: null, - viewRule: null, - createRule: "@request.auth.id != ''", - updateRule: null, - deleteRule: null, - fields: [ - { - name: "submission", - type: "relation", - required: true, - maxSelect: 1, - collectionId: submissions.id, - cascadeDelete: true, - }, - { name: "user", type: "relation", required: true, maxSelect: 1, collectionId: users.id, cascadeDelete: true }, - { name: "reason", type: "text", required: true, min: 3, max: 1000 }, - { name: "created", type: "autodate", onCreate: true }, - ], - }); - app.save(reports); - - const comments = new Collection({ - type: "base", - name: "comments", - listRule: "", - viewRule: "", - createRule: "@request.auth.id != ''", - updateRule: "author = @request.auth.id", - deleteRule: "author = @request.auth.id", - fields: [ - { - name: "submission", - type: "relation", - required: true, - maxSelect: 1, - collectionId: submissions.id, - cascadeDelete: true, - }, - { name: "author", type: "relation", required: true, maxSelect: 1, collectionId: users.id, cascadeDelete: true }, - { name: "content", type: "text", required: true, min: 1, max: 2000 }, - { name: "created", type: "autodate", onCreate: true }, - { name: "updated", type: "autodate", onCreate: true, onUpdate: true }, - ], - }); - comments.addIndex("idx_comments_submission", false, "submission", ""); - app.save(comments); - }, - (app) => { - for (const name of ["comments", "reports", "votes", "submissions"]) { - try { - app.delete(app.findCollectionByNameOrId(name)); - } catch { - // already gone - } - } - }, -); diff --git a/apps/store/.gitignore b/apps/workshop/.gitignore similarity index 100% rename from apps/store/.gitignore rename to apps/workshop/.gitignore diff --git a/apps/workshop/Dockerfile b/apps/workshop/Dockerfile new file mode 100644 index 000000000..2c1c398c3 --- /dev/null +++ b/apps/workshop/Dockerfile @@ -0,0 +1,54 @@ +# syntax=docker/dockerfile:1.10 + +FROM node:24.18.0-alpine AS docs +WORKDIR /app + +RUN corepack enable + +COPY . . +RUN --mount=type=cache,id=workshop-pnpm,target=/pnpm/store \ + pnpm config set store-dir /pnpm/store && \ + pnpm install --frozen-lockfile --filter @homarr/docs... +RUN WORKSHOP_URL="" pnpm --filter @homarr/docs build + +FROM alpine:3.22 AS pocketbase +ARG POCKETBASE_VERSION=0.39.6 +ARG TARGETARCH + +RUN apk add --no-cache ca-certificates curl unzip +RUN case "${TARGETARCH}" in \ + amd64) PB_ARCH=amd64 ;; \ + arm64) PB_ARCH=arm64 ;; \ + arm) PB_ARCH=armv7 ;; \ + *) echo "Unsupported architecture: ${TARGETARCH}" >&2; exit 1 ;; \ + esac && \ + BASE_URL="https://github.com/pocketbase/pocketbase/releases/download/v${POCKETBASE_VERSION}" && \ + ARCHIVE="pocketbase_${POCKETBASE_VERSION}_linux_${PB_ARCH}.zip" && \ + curl --fail --location --silent --show-error "${BASE_URL}/${ARCHIVE}" --output "/tmp/${ARCHIVE}" && \ + curl --fail --location --silent --show-error "${BASE_URL}/checksums.txt" --output /tmp/checksums.txt && \ + cd /tmp && \ + grep " ${ARCHIVE}$" checksums.txt | sha256sum -c - && \ + unzip "/tmp/${ARCHIVE}" pocketbase -d /out + +FROM alpine:3.22 AS runtime +RUN apk add --no-cache ca-certificates tzdata + +COPY --from=pocketbase /out/pocketbase /usr/local/bin/pocketbase +COPY --from=docs /app/apps/docs/build /pb_public +COPY apps/workshop/pb_hooks /pb_hooks +COPY apps/workshop/pb_migrations /pb_migrations + +RUN addgroup -S workshop && \ + adduser -S -G workshop -h /home/workshop workshop && \ + mkdir -p /pb_data && \ + chown -R workshop:workshop /pb_data /pb_public /pb_hooks /pb_migrations + +USER workshop +EXPOSE 8090 +VOLUME ["/pb_data"] + +HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=10 \ + CMD wget --quiet --spider http://127.0.0.1:8090/api/health || exit 1 + +ENTRYPOINT ["/usr/local/bin/pocketbase"] +CMD ["serve", "--http=0.0.0.0:8090", "--dir=/pb_data", "--publicDir=/pb_public", "--hooksDir=/pb_hooks", "--migrationsDir=/pb_migrations", "--indexFallback=true"] diff --git a/apps/workshop/Dockerfile.dockerignore b/apps/workshop/Dockerfile.dockerignore new file mode 100644 index 000000000..00910866f --- /dev/null +++ b/apps/workshop/Dockerfile.dockerignore @@ -0,0 +1,15 @@ +.git +.github +.env +.env.* +!.env.example +**/.docusaurus +**/.next +**/.turbo +**/build +**/coverage +**/dist +**/node_modules +**/pb_data +.pnpm-store +e2e diff --git a/apps/workshop/README.md b/apps/workshop/README.md new file mode 100644 index 000000000..c5ff7ea4c --- /dev/null +++ b/apps/workshop/README.md @@ -0,0 +1,80 @@ +# Homarr Workshop + +Workshop ships as one container. Its build stage compiles the current Docusaurus site, and its runtime stage serves those static files and the PocketBase API from the same origin. + +## Local production-like run + +Configure optional values in the repository-root `.env`, then run: + +```sh +pnpm dev:workshop +``` + +Open: + +- documentation: `http://localhost:8090/`; +- Workshop: `http://localhost:8090/workshop/`; +- PocketBase API: `http://localhost:8090/api/`; +- PocketBase dashboard: `http://localhost:8090/_/`. + +The `homarr_workshop_deployment_data` volume persists records and uploaded files across image rebuilds. Migrations run automatically when PocketBase starts. It is intentionally separate from volumes created by earlier prototypes with incompatible schemas. + +## GitHub OAuth + +Create a GitHub OAuth application with these local values: + +- homepage: `http://localhost:8090`; +- callback: `http://localhost:8090/api/oauth2-redirect`. + +Set these values only in the repository-root `.env`: + +```dotenv +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= +``` + +For production, use `https://workshop.homarr.dev` and `https://workshop.homarr.dev/api/oauth2-redirect`. Create the first PocketBase superuser through the installer link printed on first startup or through the PocketBase CLI. + +## Local image validation + +Build and test the exact runtime shape: + +```sh +docker build -f apps/workshop/Dockerfile -t homarr/workshop:ci . +pnpm test:workshop-image +``` + +The smoke test starts a temporary container and verifies the documentation homepage, `/workshop/`, the migrated public collection, and `/api/health`. + +## GitHub Actions and ACT + +`.github/workflows/deployment-workshop.yml` runs only for changes below `apps/docs/` or `apps/workshop/`, plus manual dispatches. It builds and tests the combined image, then publishes: + +- `ghcr.io/homarr-labs/workshop:latest`; +- `ghcr.io/homarr-labs/workshop:sha-`. + +Run the same build and smoke test locally without registry credentials: + +```sh +pnpm ci:act:workshop +``` + +See `.github/act/workshop.md` for the direct ACT command and Docker socket behavior. + +## VPS deployment + +Copy `apps/workshop/docker-compose.yml` to the checkout on the VPS, authenticate Docker to GHCR if the package is private, and run from the repository root. Compose reads the optional root `.env` automatically: + +```sh +docker compose -f apps/workshop/docker-compose.yml pull +docker compose -f apps/workshop/docker-compose.yml up -d --no-build +``` + +The service binds to `127.0.0.1:8090` by default. Put a TLS reverse proxy in front of it for `workshop.homarr.dev`, forward the original scheme and host, and persist the `homarr_workshop_deployment_data` volume. Back up that volume before PocketBase upgrades. + +## Content schema + +- Custom widgets use `homarr-custom-widget-v2` JSON. +- Custom CSS uses raw CSS tagged as `homarr-custom-css-v1`. + +Client-side validation lives in `apps/docs/src/lib/workshop-schema.ts`. PocketBase collection rules and hooks remain in this directory. diff --git a/apps/workshop/docker-compose.yml b/apps/workshop/docker-compose.yml new file mode 100644 index 000000000..09bc3bc79 --- /dev/null +++ b/apps/workshop/docker-compose.yml @@ -0,0 +1,28 @@ +name: homarr-workshop + +services: + workshop: + image: ghcr.io/homarr-labs/workshop:${WORKSHOP_TAG:-latest} + build: + context: ../.. + dockerfile: apps/workshop/Dockerfile + restart: unless-stopped + environment: + GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-} + GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-} + ports: + - "127.0.0.1:${WORKSHOP_PORT:-8090}:8090" + volumes: + - workshop_data:/pb_data + healthcheck: + test: ["CMD", "wget", "--quiet", "--spider", "http://127.0.0.1:8090/api/health"] + interval: 10s + timeout: 3s + retries: 10 + start_period: 10s + security_opt: + - no-new-privileges:true + +volumes: + workshop_data: + name: homarr_workshop_deployment_data diff --git a/apps/store/pb_hooks/store.pb.js b/apps/workshop/pb_hooks/workshop.pb.js similarity index 100% rename from apps/store/pb_hooks/store.pb.js rename to apps/workshop/pb_hooks/workshop.pb.js diff --git a/apps/workshop/pb_migrations/1750000000_init_workshop.js b/apps/workshop/pb_migrations/1750000000_init_workshop.js new file mode 100644 index 000000000..6fc1d7d0c --- /dev/null +++ b/apps/workshop/pb_migrations/1750000000_init_workshop.js @@ -0,0 +1,172 @@ +/// + +migrate( + (app) => { + const users = app.findCollectionByNameOrId("users"); + + const githubClientId = $os.getenv("GITHUB_CLIENT_ID"); + const githubClientSecret = $os.getenv("GITHUB_CLIENT_SECRET"); + if (githubClientId && githubClientSecret) { + users.oauth2.enabled = true; + users.oauth2.providers = [ + { + name: "github", + clientId: githubClientId, + clientSecret: githubClientSecret, + }, + ]; + } + app.save(users); + + let submissions; + try { + submissions = app.findCollectionByNameOrId("submissions"); + } catch { + submissions = new Collection({ + type: "base", + name: "submissions", + listRule: "", + viewRule: "", + createRule: "@request.auth.id != ''", + updateRule: "author = @request.auth.id", + deleteRule: "author = @request.auth.id", + fields: [ + { name: "type", type: "select", required: true, maxSelect: 1, values: ["css", "widget"] }, + { name: "title", type: "text", required: true, min: 3, max: 100 }, + { name: "description", type: "text", max: 2000 }, + { name: "schemaVersion", type: "text", required: true, max: 100 }, + { name: "content", type: "text", required: true, max: 1000000 }, + { + name: "screenshots", + type: "file", + maxSelect: 5, + maxSize: 5242880, + mimeTypes: ["image/png", "image/jpeg", "image/webp", "image/gif"], + }, + { + name: "author", + type: "relation", + required: true, + maxSelect: 1, + collectionId: users.id, + cascadeDelete: true, + }, + { name: "upvotes", type: "number", onlyInt: true }, + { name: "downvotes", type: "number", onlyInt: true }, + { name: "version", type: "number", onlyInt: true }, + { name: "changelog", type: "text", max: 2000 }, + { name: "created", type: "autodate", onCreate: true }, + { name: "updated", type: "autodate", onCreate: true, onUpdate: true }, + ], + }); + app.save(submissions); + } + + let votes; + try { + votes = app.findCollectionByNameOrId("votes"); + } catch { + votes = new Collection({ + type: "base", + name: "votes", + listRule: "user = @request.auth.id", + viewRule: "user = @request.auth.id", + createRule: "@request.auth.id != ''", + updateRule: "user = @request.auth.id", + deleteRule: "user = @request.auth.id", + fields: [ + { + name: "submission", + type: "relation", + required: true, + maxSelect: 1, + collectionId: submissions.id, + cascadeDelete: true, + }, + { name: "user", type: "relation", required: true, maxSelect: 1, collectionId: users.id, cascadeDelete: true }, + { name: "value", type: "number", required: true, onlyInt: true, min: -1, max: 1 }, + { name: "created", type: "autodate", onCreate: true }, + { name: "updated", type: "autodate", onCreate: true, onUpdate: true }, + ], + }); + votes.addIndex("idx_votes_unique_user_submission", true, "submission, user", ""); + app.save(votes); + } + + let reports; + try { + reports = app.findCollectionByNameOrId("reports"); + } catch { + reports = new Collection({ + type: "base", + name: "reports", + listRule: null, + viewRule: null, + createRule: "@request.auth.id != ''", + updateRule: null, + deleteRule: null, + fields: [ + { + name: "submission", + type: "relation", + required: true, + maxSelect: 1, + collectionId: submissions.id, + cascadeDelete: true, + }, + { name: "user", type: "relation", required: true, maxSelect: 1, collectionId: users.id, cascadeDelete: true }, + { name: "reason", type: "text", required: true, min: 3, max: 1000 }, + { name: "created", type: "autodate", onCreate: true }, + ], + }); + app.save(reports); + } + + let comments; + try { + comments = app.findCollectionByNameOrId("comments"); + } catch { + comments = new Collection({ + type: "base", + name: "comments", + listRule: "", + viewRule: "", + createRule: "@request.auth.id != ''", + updateRule: "author = @request.auth.id", + deleteRule: "author = @request.auth.id", + fields: [ + { + name: "submission", + type: "relation", + required: true, + maxSelect: 1, + collectionId: submissions.id, + cascadeDelete: true, + }, + { + name: "author", + type: "relation", + required: true, + maxSelect: 1, + collectionId: users.id, + cascadeDelete: true, + }, + { name: "content", type: "text", required: true, min: 1, max: 2000 }, + { name: "created", type: "autodate", onCreate: true }, + { name: "updated", type: "autodate", onCreate: true, onUpdate: true }, + ], + }); + comments.addIndex("idx_comments_submission", false, "submission", ""); + app.save(comments); + } + }, + (app) => { + for (const name of ["comments", "reports", "votes", "submissions"]) { + try { + app.delete(app.findCollectionByNameOrId(name)); + } catch { + // already gone + } + } + }, +); diff --git a/apps/store/pb_migrations/1750000002_marketplace_view.js b/apps/workshop/pb_migrations/1750000002_marketplace_view.js similarity index 80% rename from apps/store/pb_migrations/1750000002_marketplace_view.js rename to apps/workshop/pb_migrations/1750000002_marketplace_view.js index 5595394d1..dbd62046b 100644 --- a/apps/store/pb_migrations/1750000002_marketplace_view.js +++ b/apps/workshop/pb_migrations/1750000002_marketplace_view.js @@ -2,6 +2,15 @@ migrate( (app) => { + // Keep databases created by the original prototype valid after the + // migration file was renamed with the feature. + try { + app.findCollectionByNameOrId("marketplace"); + return; + } catch { + // Fresh database; create the public view below. + } + const collection = new Collection({ name: "marketplace", type: "view", diff --git a/apps/workshop/scripts/smoke-test.sh b/apps/workshop/scripts/smoke-test.sh new file mode 100755 index 000000000..57dc55d46 --- /dev/null +++ b/apps/workshop/scripts/smoke-test.sh @@ -0,0 +1,30 @@ +#!/bin/sh +set -eu + +image="${1:-homarr/workshop:ci}" +container="homarr-workshop-smoke-${$}" +port="${WORKSHOP_SMOKE_PORT:-18090}" + +cleanup() { + docker rm --force "$container" >/dev/null 2>&1 || true +} +trap cleanup EXIT INT TERM + +docker run --detach --name "$container" --publish "127.0.0.1:${port}:8090" "$image" >/dev/null + +attempt=0 +until curl --fail --silent "http://127.0.0.1:${port}/api/health" >/dev/null; do + attempt=$((attempt + 1)) + if [ "$attempt" -ge 30 ]; then + docker logs "$container" + exit 1 + fi + sleep 1 +done + +curl --fail --silent "http://127.0.0.1:${port}/" | grep --quiet "Homarr" +curl --fail --silent "http://127.0.0.1:${port}/workshop/" | grep --quiet "Workshop" +curl --fail --silent "http://127.0.0.1:${port}/api/collections/marketplace/records?page=1&perPage=1" >/dev/null +curl --fail --silent "http://127.0.0.1:${port}/api/health" | grep --quiet '"code":200' + +echo "Workshop image serves Docusaurus and PocketBase successfully" diff --git a/package.json b/package.json index fcc2923dc..49ac2cebb 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,9 @@ "db:studio": "pnpm -F db studio", "dev": "pnpm -F nextjs dev", "dev:docs": "pnpm -F @homarr/docs dev", - "dev:workshop": "docker compose -f ./apps/store/docker-compose.yml up", + "dev:workshop": "docker compose -f ./apps/workshop/docker-compose.yml up --build", + "test:workshop-image": "bash ./apps/workshop/scripts/smoke-test.sh", + "ci:act:workshop": "bash ./scripts/ci/workshop-act.sh", "docker:dev": "docker compose -f ./development/development.docker-compose.yml up", "format": "turbo format --continue -- --check", "format:fix": "turbo format --continue", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5a2b6f3c7..6b3bf5a5b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -572,8 +572,8 @@ catalogs: version: 5.0.0 overrides: - '@babel/helpers@<7.26.10': '>=7.29.7' - '@babel/runtime@<7.26.10': '>=7.29.7' + '@babel/helpers@<7.26.10': ^7.29.7 + '@babel/runtime@<7.26.10': ^7.29.7 axios@>=1.0.0 <1.8.2: '>=1.18.1' brace-expansion@>=2.0.0 <=2.0.1: '>=5.0.7' brace-expansion@>=1.0.0 <=1.1.11: '>=5.0.7' @@ -3450,9 +3450,6 @@ packages: resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} engines: {node: '>=6.9.0'} - '@babel/runtime@8.0.0': - resolution: {integrity: sha512-sL6cvO2IfkSu/iU+zs2S/w01B7A8V7suXSIKEN4hPFFdZoiPGxrj5pAG0lCaqLWiEIrjKzdznIWuaLcxPR53qw==} - '@babel/template@7.29.7': resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} @@ -4278,11 +4275,11 @@ packages: '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild-kit/esm-loader@2.6.5': resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} @@ -16209,8 +16206,6 @@ snapshots: '@babel/runtime@7.29.7': {} - '@babel/runtime@8.0.0': {} - '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -16879,7 +16874,7 @@ snapshots: '@babel/preset-env': 7.29.7(@babel/core@7.29.7) '@babel/preset-react': 7.29.7(@babel/core@7.29.7) '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7) - '@babel/runtime': 8.0.0 + '@babel/runtime': 7.29.7 '@babel/traverse': 7.29.7 '@docusaurus/logger': 3.10.1 '@docusaurus/utils': 3.10.1(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.16))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(postcss@8.5.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -20359,7 +20354,7 @@ snapshots: '@slorber/react-helmet-async@1.3.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@babel/runtime': 8.0.0 + '@babel/runtime': 7.29.7 invariant: 2.2.4 prop-types: 15.8.1 react: 19.2.7 @@ -21585,7 +21580,7 @@ snapshots: '@videojs/http-streaming@3.17.5(video.js@8.23.9)': dependencies: - '@babel/runtime': 8.0.0 + '@babel/runtime': 7.29.7 '@videojs/vhs-utils': 4.1.2 aes-decrypter: 4.0.2 global: 4.4.0 @@ -21596,12 +21591,12 @@ snapshots: '@videojs/vhs-utils@4.1.2': dependencies: - '@babel/runtime': 8.0.0 + '@babel/runtime': 7.29.7 global: 4.4.0 '@videojs/xhr@2.7.0': dependencies: - '@babel/runtime': 8.0.0 + '@babel/runtime': 7.29.7 global: 4.4.0 is-function: 1.0.2 @@ -21912,7 +21907,7 @@ snapshots: aes-decrypter@4.0.2: dependencies: - '@babel/runtime': 8.0.0 + '@babel/runtime': 7.29.7 '@videojs/vhs-utils': 4.1.2 global: 4.4.0 pkcs7: 1.0.4 @@ -23437,7 +23432,7 @@ snapshots: dom-helpers@5.2.1: dependencies: - '@babel/runtime': 8.0.0 + '@babel/runtime': 7.29.7 csstype: 3.2.3 dom-serializer@1.4.1: @@ -24604,7 +24599,7 @@ snapshots: history@4.10.1: dependencies: - '@babel/runtime': 8.0.0 + '@babel/runtime': 7.29.7 loose-envify: 1.4.0 resolve-pathname: 3.0.0 tiny-invariant: 1.3.3 @@ -25413,7 +25408,7 @@ snapshots: m3u8-parser@7.2.0: dependencies: - '@babel/runtime': 8.0.0 + '@babel/runtime': 7.29.7 '@videojs/vhs-utils': 4.1.2 global: 4.4.0 @@ -26169,7 +26164,7 @@ snapshots: mpd-parser@1.4.0: dependencies: - '@babel/runtime': 8.0.0 + '@babel/runtime': 7.29.7 '@videojs/vhs-utils': 4.1.2 '@xmldom/xmldom': 0.8.13 global: 4.4.0 @@ -26244,7 +26239,7 @@ snapshots: mux.js@7.1.0: dependencies: - '@babel/runtime': 8.0.0 + '@babel/runtime': 7.29.7 global: 4.4.0 mysql2@3.22.5(@types/node@24.13.2): @@ -26843,7 +26838,7 @@ snapshots: pkcs7@1.0.4: dependencies: - '@babel/runtime': 8.0.0 + '@babel/runtime': 7.29.7 pkg-conf@2.1.0: dependencies: @@ -27702,7 +27697,7 @@ snapshots: react-loadable-ssr-addon-v5-slorber@1.0.3(@docusaurus/react-loadable@6.0.0(react@19.2.7))(webpack@5.108.3(@swc/core@1.15.43(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.16)): dependencies: - '@babel/runtime': 8.0.0 + '@babel/runtime': 7.29.7 react-loadable: '@docusaurus/react-loadable@6.0.0(react@19.2.7)' webpack: 5.108.3(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.16))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(postcss@8.5.16) @@ -27767,13 +27762,13 @@ snapshots: react-router-config@5.1.1(react-router@5.3.4(react@19.2.7))(react@19.2.7): dependencies: - '@babel/runtime': 8.0.0 + '@babel/runtime': 7.29.7 react: 19.2.7 react-router: 5.3.4(react@19.2.7) react-router-dom@5.3.4(react@19.2.7): dependencies: - '@babel/runtime': 8.0.0 + '@babel/runtime': 7.29.7 history: 4.10.1 loose-envify: 1.4.0 prop-types: 15.8.1 @@ -27784,7 +27779,7 @@ snapshots: react-router@5.3.4(react@19.2.7): dependencies: - '@babel/runtime': 8.0.0 + '@babel/runtime': 7.29.7 history: 4.10.1 hoist-non-react-statics: 3.3.2 loose-envify: 1.4.0 @@ -27810,7 +27805,7 @@ snapshots: react-transition-group@4.4.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: - '@babel/runtime': 8.0.0 + '@babel/runtime': 7.29.7 dom-helpers: 5.2.1 loose-envify: 1.4.0 prop-types: 15.8.1 @@ -29410,7 +29405,7 @@ snapshots: use-deep-compare-effect@1.8.1(react@19.2.7): dependencies: - '@babel/runtime': 8.0.0 + '@babel/runtime': 7.29.7 dequal: 2.0.3 react: 19.2.7 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1ebd3530c..33a76ce4f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -217,8 +217,8 @@ minimumReleaseAgeExclude: # Renovate security update: esbuild@0.28.1 - esbuild@0.28.1 overrides: - "@babel/helpers@<7.26.10": ">=7.29.7" - "@babel/runtime@<7.26.10": ">=7.29.7" + "@babel/helpers@<7.26.10": "^7.29.7" + "@babel/runtime@<7.26.10": "^7.29.7" "axios@>=1.0.0 <1.8.2": ">=1.18.1" "brace-expansion@>=2.0.0 <=2.0.1": ">=5.0.7" "brace-expansion@>=1.0.0 <=1.1.11": ">=5.0.7" diff --git a/scripts/ci/workshop-act.sh b/scripts/ci/workshop-act.sh new file mode 100755 index 000000000..e0ff13b5f --- /dev/null +++ b/scripts/ci/workshop-act.sh @@ -0,0 +1,22 @@ +#!/bin/sh +set -eu + +if ! command -v act >/dev/null 2>&1; then + echo "act is required: https://nektosact.com/installation/" >&2 + exit 1 +fi + +socket="${DOCKER_HOST:-}" +if [ -z "$socket" ]; then + socket="$(docker context inspect --format '{{ (index .Endpoints "docker").Host }}' 2>/dev/null || true)" +fi +if [ -z "$socket" ]; then + socket="unix:///var/run/docker.sock" +fi + +exec act workflow_dispatch \ + --workflows .github/workflows/deployment-workshop.yml \ + --job deploy \ + --env ACT=true \ + --container-daemon-socket "$socket" \ + --container-architecture linux/amd64