From 2eb0baf17e8b8468138878e5238251de7d542d91 Mon Sep 17 00:00:00 2001 From: ajnart Date: Thu, 30 Jul 2026 15:19:17 +0200 Subject: [PATCH] refactor: trim v2 hardening scaffolding --- .coderabbit.yaml | 8 - .env.example | 6 +- .github/workflows/automatic-approval.yml | 2 +- .github/workflows/ci.yml | 224 ++------ .../workflows/crowdin-schedule-download.yml | 2 +- .github/workflows/deployment-beta-release.yml | 4 +- .github/workflows/deployment-docker-image.yml | 4 +- .github/workflows/deployment-docs.yml | 140 +---- .../workflows/deployment-weekly-release.yml | 4 +- .github/workflows/docs-algolia-recrawl.yml | 14 +- .github/workflows/on-release.yml | 35 +- .github/workflows/update-contributors.yml | 2 +- .github/workflows/update-integration-list.yml | 2 +- .github/workflows/workshop.yml | 43 +- README.md | 96 ---- apps/docs/README.md | 4 +- .../advanced/development/getting-started.mdx | 16 +- .../advanced/environment-variables/index.mdx | 2 - .../custom-widgets/agent-authoring.mdx | 18 - .../docs/management/custom-widgets/index.mdx | 7 +- .../custom-widgets/troubleshooting.mdx | 10 +- apps/docs/docs/management/workshop/index.mdx | 8 +- apps/nextjs/README.md | 14 +- .../[token]/page.tsx | 3 - apps/nextjs/src/app/[locale]/layout.tsx | 2 - .../custom-widgets/_custom-widget-actions.tsx | 14 +- .../manage/custom-widgets/edit/[id]/page.tsx | 4 - .../manage/custom-widgets/new/page.tsx | 4 - .../[locale]/manage/custom-widgets/page.tsx | 4 - .../custom-widgets/preview/[id]/page.tsx | 4 - .../nextjs/src/app/[locale]/manage/layout.tsx | 8 +- .../src/app/[locale]/manage/workshop/page.tsx | 1 - .../src/app/api/custom-widgets/admin.spec.ts | 10 - .../src/app/api/custom-widgets/admin.ts | 8 - .../src/app/api/mcp/[transport]/route.spec.ts | 25 - .../src/app/api/mcp/[transport]/route.ts | 8 +- .../board/items/item-select-modal.tsx | 17 +- .../custom-widgets-unavailable.tsx | 16 - .../workshop/workshop-css-import-button.tsx | 4 - .../workshop/workshop-install-button.tsx | 4 - .../workshop/workshop-publish-modal.tsx | 9 +- apps/nextjs/src/env.ts | 2 - apps/nextjs/src/hooks/use-runtime-feature.ts | 12 - apps/workshop/README.md | 21 +- package.json | 4 +- packages/api/src/env.ts | 4 - packages/api/src/router/board.ts | 9 +- .../board/custom-widget-placement-access.ts | 21 +- .../custom-widget-placement-access.spec.ts | 34 +- .../custom-widget/custom-widget-router.ts | 45 +- .../src/router/custom-widget/feature-flags.ts | 32 -- .../custom-widget/management-queries.ts | 17 +- .../custom-widget/metadata-procedures.ts | 14 +- .../preview-action-procedures.ts | 5 +- .../custom-widget/preview-base-procedures.ts | 50 +- .../custom-widget/preview-query-procedures.ts | 14 +- .../router/custom-widget/request-executor.ts | 2 - .../router/custom-widget/secret-procedures.ts | 14 +- .../custom-widget/template-procedures.ts | 39 +- .../custom-widget/transfer-procedures.ts | 25 +- .../custom-widget/workshop-procedures.ts | 11 +- .../src/router/test/widgets/options.spec.ts | 19 +- packages/api/src/router/widgets/custom-api.ts | 2 - packages/api/src/router/widgets/options.ts | 2 - packages/custom-widgets/package.json | 7 +- .../scripts/evaluate-mcp-agent.ts | 80 --- packages/custom-widgets/scripts/mcp-agent.ts | 322 ----------- .../custom-widgets/src/test/mcp-agent.spec.ts | 519 ------------------ packages/custom-widgets/tsconfig.scripts.json | 8 - packages/translation/src/lang/en.json | 5 - packages/widgets/src/custom-api/component.tsx | 17 +- pnpm-lock.yaml | 70 --- pnpm-workspace.yaml | 4 - scripts/ci/check-actions.sh | 17 - scripts/dev-workshop.mjs | 144 ----- 75 files changed, 334 insertions(+), 2067 deletions(-) delete mode 100644 .coderabbit.yaml delete mode 100644 README.md delete mode 100644 apps/nextjs/src/components/custom-widgets/custom-widgets-unavailable.tsx delete mode 100644 apps/nextjs/src/hooks/use-runtime-feature.ts delete mode 100644 packages/api/src/router/custom-widget/feature-flags.ts delete mode 100644 packages/custom-widgets/scripts/evaluate-mcp-agent.ts delete mode 100644 packages/custom-widgets/scripts/mcp-agent.ts delete mode 100644 packages/custom-widgets/src/test/mcp-agent.spec.ts delete mode 100644 packages/custom-widgets/tsconfig.scripts.json delete mode 100644 scripts/ci/check-actions.sh delete mode 100644 scripts/dev-workshop.mjs diff --git a/.coderabbit.yaml b/.coderabbit.yaml deleted file mode 100644 index b04edea28..000000000 --- a/.coderabbit.yaml +++ /dev/null @@ -1,8 +0,0 @@ -# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json -language: "en-US" -reviews: - auto_review: - enabled: true - drafts: false - base_branches: - - "v2" diff --git a/.env.example b/.env.example index 881cb42c6..14c729c92 100644 --- a/.env.example +++ b/.env.example @@ -70,17 +70,13 @@ HOMARR_WEBSITE_URL=https://homarr.dev # Public Workshop page opened from Homarr. Defaults to HOMARR_WEBSITE_URL/workshop. # WORKSHOP_WEB_URL=https://homarr.dev/workshop -# Emergency switches preserve stored data. They are enabled by default. -CUSTOM_WIDGETS_ENABLED=true -WORKSHOP_ENABLED=true - # Documentation and PocketBase deployment settings. # DOCS_BASE_URL=/ # WORKSHOP_PUBLIC_ORIGIN=https://homarr.dev # PB_ALLOWED_ORIGINS=* # WORKSHOP_REQUIRE_OAUTH=false -# Local Workshop stack (pnpm dev:workshop): +# Local Workshop stack: # HOMARR_WEBSITE_URL=http://127.0.0.1:3003 # WORKSHOP_API_URL=http://127.0.0.1:8090 # WORKSHOP_WEB_URL=http://127.0.0.1:3003/workshop diff --git a/.github/workflows/automatic-approval.yml b/.github/workflows/automatic-approval.yml index 8a28050f8..9e246f838 100644 --- a/.github/workflows/automatic-approval.yml +++ b/.github/workflows/automatic-approval.yml @@ -23,7 +23,7 @@ jobs: uses: actions/create-github-app-token@v3 with: private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }} - app-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }} + client-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }} permission-pull-requests: write # required to approve pull request - name: Install GitHub CLI diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8f118611e..57745bd94 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,24 +47,6 @@ env: PREVIEW_IMAGE: ghcr.io/homarr-labs/homarr-test jobs: - formatting: - name: Formatting - if: github.event_name != 'pull_request_target' && (github.event_name != 'workflow_dispatch' || inputs.operation != 'cleanup') - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@v7 - if: "!env.ACT" - with: - persist-credentials: false - ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }} - - - name: Setup - uses: ./tooling/github/setup - - - name: Check formatting - run: pnpm format - fast-gate: name: Fast gate if: github.event_name != 'pull_request_target' && (github.event_name != 'workflow_dispatch' || inputs.operation != 'cleanup') @@ -107,9 +89,6 @@ jobs: - name: Validate workspace configuration run: pnpm lint:ws - - name: Validate GitHub Actions - run: pnpm check:actions - - name: Validate Custom Widget architecture run: pnpm check:custom-widgets @@ -121,63 +100,12 @@ jobs: pnpm --filter nextjs build pnpm check:custom-widget-bundle - unit: - name: Full unit suite - if: github.event_name != 'pull_request_target' && (github.event_name != 'workflow_dispatch' || inputs.operation != 'cleanup') - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - uses: actions/checkout@v7 - if: "!env.ACT" - with: - persist-credentials: false - ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }} - - - name: Setup - uses: ./tooling/github/setup - - - name: Copy environment defaults - run: cp .env.example .env - - name: Run unit tests run: pnpm test - migration-matrix: - name: Database migrations (${{ matrix.database }}) - if: github.event_name != 'pull_request_target' && (github.event_name != 'workflow_dispatch' || inputs.operation != 'cleanup') - runs-on: ubuntu-latest - timeout-minutes: 15 - strategy: - fail-fast: false - matrix: - include: - - database: SQLite - test_file: packages/db/test/sqlite-migration.spec.ts - - database: MySQL - test_file: packages/db/test/mysql-migration.spec.ts - - database: PostgreSQL - test_file: packages/db/test/postgresql-migration.spec.ts - steps: - - uses: actions/checkout@v7 - if: "!env.ACT" - with: - persist-credentials: false - ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }} - - - name: Setup - uses: ./tooling/github/setup - - - name: Copy environment defaults - run: cp .env.example .env - - - name: Run ${{ matrix.database }} migration tests - env: - MIGRATION_TEST_FILE: ${{ matrix.test_file }} - run: pnpm exec vitest run --project db-node "$MIGRATION_TEST_FILE" - container: name: Container and E2E - needs: [formatting, fast-gate, unit, migration-matrix] + needs: fast-gate if: | github.event_name == 'merge_group' || (github.event_name == 'pull_request' && github.event.pull_request.draft == false) || @@ -310,12 +238,11 @@ jobs: run: echo "Act validated the publication path; external GHCR writes are disabled locally." preview-arm64: - name: Arm64 image - needs: [formatting, fast-gate, unit, migration-matrix] + name: Build arm64 preview image + needs: fast-gate if: | ( - github.event_name == 'merge_group' || - (github.event_name == 'pull_request' && github.event.pull_request.draft == false) || + (github.event_name == 'pull_request' && github.event.pull_request.draft == false && github.event.pull_request.head.repo.full_name == github.repository) || (github.event_name == 'workflow_dispatch' && inputs.operation == 'publish') ) runs-on: ubuntu-24.04-arm @@ -333,12 +260,7 @@ jobs: ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }} - name: Log in to GHCR - if: | - !env.ACT && - ( - (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || - (github.event_name == 'workflow_dispatch' && inputs.operation == 'publish') - ) + if: "!env.ACT" uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} @@ -351,12 +273,7 @@ jobs: - name: Build and push arm64 preview image by digest id: preview-build - if: | - !env.ACT && - ( - (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || - (github.event_name == 'workflow_dispatch' && inputs.operation == 'publish') - ) + if: "!env.ACT" uses: docker/build-push-action@v7 with: context: . @@ -371,34 +288,12 @@ jobs: TURBO_TOKEN=${{ secrets.TURBO_TOKEN }} TURBO_REMOTE_CACHE_SIGNATURE_KEY=${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }} - - name: Build arm64 image without publication - if: | - !env.ACT && - ( - github.event_name == 'merge_group' || - (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) - ) - uses: docker/build-push-action@v7 - with: - context: . - network: host - platforms: linux/arm64 - outputs: type=cacheonly - cache-from: type=gha,scope=homarr-linux-arm64 - cache-to: type=gha,scope=homarr-linux-arm64,mode=max - secrets: | - 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 }} - publish-preview: name: Publish multi-platform preview image needs: [container, preview-arm64] if: | ( - github.event_name == 'merge_group' || - github.event_name == 'pull_request' || + (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || (github.event_name == 'workflow_dispatch' && inputs.operation == 'publish') ) runs-on: ubuntu-latest @@ -411,12 +306,7 @@ jobs: PREVIEW_TAG: pr-${{ github.event.pull_request.number || inputs.pr_number }} steps: - name: Log in to GHCR - if: | - !env.ACT && - ( - (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || - (github.event_name == 'workflow_dispatch' && inputs.operation == 'publish') - ) + if: "!env.ACT" uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} @@ -424,17 +314,11 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Set up Docker Buildx - if: | - !env.ACT && - ( - (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || - (github.event_name == 'workflow_dispatch' && inputs.operation == 'publish') - ) + if: "!env.ACT" uses: docker/setup-buildx-action@v4 - name: Verify PR head before publication - id: publication-preflight - if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + if: github.event_name == 'pull_request' uses: actions/github-script@v8 env: EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha }} @@ -446,28 +330,15 @@ jobs: pull_number: context.payload.pull_request.number, }); if (pull.state !== "open") { - core.notice(`PR #${pull.number} is ${pull.state}; preview publication is no longer needed.`); - core.setOutput("publish", "false"); + core.setFailed(`PR #${pull.number} is ${pull.state}; refusing to publish its preview.`); return; } if (pull.head.sha !== process.env.EXPECTED_HEAD_SHA) { - core.notice(`PR #${pull.number} moved to ${pull.head.sha}; a newer run owns preview publication.`); - core.setOutput("publish", "false"); - return; + core.setFailed(`PR #${pull.number} moved from ${process.env.EXPECTED_HEAD_SHA} to ${pull.head.sha}.`); } - core.setOutput("publish", "true"); - name: Publish preview manifest - if: | - !env.ACT && - ( - ( - github.event_name == 'pull_request' && - github.event.pull_request.head.repo.full_name == github.repository && - steps.publication-preflight.outputs.publish == 'true' - ) || - (github.event_name == 'workflow_dispatch' && inputs.operation == 'publish') - ) + if: "!env.ACT" env: AMD64_DIGEST: ${{ needs.container.outputs.digest }} ARM64_DIGEST: ${{ needs.preview-arm64.outputs.digest }} @@ -477,16 +348,7 @@ jobs: "${PREVIEW_IMAGE}@${ARM64_DIGEST}" - name: Verify preview manifest platforms - if: | - !env.ACT && - ( - ( - github.event_name == 'pull_request' && - github.event.pull_request.head.repo.full_name == github.repository && - steps.publication-preflight.outputs.publish == 'true' - ) || - (github.event_name == 'workflow_dispatch' && inputs.operation == 'publish') - ) + if: "!env.ACT" run: | image="${PREVIEW_IMAGE}:${PREVIEW_TAG}" manifest="$(docker buildx imagetools inspect "$image" --raw)" @@ -497,12 +359,6 @@ jobs: done docker buildx imagetools inspect "$image" - - name: Confirm multi-platform preview validation - if: | - github.event_name == 'merge_group' || - (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) - run: echo "Both platform image builds completed; registry publication is intentionally disabled for this event." - cleanup: name: Clean up preview image if: github.event_name == 'pull_request_target' || (github.event_name == 'workflow_dispatch' && inputs.operation == 'cleanup') @@ -527,42 +383,24 @@ jobs: const packageName = "homarr-test"; const org = context.repo.owner; const tag = process.env.IMAGE_TAG; - const attempts = context.eventName === "pull_request_target" ? 7 : 1; - for (let attempt = 1; attempt <= attempts; attempt += 1) { - let versions; - try { - versions = await github.paginate( - github.rest.packages.getAllPackageVersionsForPackageOwnedByOrg, - { package_type: "container", package_name: packageName, org, per_page: 100 }, - ); - } catch (error) { - if (error.status === 404) { - core.info(`Package "${packageName}" does not exist; cleanup is already complete.`); - return; - } - throw error; - } - const targets = versions.filter((version) => - version.metadata?.container?.tags?.includes(tag), - ); - if (targets.length > 0) { - for (const target of targets) { - await github.rest.packages.deletePackageVersionForOrg({ - package_type: "container", - package_name: packageName, - org, - package_version_id: target.id, - }); - core.info(`Deleted package version ${target.id} (${tag})`); - } - return; - } - if (attempt < attempts) { - core.info(`No "${tag}" version found yet; retrying cleanup after publication settles.`); - await new Promise((resolve) => setTimeout(resolve, 5_000)); - } + const versions = await github.paginate( + github.rest.packages.getAllPackageVersionsForPackageOwnedByOrg, + { package_type: "container", package_name: packageName, org, per_page: 100 }, + ); + const target = versions.find((version) => + version.metadata?.container?.tags?.includes(tag), + ); + if (!target) { + core.info(`No package version found with tag "${tag}"`); + return; } - core.info(`No package version found with tag "${tag}"; cleanup is already complete.`); + await github.rest.packages.deletePackageVersionForOrg({ + package_type: "container", + package_name: packageName, + org, + package_version_id: target.id, + }); + core.info(`Deleted package version ${target.id} (${tag})`); - name: Show Act cleanup result if: env.ACT diff --git a/.github/workflows/crowdin-schedule-download.yml b/.github/workflows/crowdin-schedule-download.yml index 7b783f4e5..22cb2b943 100644 --- a/.github/workflows/crowdin-schedule-download.yml +++ b/.github/workflows/crowdin-schedule-download.yml @@ -22,7 +22,7 @@ jobs: uses: actions/create-github-app-token@v3 with: private-key: ${{ secrets.CROWDIN_APP_PRIVATE_KEY }} - app-id: ${{ secrets.CROWDIN_APP_ID }} + client-id: ${{ secrets.CROWDIN_APP_ID }} permission-contents: write # required to commit to crowdin branch permission-pull-requests: write # required to create pull request diff --git a/.github/workflows/deployment-beta-release.yml b/.github/workflows/deployment-beta-release.yml index 0f0725bef..ba3bde3a2 100644 --- a/.github/workflows/deployment-beta-release.yml +++ b/.github/workflows/deployment-beta-release.yml @@ -52,7 +52,7 @@ jobs: uses: actions/create-github-app-token@v3 with: private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }} - app-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }} + client-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }} permission-pull-requests: write - name: Approve PR env: @@ -64,7 +64,7 @@ jobs: uses: actions/create-github-app-token@v3 with: private-key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }} - app-id: ${{ secrets.RENOVATE_MERGE_APP_ID }} + client-id: ${{ secrets.RENOVATE_MERGE_APP_ID }} permission-contents: write # write to beta branch (due to merge) permission-pull-requests: write # merge pull request - id: automerge diff --git a/.github/workflows/deployment-docker-image.yml b/.github/workflows/deployment-docker-image.yml index 3acae9b6a..9ea0c2a09 100644 --- a/.github/workflows/deployment-docker-image.yml +++ b/.github/workflows/deployment-docker-image.yml @@ -54,7 +54,7 @@ jobs: uses: actions/create-github-app-token@v3 with: private-key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }} - app-id: ${{ secrets.RENOVATE_MERGE_APP_ID }} + client-id: ${{ secrets.RENOVATE_MERGE_APP_ID }} permission-contents: write # required to commit package.json & changelog changes, merge them to dev / next and publish the release - uses: actions/checkout@v7 @@ -246,7 +246,7 @@ jobs: uses: actions/create-github-app-token@v3 with: private-key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }} - app-id: ${{ secrets.RENOVATE_MERGE_APP_ID }} + client-id: ${{ secrets.RENOVATE_MERGE_APP_ID }} permission-contents: write # required to publish the release - name: Log in to the Container registry diff --git a/.github/workflows/deployment-docs.yml b/.github/workflows/deployment-docs.yml index 7ce023e52..eeb53d101 100644 --- a/.github/workflows/deployment-docs.yml +++ b/.github/workflows/deployment-docs.yml @@ -1,136 +1,56 @@ -name: "[Publish] Documentation and Workshop" +name: "[Deployment] Documentation" on: push: - branches: ["v2"] + branches: ["dev"] paths: - "apps/docs/**" - - "apps/workshop/**" - - "packages/custom-widgets/**" - - "packages/workshop/**" - - "packages/definitions/**" - - "tooling/typescript/**" - - "patches/**" - - "package.json" - - "pnpm-lock.yaml" - - "pnpm-workspace.yaml" - - "turbo.json" - - ".dockerignore" + - "packages/definitions/src/docs/**" - ".github/workflows/deployment-docs.yml" workflow_dispatch: permissions: contents: read - packages: write + pages: write id-token: write concurrency: - group: workshop-image-${{ github.ref }} - cancel-in-progress: true + group: "pages" + cancel-in-progress: false env: - REGISTRY: ghcr.io - IMAGE_NAME: ghcr.io/homarr-labs/homarr-workshop + 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: - publish: - name: Publish immutable multi-platform image + build: runs-on: ubuntu-latest - timeout-minutes: 45 - outputs: - digest: ${{ steps.build.outputs.digest }} + timeout-minutes: 10 steps: - uses: actions/checkout@v7 - with: - persist-credentials: false - - - name: Set up QEMU - uses: docker/setup-qemu-action@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 - - - name: Log in to GHCR - uses: docker/login-action@v4 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Build and publish by immutable commit tag - id: build - uses: docker/build-push-action@v7 - with: - context: . - file: apps/workshop/Dockerfile - target: production - platforms: linux/amd64,linux/arm64 - push: true - tags: ${{ env.IMAGE_NAME }}:sha-${{ github.sha }} - labels: | - org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} - org.opencontainers.image.revision=${{ github.sha }} - provenance: mode=max - sbom: true - cache-from: type=gha,scope=workshop-production - cache-to: type=gha,scope=workshop-production,mode=max - secrets: | - 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 }} - - - name: Smoke-test the published digest + - name: Setup + uses: ./tooling/github/setup + - name: Build docs + run: pnpm turbo build --filter=@homarr/docs env: - IMAGE_DIGEST: ${{ steps.build.outputs.digest }} - run: | - manifest="$(docker buildx imagetools inspect "${IMAGE_NAME}@${IMAGE_DIGEST}" --raw)" - for architecture in amd64 arm64; do - jq --exit-status --arg architecture "$architecture" \ - 'any(.manifests[]; .platform.os == "linux" and .platform.architecture == $architecture)' \ - <<<"$manifest" >/dev/null - done - docker run --detach --name workshop-smoke --platform linux/amd64 \ - --publish 127.0.0.1:18090:8090 \ - --env HOMARR_WEBSITE_URL=http://127.0.0.1:18090 \ - --env WORKSHOP_API_URL=http://127.0.0.1:18090 \ - --env WORKSHOP_WEB_URL=http://127.0.0.1:18090/workshop \ - --env WORKSHOP_PUBLIC_ORIGIN=http://127.0.0.1:18090 \ - --env WORKSHOP_REQUIRE_OAUTH=false \ - "${IMAGE_NAME}@${IMAGE_DIGEST}" - for attempt in $(seq 1 60); do - if curl --fail --silent http://127.0.0.1:18090/api/health >/dev/null; then - break - fi - if [ "$attempt" -eq 60 ]; then - docker logs workshop-smoke - exit 1 - fi - sleep 1 - done - curl --fail --silent --show-error http://127.0.0.1:18090/ >/dev/null - curl --location --fail --silent --show-error http://127.0.0.1:18090/workshop >/dev/null - curl --fail --silent --show-error \ - 'http://127.0.0.1:18090/api/collections/workshop_listings/records?page=1&perPage=1' >/dev/null - curl --fail --silent --show-error http://127.0.0.1:18090/workshop-runtime-config.js \ - | grep --fixed-strings 'workshopApiUrl: "http://127.0.0.1:18090"' + 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" - - name: Stop smoke container - if: always() - run: docker rm --force workshop-smoke >/dev/null 2>&1 || true - - - name: Record immutable promotion input - env: - IMAGE_DIGEST: ${{ steps.build.outputs.digest }} - run: | - { - echo "### Workshop image candidate" - echo - echo "\`${IMAGE_NAME}@${IMAGE_DIGEST}\`" - echo - echo "This workflow publishes but does not deploy. Promote this exact digest only after staging acceptance and rollback rehearsal." - } >>"$GITHUB_STEP_SUMMARY" + 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-weekly-release.yml b/.github/workflows/deployment-weekly-release.yml index a35235308..cafa7b30d 100644 --- a/.github/workflows/deployment-weekly-release.yml +++ b/.github/workflows/deployment-weekly-release.yml @@ -55,7 +55,7 @@ jobs: uses: actions/create-github-app-token@v3 with: private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }} - app-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }} + client-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }} permission-pull-requests: write - name: Approve PR env: @@ -67,7 +67,7 @@ jobs: uses: actions/create-github-app-token@v3 with: private-key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }} - app-id: ${{ secrets.RENOVATE_MERGE_APP_ID }} + client-id: ${{ secrets.RENOVATE_MERGE_APP_ID }} permission-contents: write # write to main branch (due to merge) permission-pull-requests: write # merge pull request - id: automerge diff --git a/.github/workflows/docs-algolia-recrawl.yml b/.github/workflows/docs-algolia-recrawl.yml index 3764eab74..1d8bdd42d 100644 --- a/.github/workflows/docs-algolia-recrawl.yml +++ b/.github/workflows/docs-algolia-recrawl.yml @@ -1,9 +1,10 @@ name: "[Docs] Algolia Recrawl" -# Automatic recrawls are intentionally disabled until the external homarr.dev -# host can dispatch this workflow after promoting an immutable Workshop image. -# Publishing an image to GHCR does not make its documentation live. on: + workflow_run: + workflows: ["[Deployment] Documentation"] + types: [completed] + branches: [dev] workflow_dispatch: permissions: @@ -11,6 +12,11 @@ permissions: jobs: recrawl: + if: >- + ${{ + (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event != 'pull_request') + || github.event_name == 'workflow_dispatch' + }} runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -37,7 +43,7 @@ jobs: ALGOLIA_CRAWLER_USER_ID: ${{ secrets.ALGOLIA_CRAWLER_USER_ID }} ALGOLIA_CRAWLER_API_KEY: ${{ secrets.ALGOLIA_CRAWLER_API_KEY }} - - name: Allow the promoted documentation host to settle + - name: Wait for GitHub Pages to serve the new deployment run: sleep 60 - name: Trigger Algolia DocSearch recrawl diff --git a/.github/workflows/on-release.yml b/.github/workflows/on-release.yml index 80a94bd1f..644f4a74d 100644 --- a/.github/workflows/on-release.yml +++ b/.github/workflows/on-release.yml @@ -5,6 +5,39 @@ on: types: [released] jobs: + trigger-docs-release: + name: Trigger Documentation Release + runs-on: ubuntu-latest + timeout-minutes: 2 + steps: + - name: Obtain token + id: obtainToken + uses: actions/create-github-app-token@v3 + with: + private-key: ${{ secrets.HOMARR_DOCS_RELEASE_APP_PRIVATE_KEY }} + client-id: ${{ vars.HOMARR_DOCS_RELEASE_APP_ID }} + owner: homarr-labs + repositories: | + documentation + permission-contents: write # required to dispatch repository workflow + - name: Trigger documentation release + env: + GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }} + SOURCE_TAG: ${{ github.event.release.tag_name }} + run: | + curl -X POST \ + -H "Authorization: token $GITHUB_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + https://api.github.com/repos/homarr-labs/documentation/dispatches \ + -d @- </dev/null; then - break - fi - if [ "$attempt" -eq 60 ]; then - docker logs workshop-base-path-smoke - exit 1 - fi - sleep 1 - done - curl --fail --silent --show-error http://127.0.0.1:18093/docs-site/ \ - | grep --fixed-strings '/docs-site/assets/' - curl --location --fail --silent --show-error http://127.0.0.1:18093/docs-site/workshop/ \ - | grep --fixed-strings '/docs-site/workshop/' - curl --fail --silent --show-error \ - http://127.0.0.1:18093/docs-site/workshop-runtime-config.js \ - | grep --fixed-strings 'workshopWebUrl: "http://127.0.0.1:18093/docs-site/workshop"' - curl --fail --silent --show-error \ - 'http://127.0.0.1:18093/api/collections/workshop_listings/records?page=1&perPage=1' >/dev/null - - name: Stop non-root documentation smoke container - if: always() - run: docker rm --force workshop-base-path-smoke >/dev/null 2>&1 || true diff --git a/README.md b/README.md deleted file mode 100644 index cf79ddbbc..000000000 --- a/README.md +++ /dev/null @@ -1,96 +0,0 @@ -# Homarr development - -This repository contains the Homarr application, task and WebSocket services, the Docusaurus documentation site, and -the PocketBase-backed Community Workshop. - -## Prerequisites - -- Node.js 24.18 or newer -- Corepack with pnpm 11.11 -- Docker with Docker Compose -- Git - -Enable the repository's pnpm version and install dependencies: - -```sh -corepack enable -corepack install -pnpm install --frozen-lockfile -``` - -## Run Homarr locally - -Copy the environment template and choose a writable absolute SQLite path: - -```sh -cp .env.example .env -``` - -Keep the development infrastructure running in the first terminal: - -```sh -pnpm docker:dev -``` - -Then apply migrations and start Homarr in a second terminal: - -```sh -pnpm db:migration:sqlite:run -pnpm dev -``` - -Homarr is available at `http://127.0.0.1:3000`. The foreground infrastructure command starts Redis and the optional -database services from `development/development.docker-compose.yml`; stop it with Ctrl+C after stopping Homarr. - -The unreleased v2 Custom Widget migration history is intentionally consolidated before release. A development database -that already applied the earlier `custom_widget_v2_reset` migration is unsupported: back up anything you need, reset -only that disposable database, and run the migration command again. Homarr never deletes or resets it automatically. - -## Run PocketBase and documentation locally - -The integrated command starts persistent PocketBase on port 8090, waits for its health endpoint and Workshop -collections, then starts Docusaurus on port 3003: - -```sh -pnpm dev:workshop -``` - -Open the PocketBase administration UI at `http://127.0.0.1:8090/_/` to create the first superuser. Open the Workshop at -`http://127.0.0.1:3003/workshop`. - -To run Homarr against this local stack, set these values in `.env` and restart `pnpm dev`: - -```dotenv -HOMARR_WEBSITE_URL=http://127.0.0.1:3003 -WORKSHOP_API_URL=http://127.0.0.1:8090 -WORKSHOP_WEB_URL=http://127.0.0.1:3003/workshop -``` - -Stopping `pnpm dev:workshop` stops the services but retains the `homarr-workshop_pb_data` volume. See -[`apps/workshop/README.md`](apps/workshop/README.md) for OAuth, moderator access, logs, backup, restore, and intentional -reset instructions. - -## Common checks - -```sh -pnpm format -pnpm lint -pnpm typecheck -pnpm test -pnpm test:workshop -pnpm turbo build --filter=@homarr/docs -``` - -Use `pnpm format:fix` and `pnpm lint:fix` to apply mechanical fixes. User-facing code changes must include the matching -documentation under `apps/docs/docs/`. - -## Repository map - -- `apps/nextjs` — main Homarr web application -- `apps/tasks` — scheduled task runner and API -- `apps/websocket` — WebSocket server -- `apps/docs` — Docusaurus documentation and public Workshop UI -- `apps/workshop` — PocketBase image, migrations, hooks, and integration tests -- `packages` — shared application, API, database, UI, widget, and integration packages -- `development` — local Docker infrastructure -- `e2e` — end-to-end tests diff --git a/apps/docs/README.md b/apps/docs/README.md index 01eaf6842..15639cee1 100644 --- a/apps/docs/README.md +++ b/apps/docs/README.md @@ -22,8 +22,8 @@ pnpm dev:docs # Or from within this directory pnpm dev -# PocketBase on 8090 plus docs on 3003 -pnpm dev:workshop +# Point the docs at a local PocketBase instance +WORKSHOP_API_URL=http://127.0.0.1:8090 pnpm dev:docs ``` The standalone docs use `WORKSHOP_API_URL` for PocketBase, `HOMARR_WEBSITE_URL` for the public site origin, and diff --git a/apps/docs/docs/advanced/development/getting-started.mdx b/apps/docs/docs/advanced/development/getting-started.mdx index e18559dc4..f8cb00e24 100644 --- a/apps/docs/docs/advanced/development/getting-started.mdx +++ b/apps/docs/docs/advanced/development/getting-started.mdx @@ -55,14 +55,20 @@ Homarr becomes the instance administrator. ### Run PocketBase and documentation -Use the integrated local Workshop command in a second terminal: +Start PocketBase in another terminal: ```sh -pnpm dev:workshop +cp apps/workshop/.env.example apps/workshop/.env +docker compose --env-file apps/workshop/.env -f apps/workshop/docker-compose.yml up --build workshop ``` -It starts persistent PocketBase on port 8090, waits for health and the required collections, then starts Docusaurus on -port 3003. It also propagates shutdown without deleting the PocketBase volume. +Then start Docusaurus in a third terminal: + +```sh +WORKSHOP_API_URL=http://127.0.0.1:8090 pnpm dev:docs +``` + +PocketBase runs on port 8090 and Docusaurus on port 3003. Stopping the services preserves the PocketBase volume. Open `http://127.0.0.1:8090/_/` and create the first PocketBase superuser. The Workshop UI is available at `http://127.0.0.1:3003/workshop`. @@ -97,7 +103,7 @@ pnpm dev:docs - `pnpm dev` — Next.js on port 3000. - `pnpm dev:docs` — Docusaurus only on port 3003. -- `pnpm dev:workshop` — persistent PocketBase plus Docusaurus. +- `pnpm docker:workshop` — local PocketBase through Docker Compose. - `pnpm cli` — Homarr operations CLI. - `pnpm db:migration:sqlite:run` — create or update a SQLite database. - `pnpm db:studio` — inspect the database. diff --git a/apps/docs/docs/advanced/environment-variables/index.mdx b/apps/docs/docs/advanced/environment-variables/index.mdx index 9cebbefa9..a42729a8a 100644 --- a/apps/docs/docs/advanced/environment-variables/index.mdx +++ b/apps/docs/docs/advanced/environment-variables/index.mdx @@ -35,8 +35,6 @@ Using the `PUID` and `PGID` will require you to set the correct permissions on t | `HOMARR_WEBSITE_URL` | Main Homarr documentation/site base used by runtime links. Must be an absolute HTTP(S) URL. | HTTP(S) URL | `https://homarr.dev` | | `WORKSHOP_API_URL` | PocketBase API used by Homarr and standalone documentation. | HTTP(S) URL | `HOMARR_WEBSITE_URL` | | `WORKSHOP_WEB_URL` | Public Workshop page opened from Homarr. | HTTP(S) URL | `HOMARR_WEBSITE_URL/workshop` | -| `CUSTOM_WIDGETS_ENABLED` | Emergency switch. `false` preserves definitions and placements but blocks Custom Widget authoring and execution. | `true` or `false` | `true` | -| `WORKSHOP_ENABLED` | Emergency switch. `false` hides and blocks Workshop operations without disabling installed widgets. | `true` or `false` | `true` | ## Authentication diff --git a/apps/docs/docs/management/custom-widgets/agent-authoring.mdx b/apps/docs/docs/management/custom-widgets/agent-authoring.mdx index c86fa550c..25afa3724 100644 --- a/apps/docs/docs/management/custom-widgets/agent-authoring.mdx +++ b/apps/docs/docs/management/custom-widgets/agent-authoring.mdx @@ -101,22 +101,4 @@ per-scenario limit with `OPENROUTER_GENERATOR_MODEL`, `OPENROUTER_JUDGE_MODEL`, `CUSTOM_WIDGET_AI_MAX_LOOPS`. Generated artifacts are local and ignored by Git; promote a reviewed, validated result into the bundled-widget source deliberately. -To exercise the connected MCP workflow with the Vercel AI SDK agent, start Homarr and create an API key for an administrator: - -```sh -OPENROUTER_API_KEY=... \ -HOMARR_MCP_URL=http://localhost:3000/api/mcp/mcp \ -HOMARR_MCP_API_KEY=... \ -pnpm --filter @homarr/custom-widgets evaluate:mcp -- --case=pokedex -``` - -This evaluator uses DeepSeek V4 Pro with `ToolLoopAgent` and a strict structured result. It requires successful schema -and validation results, one successful preview call for every scenario query and action, and a journal read after those -calls. It can repair and retest a widget, but it does not persist by default. Add `--persist` to create a passing widget, -or `--definition= --persist` to test the edit/debug/update path. Authenticated sources use -`customWidget_configurationRequestUser`; credential-writing tools are not exposed to the model and plaintext credentials -never enter its context. A passing result is rejected until that request reports `completed` and the required preview -queries and simulated actions succeed afterward. The deterministic unit suite separately proves the repair, persistence, -and secure source-configuration loop without contacting a model or changing a Homarr instance. - The reference API check uses a real PokeAPI list/detail request and a read-only Portainer container query when `PORTAINER_BASE_URL` and `PORTAINER_API_KEY` are set. Use `pnpm --filter @homarr/custom-widgets test:reference-apis -- --pokeapi-only` for the public PokeAPI check alone. To exercise start, restart, and stop against a disposable container, also set `PORTAINER_TEST_CONTAINER_ID` and the explicit safeguard `PORTAINER_ALLOW_MUTATIONS=1`; the script restores the container's original running state. diff --git a/apps/docs/docs/management/custom-widgets/index.mdx b/apps/docs/docs/management/custom-widgets/index.mdx index ec4090084..508dde70c 100644 --- a/apps/docs/docs/management/custom-widgets/index.mdx +++ b/apps/docs/docs/management/custom-widgets/index.mdx @@ -117,10 +117,9 @@ URL also requires the same authenticated administrator; the URL is short-lived, Custom Widget response caches and action invalidation use shared Redis state, so multiple Homarr replicas see the same versioned cache generation. Deployments must point every replica at the same Redis service. -Set `CUSTOM_WIDGETS_ENABLED=false` to stop authoring and runtime execution while preserving definitions, secrets, board -placements, and v1 backups. Re-enable it after correcting the incident. Before upgrading, back up the database and -`SECRET_ENCRYPTION_KEY`; a v1 binary can still read preserved v1 tables during application rollback. Widgets created -only in v2 may be unavailable to the rolled-back binary, but do not replace the v1 backup. +Before upgrading, back up the database and `SECRET_ENCRYPTION_KEY`; a v1 binary can still read preserved v1 tables +during application rollback. Widgets created only in v2 may be unavailable to the rolled-back binary, but do not +replace the v1 backup. ## Bundled examples diff --git a/apps/docs/docs/management/custom-widgets/troubleshooting.mdx b/apps/docs/docs/management/custom-widgets/troubleshooting.mdx index d642b2751..068d8a21e 100644 --- a/apps/docs/docs/management/custom-widgets/troubleshooting.mdx +++ b/apps/docs/docs/management/custom-widgets/troubleshooting.mdx @@ -43,14 +43,10 @@ An invalid definition disables widget preview and Save/Create. The Data, Options The Diagnostics tab shows the current schema errors. -## Feature unavailable +## Workshop unavailable -If every Custom Widget reports that the feature is unavailable, check `CUSTOM_WIDGETS_ENABLED`. Setting it to `false` -is an emergency stop that preserves definitions, secrets, legacy backups, and board placements while blocking authoring -and execution. - -If Workshop alone is unavailable, check `WORKSHOP_ENABLED` and `WORKSHOP_API_URL`. Disabling Workshop does not disable -widgets that are already installed. +If Workshop is unavailable, check `WORKSHOP_API_URL` and PocketBase health. Widgets that are already installed continue +to work. ## Correct request errors diff --git a/apps/docs/docs/management/workshop/index.mdx b/apps/docs/docs/management/workshop/index.mdx index cf6f43b71..a490afbc6 100644 --- a/apps/docs/docs/management/workshop/index.mdx +++ b/apps/docs/docs/management/workshop/index.mdx @@ -87,8 +87,9 @@ Custom Widget imports detect required bearer, basic, header, or query credential 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. -Run `pnpm dev:workshop` for the recommended local stack: persistent PocketBase on port 8090 and Docusaurus on port 3003. The command waits for health and collections and preserves the `pb_data` volume on shutdown. `pnpm docker:docs` -builds the combined production layout. Normal start and stop commands retain data; `down --volumes` is an intentional, +Run `pnpm docker:workshop` for PocketBase on port 8090, then run +`WORKSHOP_API_URL=http://127.0.0.1:8090 pnpm dev:docs` for Docusaurus on port 3003. `pnpm docker:docs` builds the +combined production layout. Normal start and stop commands retain data; `down --volumes` is an intentional, irreversible development reset. GitHub needs only the central callback `https:///api/oauth2-redirect`. Keep PocketBase origins unrestricted @@ -112,8 +113,7 @@ server-renders runtime values for the browser, and the combined image writes a p startup. Restart services after changes. Back up application and PocketBase data before deployment. Publish the combined image by immutable digest, test the -exact digest in staging, rehearse application rollback and PocketBase restore, then promote that same digest. The -`WORKSHOP_ENABLED=false` switch disables Homarr's Workshop entry points without affecting installed widgets. +exact digest in staging, rehearse application rollback and PocketBase restore, then promote that same digest. ## Client architecture diff --git a/apps/nextjs/README.md b/apps/nextjs/README.md index f4a6543bf..6cecb43d3 100644 --- a/apps/nextjs/README.md +++ b/apps/nextjs/README.md @@ -26,7 +26,14 @@ pnpm dev The app listens on `http://127.0.0.1:3000`. Configure an absolute writable `DB_URL`, `AUTH_SECRET`, and `SECRET_ENCRYPTION_KEY` in `.env` before first startup. -To test Custom Widgets against a local Community Workshop, run `pnpm dev:workshop` in another terminal and set: +To test Custom Widgets against a local Community Workshop, start PocketBase and the docs in separate terminals: + +```sh +pnpm docker:workshop +WORKSHOP_API_URL=http://127.0.0.1:8090 pnpm dev:docs +``` + +Then set: ```dotenv HOMARR_WEBSITE_URL=http://127.0.0.1:3003 @@ -37,6 +44,5 @@ WORKSHOP_WEB_URL=http://127.0.0.1:3003/workshop Server-rendered runtime configuration exposes those URLs to the browser, so changing a production container's environment does not require rebuilding the Next.js bundle. -See the repository [`README.md`](../../README.md) for validation commands and -[`apps/docs/docs/advanced/development/getting-started.mdx`](../docs/docs/advanced/development/getting-started.mdx) for -the full contributor setup. +See [`apps/docs/docs/advanced/development/getting-started.mdx`](../docs/docs/advanced/development/getting-started.mdx) +for the full contributor setup. diff --git a/apps/nextjs/src/app/[locale]/custom-widget-configuration/[token]/page.tsx b/apps/nextjs/src/app/[locale]/custom-widget-configuration/[token]/page.tsx index 97b20e274..6485ec80e 100644 --- a/apps/nextjs/src/app/[locale]/custom-widget-configuration/[token]/page.tsx +++ b/apps/nextjs/src/app/[locale]/custom-widget-configuration/[token]/page.tsx @@ -3,8 +3,6 @@ import { redirect } from "next/navigation"; import { auth } from "@homarr/auth/next"; -import { CustomWidgetsUnavailable } from "~/components/custom-widgets/custom-widgets-unavailable"; -import { env } from "~/env"; import { CustomWidgetConfigurationEntry } from "./configuration-entry"; export const metadata: Metadata = { @@ -15,7 +13,6 @@ export const metadata: Metadata = { export default async function CustomWidgetConfigurationPage({ params }: { params: Promise<{ token: string }> }) { const session = await auth(); if (!session?.user.permissions.includes("admin")) redirect(session ? "/" : "/auth/login"); - if (env.CUSTOM_WIDGETS_ENABLED === false) return ; const { token } = await params; return ; } diff --git a/apps/nextjs/src/app/[locale]/layout.tsx b/apps/nextjs/src/app/[locale]/layout.tsx index 2d37c089d..9d94da2aa 100644 --- a/apps/nextjs/src/app/[locale]/layout.tsx +++ b/apps/nextjs/src/app/[locale]/layout.tsx @@ -149,8 +149,6 @@ export default async function Layout(props: { - - diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx index 7aafe51be..441ccec03 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx @@ -24,7 +24,6 @@ import { useScopedI18n } from "@homarr/translation/client"; import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog"; import { WorkshopPublishModal } from "~/components/workshop/workshop-publish-modal"; -import { useRuntimeFeature } from "~/hooks/use-runtime-feature"; const iconProps = { size: 16, stroke: 1.5 }; @@ -38,7 +37,6 @@ interface WidgetRef { export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => { const t = useScopedI18n("customWidget"); - const workshopEnabled = useRuntimeFeature("workshop"); const { openConfirmModal } = useConfirmModal(); const deleteMutation = clientApi.customWidget.delete.useMutation(); const duplicateMutation = clientApi.customWidget.duplicate.useMutation(); @@ -189,11 +187,9 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => { void handleExport()} leftSection={}> {t("action.export")} - {workshopEnabled && ( - }> - {t("action.publishWorkshop")} - - )} + }> + {t("action.publishWorkshop")} + )} @@ -226,9 +222,7 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => { )} - {widget.valid && workshopEnabled && ( - - )} + {widget.valid && } {widget.migrationRequired && ( <> ; - const params = await props.params; const definition = await api.customWidget.get({ id: params.id }).catch(catchTrpcNotFound); diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/new/page.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/new/page.tsx index cad930b02..52c114432 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/new/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/new/page.tsx @@ -5,8 +5,6 @@ import { auth } from "@homarr/auth/next"; import { getScopedI18n } from "@homarr/translation/server"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; -import { CustomWidgetsUnavailable } from "~/components/custom-widgets/custom-widgets-unavailable"; -import { env } from "~/env"; import { CustomWidgetBetaBanner } from "../_beta-banner"; import { CustomWidgetForm } from "../_custom-widget-form"; import { FormErrorBoundary } from "../_form-error-boundary"; @@ -16,8 +14,6 @@ export default async function NewCustomWidgetPage() { if (!session || !session.user.permissions.includes("admin")) { redirect("/manage/custom-widgets"); } - if (env.CUSTOM_WIDGETS_ENABLED === false) return ; - const t = await getScopedI18n("customWidget"); return ( 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 be63ef3a8..21541929f 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/page.tsx @@ -9,8 +9,6 @@ import { Link } from "@homarr/ui"; import { ManagePageLayout } from "~/components/manage/manage-page-layout"; import { MobileAffixButton } from "~/components/manage/mobile-affix-button"; -import { CustomWidgetsUnavailable } from "~/components/custom-widgets/custom-widgets-unavailable"; -import { env } from "~/env"; import { CustomWidgetBetaBanner } from "./_beta-banner"; import { WorkshopInstallButton } from "~/components/workshop/workshop-install-button"; import { ImportCustomWidgetButton } from "./_import-custom-widget-button"; @@ -22,8 +20,6 @@ export default async function CustomWidgetsPage() { if (!session || !session.user.permissions.includes("admin")) { redirect(session ? "/" : "/auth/login"); } - if (env.CUSTOM_WIDGETS_ENABLED === false) return ; - const definitions = await api.customWidget.list(); const t = await getScopedI18n("customWidget"); diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/preview/[id]/page.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/preview/[id]/page.tsx index 4760d320b..93fb6681a 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/preview/[id]/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/preview/[id]/page.tsx @@ -5,13 +5,9 @@ import { api } from "@homarr/api/server"; import { auth } from "@homarr/auth/next"; import CustomJsxDisplay from "@homarr/widgets/custom-api/custom-jsx-display"; -import { CustomWidgetsUnavailable } from "~/components/custom-widgets/custom-widgets-unavailable"; -import { env } from "~/env"; - export default async function CustomWidgetPreviewPage({ params }: { params: Promise<{ id: string }> }) { const session = await auth(); if (!session?.user.permissions.includes("admin")) redirect("/"); - if (env.CUSTOM_WIDGETS_ENABLED === false) return ; const { id } = await params; const preview = await api.customWidget.previewGet({ sessionId: id }).catch(() => null); if (!preview) notFound(); diff --git a/apps/nextjs/src/app/[locale]/manage/layout.tsx b/apps/nextjs/src/app/[locale]/manage/layout.tsx index 992dbae6c..f608189e0 100644 --- a/apps/nextjs/src/app/[locale]/manage/layout.tsx +++ b/apps/nextjs/src/app/[locale]/manage/layout.tsx @@ -41,7 +41,6 @@ import type { NavigationLink } from "~/components/layout/navigation"; import { MainNavigation } from "~/components/layout/navigation"; import { ClientShell } from "~/components/layout/shell"; import { ManageTourProvider } from "~/components/onboarding/manage-tour"; -import { env as nextEnv } from "~/env"; export default async function ManageLayout({ children }: PropsWithChildren) { const t = await getScopedI18n("management.navbar"); @@ -80,16 +79,13 @@ export default async function ManageLayout({ children }: PropsWithChildren) { icon: IconApi, href: "/manage/custom-widgets", label: t("items.customWidgets"), - hidden: nextEnv.CUSTOM_WIDGETS_ENABLED === false || !session?.user.permissions.includes("admin"), + hidden: !session?.user.permissions.includes("admin"), }, { icon: IconBuildingStore, href: "/manage/workshop", label: t("items.workshop"), - hidden: - nextEnv.CUSTOM_WIDGETS_ENABLED === false || - nextEnv.WORKSHOP_ENABLED === false || - !session?.user.permissions.includes("admin"), + hidden: !session?.user.permissions.includes("admin"), }, { icon: IconSearch, diff --git a/apps/nextjs/src/app/[locale]/manage/workshop/page.tsx b/apps/nextjs/src/app/[locale]/manage/workshop/page.tsx index 09ea8700c..d52825869 100644 --- a/apps/nextjs/src/app/[locale]/manage/workshop/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/workshop/page.tsx @@ -13,7 +13,6 @@ import { env } from "~/env"; export default async function WorkshopPage() { const session = await auth(); if (!session?.user.permissions.includes("admin")) redirect(session ? "/" : "/auth/login"); - if (env.CUSTOM_WIDGETS_ENABLED === false || env.WORKSHOP_ENABLED === false) redirect("/"); const t = await getScopedI18n("workshop"); const { workshopWebUrl } = resolveHomarrUrlConfig({ homarrWebsiteUrl: env.HOMARR_WEBSITE_URL, diff --git a/apps/nextjs/src/app/api/custom-widgets/admin.spec.ts b/apps/nextjs/src/app/api/custom-widgets/admin.spec.ts index 593ca56e2..81e4e62c3 100644 --- a/apps/nextjs/src/app/api/custom-widgets/admin.spec.ts +++ b/apps/nextjs/src/app/api/custom-widgets/admin.spec.ts @@ -4,16 +4,13 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; const mocks = vi.hoisted(() => ({ auth: vi.fn(), - env: { CUSTOM_WIDGETS_ENABLED: true }, })); vi.mock("@homarr/auth/next", () => ({ auth: mocks.auth })); -vi.mock("~/env", () => ({ env: mocks.env })); import { requireCustomWidgetAdmin } from "./admin"; beforeEach(() => { - mocks.env.CUSTOM_WIDGETS_ENABLED = true; mocks.auth.mockResolvedValue({ user: { permissions: ["admin"] } }); }); @@ -30,11 +27,4 @@ describe("Custom Widget HTTP resource access", () => { const response = await requireCustomWidgetAdmin(); expect(response?.status).toBe(403); }); - - test("returns an unavailable response when the emergency switch is off", async () => { - mocks.env.CUSTOM_WIDGETS_ENABLED = false; - const response = await requireCustomWidgetAdmin(); - expect(response?.status).toBe(503); - await expect(response?.json()).resolves.toMatchObject({ error: expect.stringContaining("temporarily disabled") }); - }); }); diff --git a/apps/nextjs/src/app/api/custom-widgets/admin.ts b/apps/nextjs/src/app/api/custom-widgets/admin.ts index 07d1cf93a..da6df6eb8 100644 --- a/apps/nextjs/src/app/api/custom-widgets/admin.ts +++ b/apps/nextjs/src/app/api/custom-widgets/admin.ts @@ -2,18 +2,10 @@ import { NextResponse } from "next/server"; import { auth } from "@homarr/auth/next"; -import { env } from "~/env"; - export const requireCustomWidgetAdmin = async (): Promise => { const session = await auth(); if (!session?.user.permissions.includes("admin")) { return NextResponse.json({ error: "Administrator access is required." }, { status: 403 }); } - if (env.CUSTOM_WIDGETS_ENABLED === false) { - return NextResponse.json( - { error: "Custom Widgets are temporarily disabled by the server administrator." }, - { status: 503 }, - ); - } return null; }; diff --git a/apps/nextjs/src/app/api/mcp/[transport]/route.spec.ts b/apps/nextjs/src/app/api/mcp/[transport]/route.spec.ts index 780a8d5a3..bea9ad1fa 100644 --- a/apps/nextjs/src/app/api/mcp/[transport]/route.spec.ts +++ b/apps/nextjs/src/app/api/mcp/[transport]/route.spec.ts @@ -7,7 +7,6 @@ const mocks = vi.hoisted(() => ({ rateLimitAddress: vi.fn(() => "127.0.0.1"), toolProcedure: vi.fn(), loggerWarn: vi.fn(), - env: { CUSTOM_WIDGETS_ENABLED: true, WORKSHOP_ENABLED: true }, buildPrompt: vi.fn((request?: string, documentationUrl?: string) => [`Request: ${request ?? ""}`, `Documentation: ${documentationUrl ?? ""}`].join("\n"), ), @@ -39,7 +38,6 @@ vi.mock("@homarr/custom-widgets/core", () => ({ getCustomWidgetJsonSchema: () => ({ type: "object", title: "Custom Widget" }), })); vi.mock("@homarr/db", () => ({ db: {} })); -vi.mock("~/env", () => ({ env: mocks.env })); vi.mock("~/versions/package-reader", () => ({ getPackageVersion: () => "test-version" })); vi.mock("../_extract-tools", () => ({ extractMcpTools: () => [ @@ -75,8 +73,6 @@ interface JsonRpcResponse { beforeEach(() => { mocks.authenticate.mockResolvedValue({ user: { id: "user-1", permissions: ["admin"] } }); - mocks.env.CUSTOM_WIDGETS_ENABLED = true; - mocks.env.WORKSHOP_ENABLED = true; mocks.buildPrompt.mockClear(); mocks.getComponent.mockClear(); mocks.rateLimitAddress.mockClear(); @@ -242,27 +238,6 @@ describe("non-admin MCP discovery", () => { }); }); -describe("MCP emergency switches", () => { - test("removes Custom Widget authoring discovery when Custom Widgets are disabled", async () => { - mocks.env.CUSTOM_WIDGETS_ENABLED = false; - expect((await callMcp("prompts/list")).body.result).toEqual({ prompts: [] }); - expect((await callMcp("resources/list")).body.result).toEqual({ resources: [] }); - const tools = await callMcp("tools/list"); - expect(((tools.body.result?.tools ?? []) as Array<{ name: string }>).map(({ name }) => name)).toEqual([ - "board_getAllBoards", - ]); - }); - - test("removes only Workshop tools when Workshop is disabled", async () => { - mocks.env.WORKSHOP_ENABLED = false; - const tools = await callMcp("tools/list"); - expect(((tools.body.result?.tools ?? []) as Array<{ name: string }>).map(({ name }) => name)).toEqual([ - "board_getAllBoards", - "customWidget_list", - ]); - }); -}); - describe("MCP protocol errors", () => { test.each([ ["prompts/get", { name: "unknown" }], diff --git a/apps/nextjs/src/app/api/mcp/[transport]/route.ts b/apps/nextjs/src/app/api/mcp/[transport]/route.ts index d4da9c8b1..2015cbbd5 100644 --- a/apps/nextjs/src/app/api/mcp/[transport]/route.ts +++ b/apps/nextjs/src/app/api/mcp/[transport]/route.ts @@ -27,7 +27,6 @@ import { import { customJsxExamples, getCustomWidgetJsonSchema } from "@homarr/custom-widgets/core"; import { db } from "@homarr/db"; -import { env } from "~/env"; import { getPackageVersion } from "~/versions/package-reader"; import { extractMcpTools } from "../_extract-tools"; @@ -124,17 +123,14 @@ function getVisibleTools() { if (!isCustomWidgetMcpEnabled()) { return tools.filter((tool) => !tool.name.startsWith("customWidget_")); } - return env.WORKSHOP_ENABLED === false - ? tools.filter((tool) => !tool.name.startsWith("customWidget_workshop")) - : tools; + return tools; } function requireCustomWidgetMcpAdmin() { if (!isCustomWidgetMcpEnabled()) throw new Error("Resource not found"); } -const isCustomWidgetMcpEnabled = () => - Boolean(callerStorage.getStore()?.isAdmin) && env.CUSTOM_WIDGETS_ENABLED !== false; +const isCustomWidgetMcpEnabled = () => Boolean(callerStorage.getStore()?.isAdmin); const SAFE_ERROR_MESSAGES: Record = { UNAUTHORIZED: "You do not have permission to perform this action", diff --git a/apps/nextjs/src/components/board/items/item-select-modal.tsx b/apps/nextjs/src/components/board/items/item-select-modal.tsx index 4a177b9de..fc75f26d1 100644 --- a/apps/nextjs/src/components/board/items/item-select-modal.tsx +++ b/apps/nextjs/src/components/board/items/item-select-modal.tsx @@ -16,14 +16,11 @@ import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/wid import { WidgetEditModal } from "@homarr/widgets/modals"; import { WorkshopInstallButton } from "~/components/workshop/workshop-install-button"; -import { useRuntimeFeature } from "~/hooks/use-runtime-feature"; import { useItemActions } from "./item-actions"; export const ItemSelectModal = createModal<{ boardId: string }>(({ actions, innerProps }) => { const { data: session } = useSession(); const isAdmin = session?.user.permissions.includes("admin") ?? false; - const customWidgetsEnabled = useRuntimeFeature("custom-widgets"); - const workshopEnabled = useRuntimeFeature("workshop"); const [search, setSearch] = useState(""); const t = useI18n(); const { createItem, updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions(); @@ -31,7 +28,7 @@ export const ItemSelectModal = createModal<{ boardId: string }>(({ actions, inne const { data: integrationData } = clientApi.integration.all.useQuery(); const { data: customWidgetDefs } = clientApi.customWidget.available.useQuery( { boardId: innerProps.boardId }, - { enabled: isAdmin && customWidgetsEnabled }, + { enabled: isAdmin }, ); const settings = useSettings(); @@ -169,7 +166,7 @@ export const ItemSelectModal = createModal<{ boardId: string }>(({ actions, inne /> ))} - {isAdmin && customWidgetsEnabled && ( + {isAdmin && ( <> (({ actions, inne ))} - {workshopEnabled && ( - - {t("workshop.installDialog")} - - )} + + {t("workshop.installDialog")} + )} - {filteredItems.length === 0 && (!isAdmin || !customWidgetsEnabled || filteredCustomWidgets.length === 0) && ( + {filteredItems.length === 0 && (!isAdmin || filteredCustomWidgets.length === 0) && (
{t("common.noResults")}
diff --git a/apps/nextjs/src/components/custom-widgets/custom-widgets-unavailable.tsx b/apps/nextjs/src/components/custom-widgets/custom-widgets-unavailable.tsx deleted file mode 100644 index 63729107e..000000000 --- a/apps/nextjs/src/components/custom-widgets/custom-widgets-unavailable.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { Alert, Container } from "@mantine/core"; -import { IconAlertTriangle } from "@tabler/icons-react"; - -import { getScopedI18n } from "@homarr/translation/server"; - -export async function CustomWidgetsUnavailable() { - const t = await getScopedI18n("customWidget.page.disabled"); - - return ( - - } title={t("title")}> - {t("message")} - - - ); -} diff --git a/apps/nextjs/src/components/workshop/workshop-css-import-button.tsx b/apps/nextjs/src/components/workshop/workshop-css-import-button.tsx index ffbc00bda..89ab01c59 100644 --- a/apps/nextjs/src/components/workshop/workshop-css-import-button.tsx +++ b/apps/nextjs/src/components/workshop/workshop-css-import-button.tsx @@ -5,14 +5,10 @@ import { Button, Modal } from "@mantine/core"; import { IconDownload } from "@tabler/icons-react"; import { WorkshopBrowser } from "./workshop-browser"; -import { useRuntimeFeature } from "~/hooks/use-runtime-feature"; export function WorkshopCssImportButton({ onImport }: { onImport(css: string): void }) { - const workshopEnabled = useRuntimeFeature("workshop"); const [opened, setOpened] = useState(false); - if (!workshopEnabled) return null; - return ( <>