refactor: trim v2 hardening scaffolding
This commit is contained in:
1 parent
ecfb9d59e0
commit
2eb0baf17e
75 files changed
+334
-2067
No files matched your search
@@ -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"
|
||||
+1
-5
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+31
-193
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 @- <<EOF
|
||||
{
|
||||
"event_type": "trigger-release",
|
||||
"client_payload": {
|
||||
"tag": "${SOURCE_TAG}"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
update-bug-report-template:
|
||||
name: Update Bug Report Template
|
||||
runs-on: ubuntu-latest
|
||||
@@ -15,7 +48,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 to branch
|
||||
permission-pull-requests: write # required to create pr & enable automerge
|
||||
- name: Checkout code
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
uses: actions/create-github-app-token@v3
|
||||
with:
|
||||
private-key: ${{ secrets.HOMARR_UPDATE_CONTRIBUTORS_PRIVATE_KEY }}
|
||||
app-id: ${{ vars.HOMARR_UPDATE_CONTRIBUTORS_APP_ID }}
|
||||
client-id: ${{ vars.HOMARR_UPDATE_CONTRIBUTORS_APP_ID }}
|
||||
permission-contents: write # required to commit to branch
|
||||
permission-pull-requests: write # required to create pr & enable automerge
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
uses: actions/create-github-app-token@v3
|
||||
with:
|
||||
private-key: ${{ secrets.HOMARR_UPDATE_CONTRIBUTORS_PRIVATE_KEY }}
|
||||
app-id: ${{ vars.HOMARR_UPDATE_CONTRIBUTORS_APP_ID }}
|
||||
client-id: ${{ vars.HOMARR_UPDATE_CONTRIBUTORS_APP_ID }}
|
||||
permission-contents: write # required to commit to branch
|
||||
permission-pull-requests: write # required to create pr & enable automerge
|
||||
- name: Checkout code
|
||||
|
||||
@@ -2,7 +2,6 @@ name: Workshop
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
merge_group:
|
||||
push:
|
||||
branches: [dev, v2]
|
||||
|
||||
@@ -11,9 +10,8 @@ permissions:
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Workshop integration
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v6
|
||||
@@ -28,42 +26,3 @@ jobs:
|
||||
- run: pnpm --filter @homarr/docs typecheck
|
||||
- run: pnpm test:workshop
|
||||
- run: docker build --target production -f apps/workshop/Dockerfile .
|
||||
- name: Build and smoke-test a non-root documentation deployment
|
||||
run: |
|
||||
docker build \
|
||||
--build-arg DOCS_BASE_URL=/docs-site/ \
|
||||
--tag homarr-workshop:base-path-smoke \
|
||||
--target production \
|
||||
--file apps/workshop/Dockerfile \
|
||||
.
|
||||
docker run --detach --name workshop-base-path-smoke \
|
||||
--publish 127.0.0.1:18093:8090 \
|
||||
--env DOCS_BASE_URL=/docs-site/ \
|
||||
--env HOMARR_WEBSITE_URL=http://127.0.0.1:18093 \
|
||||
--env WORKSHOP_API_URL=http://127.0.0.1:18093 \
|
||||
--env WORKSHOP_WEB_URL=http://127.0.0.1:18093/docs-site/workshop \
|
||||
--env WORKSHOP_PUBLIC_ORIGIN=http://127.0.0.1:18093 \
|
||||
--env WORKSHOP_REQUIRE_OAUTH=false \
|
||||
homarr-workshop:base-path-smoke
|
||||
for attempt in $(seq 1 60); do
|
||||
if curl --fail --silent http://127.0.0.1:18093/api/health >/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
|
||||
@@ -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
|
||||
+2
-2
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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=<id> --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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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://<workshop-host>/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
|
||||
|
||||
|
||||
+10
-4
@@ -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.
|
||||
@@ -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 <CustomWidgetsUnavailable />;
|
||||
const { token } = await params;
|
||||
return <CustomWidgetConfigurationEntry token={token} />;
|
||||
}
|
||||
@@ -149,8 +149,6 @@ export default async function Layout(props: {
|
||||
<meta name="homarr-website-url" content={publicUrls.homarrWebsiteUrl} />
|
||||
<meta name="homarr-workshop-api-url" content={publicUrls.workshopApiUrl} />
|
||||
<meta name="homarr-workshop-web-url" content={publicUrls.workshopWebUrl} />
|
||||
<meta name="homarr-custom-widgets-enabled" content={String(env.CUSTOM_WIDGETS_ENABLED !== false)} />
|
||||
<meta name="homarr-workshop-enabled" content={String(env.WORKSHOP_ENABLED !== false)} />
|
||||
<SearchEngineOptimization />
|
||||
<CrowdinLiveTranslation locale={locale} />
|
||||
</head>
|
||||
|
||||
@@ -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 }) => {
|
||||
<Menu.Item onClick={() => void handleExport()} leftSection={<IconDownload {...iconProps} />}>
|
||||
{t("action.export")}
|
||||
</Menu.Item>
|
||||
{workshopEnabled && (
|
||||
<Menu.Item onClick={publishControls.open} leftSection={<IconBuildingStore {...iconProps} />}>
|
||||
{t("action.publishWorkshop")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item onClick={publishControls.open} leftSection={<IconBuildingStore {...iconProps} />}>
|
||||
{t("action.publishWorkshop")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
</>
|
||||
)}
|
||||
@@ -226,9 +222,7 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
{widget.valid && workshopEnabled && (
|
||||
<WorkshopPublishModal opened={publishOpened} onClose={publishControls.close} widget={widget} />
|
||||
)}
|
||||
{widget.valid && <WorkshopPublishModal opened={publishOpened} onClose={publishControls.close} widget={widget} />}
|
||||
{widget.migrationRequired && (
|
||||
<>
|
||||
<input
|
||||
|
||||
@@ -5,9 +5,7 @@ import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { CustomWidgetsUnavailable } from "~/components/custom-widgets/custom-widgets-unavailable";
|
||||
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
|
||||
import { env } from "~/env";
|
||||
import { CustomWidgetBetaBanner } from "../../_beta-banner";
|
||||
import { CustomWidgetForm } from "../../_custom-widget-form";
|
||||
import { FormErrorBoundary } from "../../_form-error-boundary";
|
||||
@@ -21,8 +19,6 @@ export default async function EditCustomWidgetPage(props: EditCustomWidgetPagePr
|
||||
if (!session || !session.user.permissions.includes("admin")) {
|
||||
redirect("/manage/custom-widgets");
|
||||
}
|
||||
if (env.CUSTOM_WIDGETS_ENABLED === false) return <CustomWidgetsUnavailable />;
|
||||
|
||||
const params = await props.params;
|
||||
const definition = await api.customWidget.get({ id: params.id }).catch(catchTrpcNotFound);
|
||||
|
||||
|
||||
@@ -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 <CustomWidgetsUnavailable />;
|
||||
|
||||
const t = await getScopedI18n("customWidget");
|
||||
|
||||
return (
|
||||
|
||||
@@ -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 <CustomWidgetsUnavailable />;
|
||||
|
||||
const definitions = await api.customWidget.list();
|
||||
const t = await getScopedI18n("customWidget");
|
||||
|
||||
|
||||
@@ -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 <CustomWidgetsUnavailable />;
|
||||
const { id } = await params;
|
||||
const preview = await api.customWidget.previewGet({ sessionId: id }).catch(() => null);
|
||||
if (!preview) notFound();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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") });
|
||||
});
|
||||
});
|
||||
@@ -2,18 +2,10 @@ import { NextResponse } from "next/server";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
|
||||
import { env } from "~/env";
|
||||
|
||||
export const requireCustomWidgetAdmin = async (): Promise<NextResponse | null> => {
|
||||
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;
|
||||
};
|
||||
@@ -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" }],
|
||||
|
||||
@@ -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<string, string> = {
|
||||
UNAUTHORIZED: "You do not have permission to perform this action",
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
<Divider
|
||||
label={t("customWidget.page.list.title")}
|
||||
@@ -221,15 +218,13 @@ export const ItemSelectModal = createModal<{ boardId: string }>(({ actions, inne
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{workshopEnabled && (
|
||||
<Box style={{ gridColumn: "1 / -1" }}>
|
||||
<WorkshopInstallButton fullWidth>{t("workshop.installDialog")}</WorkshopInstallButton>
|
||||
</Box>
|
||||
)}
|
||||
<Box style={{ gridColumn: "1 / -1" }}>
|
||||
<WorkshopInstallButton fullWidth>{t("workshop.installDialog")}</WorkshopInstallButton>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{filteredItems.length === 0 && (!isAdmin || !customWidgetsEnabled || filteredCustomWidgets.length === 0) && (
|
||||
{filteredItems.length === 0 && (!isAdmin || filteredCustomWidgets.length === 0) && (
|
||||
<Center p="xl">
|
||||
<Text c="dimmed">{t("common.noResults")}</Text>
|
||||
</Center>
|
||||
|
||||
@@ -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 (
|
||||
<Container size="sm" py="xl">
|
||||
<Alert color="yellow" icon={<IconAlertTriangle size={18} />} title={t("title")}>
|
||||
{t("message")}
|
||||
</Alert>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<Button type="button" variant="light" leftSection={<IconDownload size={16} />} onClick={() => setOpened(true)}>
|
||||
|
||||
@@ -9,7 +9,6 @@ import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
|
||||
import { useRuntimeFeature } from "~/hooks/use-runtime-feature";
|
||||
import { WorkshopBrowser } from "./workshop-browser";
|
||||
|
||||
interface WorkshopInstallButtonProps {
|
||||
@@ -18,7 +17,6 @@ interface WorkshopInstallButtonProps {
|
||||
}
|
||||
|
||||
export function WorkshopInstallButton({ children, fullWidth }: WorkshopInstallButtonProps = {}) {
|
||||
const workshopEnabled = useRuntimeFeature("workshop");
|
||||
const t = useScopedI18n("workshop");
|
||||
const stack = useModalsStack(["workshop", "review"]);
|
||||
const [pendingWidget, setPendingWidget] = useState<HomarrCustomWidgetV2 | null>(null);
|
||||
@@ -29,8 +27,6 @@ export function WorkshopInstallButton({ children, fullWidth }: WorkshopInstallBu
|
||||
setPendingWidget(null);
|
||||
};
|
||||
|
||||
if (!workshopEnabled) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
|
||||
@@ -10,7 +10,6 @@ import type { WorkshopUser } from "@homarr/workshop/schema";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { createWorkshopClient } from "./workshop-client";
|
||||
import { useRuntimeFeature } from "~/hooks/use-runtime-feature";
|
||||
import {
|
||||
getPrivateWorkshopSourceNames,
|
||||
publishWorkshopDefinition,
|
||||
@@ -26,7 +25,6 @@ export function WorkshopPublishModal({
|
||||
onClose(): void;
|
||||
widget: { id: string; name: string };
|
||||
}) {
|
||||
const workshopEnabled = useRuntimeFeature("workshop");
|
||||
const t = useScopedI18n("workshop");
|
||||
const client = useMemo(createWorkshopClient, []);
|
||||
const [user, setUser] = useState<WorkshopUser | null>(null);
|
||||
@@ -37,16 +35,15 @@ export function WorkshopPublishModal({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [published, setPublished] = useState(false);
|
||||
const [sourceUrlsReviewed, setSourceUrlsReviewed] = useState(false);
|
||||
const definition = clientApi.customWidget.export.useQuery({ id: widget.id }, { enabled: opened && workshopEnabled });
|
||||
const definition = clientApi.customWidget.export.useQuery({ id: widget.id }, { enabled: opened });
|
||||
const privateSourceNames = getPrivateWorkshopSourceNames(definition.data);
|
||||
const definitionFingerprint = definition.data ? serializeWorkshopDefinition(definition.data) : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!workshopEnabled) return;
|
||||
const unsubscribe = client.subscribeToAuth(setUser);
|
||||
void client.refreshAuth().then(setUser);
|
||||
return unsubscribe;
|
||||
}, [client, workshopEnabled]);
|
||||
}, [client]);
|
||||
useEffect(() => {
|
||||
if (!opened) return;
|
||||
setTitle(widget.name);
|
||||
@@ -89,8 +86,6 @@ export function WorkshopPublishModal({
|
||||
}
|
||||
};
|
||||
|
||||
if (!workshopEnabled) return null;
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title={t("publish.title")} size="lg">
|
||||
<Stack>
|
||||
|
||||
@@ -21,8 +21,6 @@ export const env = createEnv({
|
||||
UNSAFE_ENABLE_MOCK_INTEGRATION: createBooleanSchema(false),
|
||||
DEMO_MODE: createBooleanSchema(false),
|
||||
DEMO_READ_ONLY: createBooleanSchema(true),
|
||||
CUSTOM_WIDGETS_ENABLED: createBooleanSchema(true),
|
||||
WORKSHOP_ENABLED: createBooleanSchema(true),
|
||||
HOMARR_WEBSITE_URL: publicHttpUrl("HOMARR_WEBSITE_URL").default("https://homarr.dev"),
|
||||
WORKSHOP_API_URL: publicHttpUrl("WORKSHOP_API_URL").optional(),
|
||||
WORKSHOP_WEB_URL: publicHttpUrl("WORKSHOP_WEB_URL").optional(),
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
const subscribe = () => () => undefined;
|
||||
|
||||
export const useRuntimeFeature = (feature: "custom-widgets" | "workshop") =>
|
||||
useSyncExternalStore(
|
||||
subscribe,
|
||||
() => document.querySelector<HTMLMetaElement>(`meta[name="homarr-${feature}-enabled"]`)?.content !== "false",
|
||||
() => true,
|
||||
);
|
||||
+11
-10
@@ -10,25 +10,27 @@ version and digest together, then run the Workshop integration and production-im
|
||||
The internal PocketBase field `users.isAdmin` means **Workshop moderator**. It does not grant Homarr administrator
|
||||
access. Moderators can review private reports, dismiss reports, and remove submissions or comments.
|
||||
|
||||
## Run the complete local stack
|
||||
## Run locally
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
cp .env.example .env
|
||||
cp apps/workshop/.env.example apps/workshop/.env
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm dev:workshop
|
||||
docker compose --env-file apps/workshop/.env -f apps/workshop/docker-compose.yml up --build workshop
|
||||
```
|
||||
|
||||
This starts persistent PocketBase at `http://127.0.0.1:8090`, waits for health and required collections, and starts
|
||||
Docusaurus at `http://127.0.0.1:3003`. Open:
|
||||
This starts persistent PocketBase at `http://127.0.0.1:8090`. Start Docusaurus in another terminal:
|
||||
|
||||
```sh
|
||||
WORKSHOP_API_URL=http://127.0.0.1:8090 pnpm dev:docs
|
||||
```
|
||||
|
||||
Open:
|
||||
|
||||
- PocketBase administration: `http://127.0.0.1:8090/_/`
|
||||
- Workshop: `http://127.0.0.1:3003/workshop`
|
||||
|
||||
For direct Compose work, copy `apps/workshop/.env.example` to `apps/workshop/.env` and pass it explicitly with
|
||||
`docker compose --env-file apps/workshop/.env -f apps/workshop/docker-compose.yml …`.
|
||||
|
||||
Create the first PocketBase superuser in the administration UI, or use:
|
||||
|
||||
```sh
|
||||
@@ -36,8 +38,7 @@ docker compose -f apps/workshop/docker-compose.yml exec workshop \
|
||||
pocketbase superuser create you@example.com 'replace-with-a-strong-password' --dir=/pb_data
|
||||
```
|
||||
|
||||
Stopping `pnpm dev:workshop` stops PocketBase and Docusaurus without deleting
|
||||
`homarr-workshop_pb_data`. To inspect or stop the service separately:
|
||||
Stopping the Compose process preserves `homarr-workshop_pb_data`. To inspect or stop the service separately:
|
||||
|
||||
```sh
|
||||
docker compose -f apps/workshop/docker-compose.yml logs -f workshop
|
||||
|
||||
+1
-3
@@ -8,7 +8,6 @@
|
||||
"clean:workspaces": "turbo clean",
|
||||
"check:custom-widgets": "pnpm --filter @homarr/custom-widgets check:architecture",
|
||||
"check:custom-widget-bundle": "node ./scripts/check-custom-widget-bundle.mjs",
|
||||
"check:actions": "bash ./scripts/ci/check-actions.sh",
|
||||
"check:openapi": "tsx ./scripts/check-openapi-schema.mts",
|
||||
"ci:act:all": "bash scripts/ci/act.sh all",
|
||||
"ci:act:cleanup": "bash scripts/ci/act.sh cleanup",
|
||||
@@ -25,7 +24,6 @@
|
||||
"db:studio": "pnpm -F db studio",
|
||||
"dev": "pnpm -F nextjs dev",
|
||||
"dev:docs": "pnpm -F @homarr/docs dev",
|
||||
"dev:workshop": "node ./scripts/dev-workshop.mjs",
|
||||
"docker:docs": "docker compose -f apps/workshop/docker-compose.yml up --build docs",
|
||||
"docker:workshop": "docker compose -f apps/workshop/docker-compose.yml up --build workshop",
|
||||
"docker:dev": "docker compose -f ./development/development.docker-compose.yml up",
|
||||
@@ -33,7 +31,7 @@
|
||||
"format:fix": "turbo format --continue",
|
||||
"lint": "turbo lint --continue",
|
||||
"lint:fix": "turbo lint --continue -- --fix",
|
||||
"lint:ws": "pnpm dlx sherif@1.13.0",
|
||||
"lint:ws": "pnpm dlx sherif@latest",
|
||||
"release": "semantic-release",
|
||||
"scripts:update-bug-report-template": "tsx ./scripts/update-bug-report-template.mts",
|
||||
"scripts:update-readme-integrations": "tsx ./scripts/update-integration-list.mts",
|
||||
|
||||
@@ -21,8 +21,6 @@ export const env = createEnv({
|
||||
KUBERNETES_SERVICE_ACCOUNT_NAME: z.string().optional(),
|
||||
DEMO_MODE: createBooleanSchema(false),
|
||||
DEMO_READ_ONLY: createBooleanSchema(true),
|
||||
CUSTOM_WIDGETS_ENABLED: createBooleanSchema(true),
|
||||
WORKSHOP_ENABLED: createBooleanSchema(true),
|
||||
HOMARR_WEBSITE_URL: publicHttpUrl("HOMARR_WEBSITE_URL").default("https://homarr.dev"),
|
||||
WORKSHOP_API_URL: publicHttpUrl("WORKSHOP_API_URL").optional(),
|
||||
},
|
||||
@@ -30,8 +28,6 @@ export const env = createEnv({
|
||||
KUBERNETES_SERVICE_ACCOUNT_NAME: process.env.KUBERNETES_SERVICE_ACCOUNT_NAME,
|
||||
DEMO_MODE: process.env.DEMO_MODE,
|
||||
DEMO_READ_ONLY: process.env.DEMO_READ_ONLY,
|
||||
CUSTOM_WIDGETS_ENABLED: process.env.CUSTOM_WIDGETS_ENABLED,
|
||||
WORKSHOP_ENABLED: process.env.WORKSHOP_ENABLED,
|
||||
HOMARR_WEBSITE_URL: process.env.HOMARR_WEBSITE_URL,
|
||||
WORKSHOP_API_URL: process.env.WORKSHOP_API_URL,
|
||||
},
|
||||
|
||||
@@ -59,7 +59,6 @@ import { zodUnionFromArray } from "@homarr/validation/enums";
|
||||
import type { BoardItemAdvancedOptions } from "@homarr/validation/shared";
|
||||
import { sectionSchema, sharedItemSchema } from "@homarr/validation/shared";
|
||||
|
||||
import { env } from "../env";
|
||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import { throwIfActionForbiddenAsync } from "./board/board-access";
|
||||
import {
|
||||
@@ -377,11 +376,7 @@ export const boardRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
const { sections: boardSections, items: boardItems, layouts: boardLayouts, ...boardProps } = board;
|
||||
throwIfCustomWidgetBoardDuplicationForbidden(
|
||||
ctx.session.user.permissions.includes("admin"),
|
||||
env.CUSTOM_WIDGETS_ENABLED !== false,
|
||||
boardItems,
|
||||
);
|
||||
throwIfCustomWidgetBoardDuplicationForbidden(ctx.session.user.permissions.includes("admin"), boardItems);
|
||||
|
||||
const newBoardId = createId();
|
||||
|
||||
@@ -867,7 +862,6 @@ export const boardRouter = createTRPCRouter({
|
||||
const dbBoard = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id);
|
||||
throwIfCustomWidgetPlacementChangeForbidden({
|
||||
isAdmin: ctx.session.user.permissions.includes("admin"),
|
||||
customWidgetsEnabled: env.CUSTOM_WIDGETS_ENABLED !== false,
|
||||
submittedItems: input.items,
|
||||
storedItems: dbBoard.items,
|
||||
});
|
||||
@@ -1458,7 +1452,6 @@ export const boardRouter = createTRPCRouter({
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
|
||||
throwIfCustomWidgetPlacementChangeForbidden({
|
||||
isAdmin: ctx.session.user.permissions.includes("admin"),
|
||||
customWidgetsEnabled: env.CUSTOM_WIDGETS_ENABLED !== false,
|
||||
submittedItems: [{ id: "", kind: input.kind, options: input.options }],
|
||||
storedItems: [],
|
||||
});
|
||||
|
||||
@@ -10,7 +10,6 @@ interface BoardPlacement {
|
||||
|
||||
interface CustomWidgetPlacementGuardInput {
|
||||
isAdmin: boolean;
|
||||
customWidgetsEnabled: boolean;
|
||||
submittedItems: readonly BoardPlacement[];
|
||||
storedItems: readonly BoardPlacement[];
|
||||
}
|
||||
@@ -22,15 +21,7 @@ const throwAdminRequired = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const throwFeatureDisabled = () => {
|
||||
throw new TRPCError({
|
||||
code: "SERVICE_UNAVAILABLE",
|
||||
message: "Custom Widgets are temporarily disabled by the server administrator",
|
||||
});
|
||||
};
|
||||
|
||||
const assertAuthoringAllowed = (isAdmin: boolean, customWidgetsEnabled: boolean) => {
|
||||
if (!customWidgetsEnabled) throwFeatureDisabled();
|
||||
const assertAuthoringAllowed = (isAdmin: boolean) => {
|
||||
if (!isAdmin) throwAdminRequired();
|
||||
};
|
||||
|
||||
@@ -41,7 +32,6 @@ const assertAuthoringAllowed = (isAdmin: boolean, customWidgetsEnabled: boolean)
|
||||
*/
|
||||
export const throwIfCustomWidgetPlacementChangeForbidden = ({
|
||||
isAdmin,
|
||||
customWidgetsEnabled,
|
||||
submittedItems,
|
||||
storedItems,
|
||||
}: CustomWidgetPlacementGuardInput) => {
|
||||
@@ -50,14 +40,14 @@ export const throwIfCustomWidgetPlacementChangeForbidden = ({
|
||||
const stored = storedById.get(submitted.id);
|
||||
|
||||
if (!stored) {
|
||||
if (submitted.kind === "customApi") assertAuthoringAllowed(isAdmin, customWidgetsEnabled);
|
||||
if (submitted.kind === "customApi") assertAuthoringAllowed(isAdmin);
|
||||
continue;
|
||||
}
|
||||
|
||||
const wasCustomWidget = stored.kind === "customApi";
|
||||
const isCustomWidget = submitted.kind === "customApi";
|
||||
if (wasCustomWidget !== isCustomWidget) {
|
||||
if (isCustomWidget) assertAuthoringAllowed(isAdmin, customWidgetsEnabled);
|
||||
if (isCustomWidget) assertAuthoringAllowed(isAdmin);
|
||||
else if (!isAdmin) throwAdminRequired();
|
||||
continue;
|
||||
}
|
||||
@@ -65,17 +55,16 @@ export const throwIfCustomWidgetPlacementChangeForbidden = ({
|
||||
wasCustomWidget &&
|
||||
!isDeepStrictEqual(normalizeCustomWidgetOptions(submitted.options), normalizeCustomWidgetOptions(stored.options))
|
||||
) {
|
||||
assertAuthoringAllowed(isAdmin, customWidgetsEnabled);
|
||||
assertAuthoringAllowed(isAdmin);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const throwIfCustomWidgetBoardDuplicationForbidden = (
|
||||
isAdmin: boolean,
|
||||
customWidgetsEnabled: boolean,
|
||||
items: readonly Pick<BoardPlacement, "kind">[],
|
||||
) => {
|
||||
if (items.some((item) => item.kind === "customApi")) assertAuthoringAllowed(isAdmin, customWidgetsEnabled);
|
||||
if (items.some((item) => item.kind === "customApi")) assertAuthoringAllowed(isAdmin);
|
||||
};
|
||||
|
||||
function normalizeCustomWidgetOptions(options: Record<string, unknown>) {
|
||||
|
||||
@@ -20,7 +20,6 @@ describe("Custom Widget placement access", () => {
|
||||
expect(() =>
|
||||
throwIfCustomWidgetPlacementChangeForbidden({
|
||||
isAdmin: false,
|
||||
customWidgetsEnabled: true,
|
||||
submittedItems: [{ ...placement, options: { ...placement.options, configurationVersion: 1 } }],
|
||||
storedItems: [],
|
||||
}),
|
||||
@@ -39,7 +38,6 @@ describe("Custom Widget placement access", () => {
|
||||
expect(() =>
|
||||
throwIfCustomWidgetPlacementChangeForbidden({
|
||||
isAdmin: false,
|
||||
customWidgetsEnabled: true,
|
||||
submittedItems: [submitted],
|
||||
storedItems: [placement],
|
||||
}),
|
||||
@@ -55,7 +53,6 @@ describe("Custom Widget placement access", () => {
|
||||
expect(() =>
|
||||
throwIfCustomWidgetPlacementChangeForbidden({
|
||||
isAdmin: false,
|
||||
customWidgetsEnabled: true,
|
||||
submittedItems: [submitted],
|
||||
storedItems: [placement],
|
||||
}),
|
||||
@@ -66,7 +63,6 @@ describe("Custom Widget placement access", () => {
|
||||
expect(() =>
|
||||
throwIfCustomWidgetPlacementChangeForbidden({
|
||||
isAdmin: false,
|
||||
customWidgetsEnabled: true,
|
||||
submittedItems: [],
|
||||
storedItems: [placement],
|
||||
}),
|
||||
@@ -77,7 +73,6 @@ describe("Custom Widget placement access", () => {
|
||||
expect(() =>
|
||||
throwIfCustomWidgetPlacementChangeForbidden({
|
||||
isAdmin: true,
|
||||
customWidgetsEnabled: true,
|
||||
submittedItems: [{ ...placement, options: { definitionId: "definition-2" } }],
|
||||
storedItems: [],
|
||||
}),
|
||||
@@ -85,18 +80,17 @@ describe("Custom Widget placement access", () => {
|
||||
});
|
||||
|
||||
test("duplicating a board with Custom Widgets requires admin", () => {
|
||||
expect(() => throwIfCustomWidgetBoardDuplicationForbidden(false, true, [placement])).toThrowError(
|
||||
expect(() => throwIfCustomWidgetBoardDuplicationForbidden(false, [placement])).toThrowError(
|
||||
"Only administrators can add or configure Custom Widgets",
|
||||
);
|
||||
expect(() => throwIfCustomWidgetBoardDuplicationForbidden(true, true, [placement])).not.toThrow();
|
||||
expect(() => throwIfCustomWidgetBoardDuplicationForbidden(false, true, [{ kind: "weather" }])).not.toThrow();
|
||||
expect(() => throwIfCustomWidgetBoardDuplicationForbidden(true, [placement])).not.toThrow();
|
||||
expect(() => throwIfCustomWidgetBoardDuplicationForbidden(false, [{ kind: "weather" }])).not.toThrow();
|
||||
});
|
||||
|
||||
test("treats missing persisted v2 defaults as equivalent during presentation-only edits", () => {
|
||||
expect(() =>
|
||||
throwIfCustomWidgetPlacementChangeForbidden({
|
||||
isAdmin: false,
|
||||
customWidgetsEnabled: true,
|
||||
submittedItems: [placement],
|
||||
storedItems: [
|
||||
{
|
||||
@@ -112,26 +106,4 @@ describe("Custom Widget placement access", () => {
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("blocks authoring but still allows removal while the emergency switch is off", () => {
|
||||
expect(() =>
|
||||
throwIfCustomWidgetPlacementChangeForbidden({
|
||||
isAdmin: true,
|
||||
customWidgetsEnabled: false,
|
||||
submittedItems: [placement],
|
||||
storedItems: [],
|
||||
}),
|
||||
).toThrowError("temporarily disabled");
|
||||
expect(() =>
|
||||
throwIfCustomWidgetPlacementChangeForbidden({
|
||||
isAdmin: false,
|
||||
customWidgetsEnabled: false,
|
||||
submittedItems: [],
|
||||
storedItems: [placement],
|
||||
}),
|
||||
).not.toThrow();
|
||||
expect(() => throwIfCustomWidgetBoardDuplicationForbidden(true, false, [placement])).toThrowError(
|
||||
"temporarily disabled",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
|
||||
import { createTRPCRouter } from "../../trpc";
|
||||
import { insertCustomWidgetDefinition } from "./definition-insert";
|
||||
import { customWidgetAdminProcedure } from "./feature-flags";
|
||||
import { permissionRequiredProcedure } from "../../trpc";
|
||||
import { managementQueryProcedures } from "./management-queries";
|
||||
import { metadataProcedures } from "./metadata-procedures";
|
||||
import { previewActionProcedures } from "./preview-action-procedures";
|
||||
@@ -37,7 +37,8 @@ export const customWidgetRouter = createTRPCRouter({
|
||||
...metadataProcedures,
|
||||
...managementQueryProcedures,
|
||||
|
||||
create: customWidgetAdminProcedure
|
||||
create: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({ mcp: { enabled: true, description: "Create one validated Custom JSX widget." } })
|
||||
.input(customWidgetCreateSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
@@ -49,7 +50,8 @@ export const customWidgetRouter = createTRPCRouter({
|
||||
return { id };
|
||||
}),
|
||||
|
||||
update: customWidgetAdminProcedure
|
||||
update: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({ mcp: { enabled: true, description: "Update one Custom JSX widget." } })
|
||||
.input(customWidgetUpdateSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
@@ -182,7 +184,8 @@ export const customWidgetRouter = createTRPCRouter({
|
||||
...secretProcedures,
|
||||
...workshopProcedures,
|
||||
|
||||
toggleEnabled: customWidgetAdminProcedure
|
||||
toggleEnabled: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(z.object({ id: z.string(), enabled: z.boolean() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (input.enabled) {
|
||||
@@ -204,7 +207,8 @@ export const customWidgetRouter = createTRPCRouter({
|
||||
.where(eq(customWidgetDefinitions.id, input.id));
|
||||
}),
|
||||
|
||||
delete: customWidgetAdminProcedure
|
||||
delete: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({ mcp: { enabled: true, description: "Delete one Custom JSX widget." } })
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
@@ -212,20 +216,23 @@ export const customWidgetRouter = createTRPCRouter({
|
||||
logger.info("Deleted custom widget definition", { id: input.id });
|
||||
}),
|
||||
|
||||
duplicate: customWidgetAdminProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
|
||||
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.id),
|
||||
});
|
||||
if (!existing) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
const current = parseStoredCustomWidgetDefinition(existing);
|
||||
const id = createId();
|
||||
await ctx.db.insert(customWidgetDefinitions).values({
|
||||
id,
|
||||
...serializeCustomWidgetDefinition({ ...current, name: `${current.name} (copy)` }),
|
||||
creatorId: ctx.session.user.id,
|
||||
});
|
||||
return { id, name: `${current.name} (copy)` };
|
||||
}),
|
||||
duplicate: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.id),
|
||||
});
|
||||
if (!existing) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
const current = parseStoredCustomWidgetDefinition(existing);
|
||||
const id = createId();
|
||||
await ctx.db.insert(customWidgetDefinitions).values({
|
||||
id,
|
||||
...serializeCustomWidgetDefinition({ ...current, name: `${current.name} (copy)` }),
|
||||
creatorId: ctx.session.user.id,
|
||||
});
|
||||
return { id, name: `${current.name} (copy)` };
|
||||
}),
|
||||
|
||||
...templateProcedures,
|
||||
...transferProcedures,
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { env } from "../../env";
|
||||
import { permissionRequiredProcedure } from "../../trpc";
|
||||
|
||||
export function assertCustomWidgetsEnabled(): void {
|
||||
// Environment validation is intentionally skipped in some build and test
|
||||
// processes, where unset defaulted values are exposed as undefined.
|
||||
if (env.CUSTOM_WIDGETS_ENABLED !== false) return;
|
||||
throw new TRPCError({
|
||||
code: "SERVICE_UNAVAILABLE",
|
||||
message: "Custom Widgets are temporarily disabled by the server administrator",
|
||||
});
|
||||
}
|
||||
|
||||
export function assertWorkshopEnabled(): void {
|
||||
if (env.WORKSHOP_ENABLED !== false) return;
|
||||
throw new TRPCError({
|
||||
code: "SERVICE_UNAVAILABLE",
|
||||
message: "Workshop is temporarily disabled by the server administrator",
|
||||
});
|
||||
}
|
||||
|
||||
export const customWidgetAdminProcedure = permissionRequiredProcedure.requiresPermission("admin").use(({ next }) => {
|
||||
assertCustomWidgetsEnabled();
|
||||
return next();
|
||||
});
|
||||
|
||||
export const workshopAdminProcedure = customWidgetAdminProcedure.use(({ next }) => {
|
||||
assertWorkshopEnabled();
|
||||
return next();
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import { boards, customWidgetDefinitions, legacyCustomWidgetDefinitions } from "
|
||||
import { collectCustomWidgetRequestReferences } from "@homarr/custom-widgets/core";
|
||||
|
||||
import { throwIfActionForbiddenAsync } from "../board/board-access";
|
||||
import { customWidgetAdminProcedure } from "./feature-flags";
|
||||
import { permissionRequiredProcedure } from "../../trpc";
|
||||
import { parseStoredCustomWidgetDefinition } from "./stored-definition";
|
||||
import { executeCustomWidgetRequest } from "./request-executor";
|
||||
import {
|
||||
@@ -27,7 +27,8 @@ import {
|
||||
} from "./management-query-mappers";
|
||||
|
||||
export const managementQueryProcedures = {
|
||||
list: customWidgetAdminProcedure
|
||||
list: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({ mcp: { enabled: true, description: "List all Custom JSX widgets." } })
|
||||
.query(async ({ ctx }) => {
|
||||
const [definitions, legacyDefinitions] = await Promise.all([
|
||||
@@ -46,7 +47,8 @@ export const managementQueryProcedures = {
|
||||
return [...current, ...legacy].toSorted((left, right) => left.name.localeCompare(right.name));
|
||||
}),
|
||||
|
||||
legacyMigrationPrompt: customWidgetAdminProcedure
|
||||
legacyMigrationPrompt: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
@@ -71,7 +73,8 @@ export const managementQueryProcedures = {
|
||||
};
|
||||
}),
|
||||
|
||||
get: customWidgetAdminProcedure
|
||||
get: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({ mcp: { enabled: true, description: "Get one Custom JSX widget without secret values." } })
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
@@ -95,7 +98,8 @@ export const managementQueryProcedures = {
|
||||
};
|
||||
}),
|
||||
|
||||
available: customWidgetAdminProcedure
|
||||
available: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(z.object({ boardId: z.string(), currentId: z.string().optional() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
|
||||
@@ -114,7 +118,8 @@ export const managementQueryProcedures = {
|
||||
return [...available, mapLegacyAvailableCustomWidget(legacy)];
|
||||
}),
|
||||
|
||||
optionRequest: customWidgetAdminProcedure
|
||||
optionRequest: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(
|
||||
z.object({
|
||||
boardId: z.string(),
|
||||
|
||||
@@ -4,14 +4,16 @@ import { CUSTOM_WIDGET_MCP_AUTHORING_PROMPT } from "@homarr/custom-widgets/autho
|
||||
import { getCustomWidgetSkill } from "@homarr/custom-widgets/authoring-resources";
|
||||
import { customWidgetImportSchema, getCustomWidgetJsonSchema } from "@homarr/custom-widgets/core";
|
||||
|
||||
import { customWidgetAdminProcedure } from "./feature-flags";
|
||||
import { permissionRequiredProcedure } from "../../trpc";
|
||||
|
||||
export const metadataProcedures = {
|
||||
schema: customWidgetAdminProcedure
|
||||
schema: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({ mcp: { enabled: true, description: "Get the current Custom JSX widget JSON Schema." } })
|
||||
.query(() => getCustomWidgetJsonSchema()),
|
||||
|
||||
getAuthoringPrompt: customWidgetAdminProcedure
|
||||
getAuthoringPrompt: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({ mcp: { enabled: true, description: "Get the current Custom Widget authoring instructions." } })
|
||||
.query(() => ({
|
||||
version: 2,
|
||||
@@ -24,11 +26,13 @@ export const metadataProcedures = {
|
||||
httpResources: ["/api/custom-widgets/schema", "/api/custom-widgets/components", "/api/custom-widgets/skill"],
|
||||
})),
|
||||
|
||||
getSkill: customWidgetAdminProcedure
|
||||
getSkill: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({ mcp: { enabled: true, description: "Get the portable Homarr Custom Widget skill." } })
|
||||
.query(() => getCustomWidgetSkill()),
|
||||
|
||||
validate: customWidgetAdminProcedure
|
||||
validate: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({ mcp: { enabled: true, description: "Validate one Custom JSX widget without saving it." } })
|
||||
.input(z.object({ widget: z.unknown() }))
|
||||
.query(({ input }) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { customWidgetAdminProcedure } from "./feature-flags";
|
||||
import { permissionRequiredProcedure } from "../../trpc";
|
||||
import {
|
||||
getPreviewRequestSource,
|
||||
previewSessionRequestSchema,
|
||||
@@ -13,7 +13,8 @@ import { acquireCustomWidgetRequestLimit } from "./request-limits";
|
||||
import { getPreviewSession } from "./preview-sessions";
|
||||
|
||||
export const previewActionProcedures = {
|
||||
previewAction: customWidgetAdminProcedure
|
||||
previewAction: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({
|
||||
mcp: { enabled: true, description: "Simulate or run one named action in a custom widget preview session." },
|
||||
})
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { eq } from "@homarr/db";
|
||||
import { customWidgetDefinitions } from "@homarr/db/schema";
|
||||
|
||||
import { customWidgetAdminProcedure } from "./feature-flags";
|
||||
import { permissionRequiredProcedure } from "../../trpc";
|
||||
import { createPreviewSession, getPreviewSession } from "./preview-sessions";
|
||||
import { hasSameSecretBinding, requiredSecretKinds } from "./secret-policy";
|
||||
import { parseStoredCustomWidgetDefinition } from "./stored-definition";
|
||||
@@ -24,7 +24,8 @@ const previewCreateInputSchema = z.object({
|
||||
options: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
const previewCreateProcedure = customWidgetAdminProcedure
|
||||
const previewCreateProcedure = permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({ mcp: { enabled: true, description: "Create a short-lived preview session for one unsaved custom widget." } })
|
||||
.input(previewCreateInputSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
@@ -118,25 +119,28 @@ const previewCreateProcedure = customWidgetAdminProcedure
|
||||
|
||||
export const previewBaseProcedures = {
|
||||
previewCreate: previewCreateProcedure,
|
||||
previewGet: customWidgetAdminProcedure.input(z.object({ sessionId: z.string() })).query(async ({ ctx, input }) => {
|
||||
const session = await getPreviewSession(input.sessionId, ctx.session.user.id);
|
||||
return {
|
||||
id: session.id,
|
||||
name: session.name,
|
||||
expiresAt: session.expiresAt,
|
||||
template: session.template,
|
||||
optionDefinitions: session.optionDefinitions,
|
||||
options: session.options,
|
||||
requests: Object.entries(session.requests).map(([id, request]) => ({
|
||||
id,
|
||||
kind: request.kind,
|
||||
method: request.method,
|
||||
minimumBoardPermission: request.permission,
|
||||
trigger: request.trigger,
|
||||
confirmation: getCustomWidgetConfirmation(request),
|
||||
invalidates: request.invalidates,
|
||||
})),
|
||||
liveActions: session.liveActions,
|
||||
};
|
||||
}),
|
||||
previewGet: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(z.object({ sessionId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const session = await getPreviewSession(input.sessionId, ctx.session.user.id);
|
||||
return {
|
||||
id: session.id,
|
||||
name: session.name,
|
||||
expiresAt: session.expiresAt,
|
||||
template: session.template,
|
||||
optionDefinitions: session.optionDefinitions,
|
||||
options: session.options,
|
||||
requests: Object.entries(session.requests).map(([id, request]) => ({
|
||||
id,
|
||||
kind: request.kind,
|
||||
method: request.method,
|
||||
minimumBoardPermission: request.permission,
|
||||
trigger: request.trigger,
|
||||
confirmation: getCustomWidgetConfirmation(request),
|
||||
invalidates: request.invalidates,
|
||||
})),
|
||||
liveActions: session.liveActions,
|
||||
};
|
||||
}),
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { customWidgetAdminProcedure } from "./feature-flags";
|
||||
import { permissionRequiredProcedure } from "../../trpc";
|
||||
import {
|
||||
getPreviewRequestSource,
|
||||
previewSessionRequestSchema,
|
||||
@@ -15,7 +15,8 @@ import { acquireCustomWidgetRequestLimit } from "./request-limits";
|
||||
import { getPreviewJournal, getPreviewSession, setPreviewSessionLiveActions } from "./preview-sessions";
|
||||
|
||||
export const previewQueryProcedures = {
|
||||
previewQuery: customWidgetAdminProcedure
|
||||
previewQuery: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
@@ -76,7 +77,8 @@ export const previewQueryProcedures = {
|
||||
};
|
||||
}),
|
||||
|
||||
previewRefresh: customWidgetAdminProcedure
|
||||
previewRefresh: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(
|
||||
z.object({
|
||||
sessionId: z.string().min(1),
|
||||
@@ -109,13 +111,15 @@ export const previewQueryProcedures = {
|
||||
return { requestIds };
|
||||
}),
|
||||
|
||||
setPreviewLiveActions: customWidgetAdminProcedure
|
||||
setPreviewLiveActions: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(z.object({ sessionId: z.string().min(1), enabled: z.boolean() }))
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
setPreviewSessionLiveActions(input.sessionId, ctx.session.user.id, input.enabled),
|
||||
),
|
||||
|
||||
previewJournal: customWidgetAdminProcedure
|
||||
previewJournal: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
|
||||
@@ -18,7 +18,6 @@ import type { CustomWidgetHttpRequest, CustomWidgetHttpResponse } from "@homarr/
|
||||
import { CustomWidgetDomainError } from "@homarr/custom-widgets/server";
|
||||
|
||||
import { toTrpcError } from "./domain-error";
|
||||
import { assertCustomWidgetsEnabled } from "./feature-flags";
|
||||
import { invalidateCustomWidgetResponseCache, withCustomWidgetResponseCache } from "./response-cache";
|
||||
|
||||
export type {
|
||||
@@ -84,7 +83,6 @@ export async function executeCustomWidgetRequest(
|
||||
input: CustomWidgetHttpRequest,
|
||||
options: CustomWidgetRequestExecutionOptions = {},
|
||||
): Promise<CustomWidgetHttpResponse> {
|
||||
assertCustomWidgetsEnabled();
|
||||
try {
|
||||
return await withCustomWidgetResponseCache(input, async () => {
|
||||
const release = await options.acquireRequestLimit?.();
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from "@homarr/custom-widgets/core";
|
||||
import type { CustomWidgetSource } from "@homarr/custom-widgets/core";
|
||||
|
||||
import { customWidgetAdminProcedure } from "./feature-flags";
|
||||
import { permissionRequiredProcedure } from "../../trpc";
|
||||
import {
|
||||
createCustomWidgetConfigurationRequest,
|
||||
getCustomWidgetConfigurationRequestForUser,
|
||||
@@ -41,7 +41,8 @@ const secretRequestInputSchema = z
|
||||
});
|
||||
|
||||
export const secretProcedures = {
|
||||
secretSet: customWidgetAdminProcedure
|
||||
secretSet: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({ mcp: { enabled: true, description: "Set one encrypted secret for a custom widget source." } })
|
||||
.input(z.object({ definitionId: z.string(), secret: customWidgetSecretInputSchema }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
@@ -55,7 +56,8 @@ export const secretProcedures = {
|
||||
return { sourceId: input.secret.sourceId, kind: input.secret.kind, isSet: true };
|
||||
}),
|
||||
|
||||
sourceConfigure: customWidgetAdminProcedure
|
||||
sourceConfigure: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({ mcp: { enabled: true, description: "Configure one custom widget API source and its credentials." } })
|
||||
.input(
|
||||
z.object({
|
||||
@@ -96,7 +98,8 @@ export const secretProcedures = {
|
||||
};
|
||||
}),
|
||||
|
||||
configurationRequestUser: customWidgetAdminProcedure
|
||||
configurationRequestUser: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({ mcp: { enabled: true, description: "Create or check a short-lived user source-configuration request." } })
|
||||
.input(secretRequestInputSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
@@ -159,7 +162,8 @@ export const secretProcedures = {
|
||||
};
|
||||
}),
|
||||
|
||||
secretClear: customWidgetAdminProcedure
|
||||
secretClear: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(
|
||||
z.object({ definitionId: z.string(), sourceId: z.string(), kind: z.enum(["apiKey", "username", "password"]) }),
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ import { customWidgetDefinitionSchema } from "@homarr/custom-widgets/core";
|
||||
import { and, eq } from "@homarr/db";
|
||||
import { customWidgetDefinitions } from "@homarr/db/schema";
|
||||
|
||||
import { customWidgetAdminProcedure } from "./feature-flags";
|
||||
import { permissionRequiredProcedure } from "../../trpc";
|
||||
import { parseStoredCustomWidgetDefinition } from "./stored-definition";
|
||||
|
||||
const logger = createLogger({ module: "custom-widget" });
|
||||
@@ -32,22 +32,26 @@ const validateTemplate = (definition: ReturnType<typeof parseStoredCustomWidgetD
|
||||
};
|
||||
|
||||
export const templateProcedures = {
|
||||
readTemplate: customWidgetAdminProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
|
||||
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.id),
|
||||
});
|
||||
if (!definition) throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
|
||||
const template = parseStoredCustomWidgetDefinition(definition).template;
|
||||
return {
|
||||
id: definition.id,
|
||||
name: definition.name,
|
||||
template,
|
||||
templateLines: template.split("\n"),
|
||||
revision: getTemplateRevision(template),
|
||||
};
|
||||
}),
|
||||
readTemplate: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.id),
|
||||
});
|
||||
if (!definition) throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
|
||||
const template = parseStoredCustomWidgetDefinition(definition).template;
|
||||
return {
|
||||
id: definition.id,
|
||||
name: definition.name,
|
||||
template,
|
||||
templateLines: template.split("\n"),
|
||||
revision: getTemplateRevision(template),
|
||||
};
|
||||
}),
|
||||
|
||||
writeTemplate: customWidgetAdminProcedure
|
||||
writeTemplate: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(
|
||||
z
|
||||
.object({ id: z.string(), template: z.string().optional(), templateLines: z.array(z.string()).optional() })
|
||||
@@ -73,7 +77,8 @@ export const templateProcedures = {
|
||||
return { id: input.id, template, templateLines: template.split("\n"), revision: getTemplateRevision(template) };
|
||||
}),
|
||||
|
||||
templatePatch: customWidgetAdminProcedure
|
||||
templatePatch: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({ mcp: { enabled: true, description: "Patch selected JSX template lines using an optimistic revision." } })
|
||||
.input(
|
||||
z.object({
|
||||
|
||||
@@ -8,22 +8,26 @@ import { customWidgetDefinitions, customWidgetSecrets, legacyCustomWidgetDefinit
|
||||
import { customWidgetImportSchema, customWidgetSecretsInputSchema } from "@homarr/custom-widgets/core";
|
||||
|
||||
import { insertCustomWidgetDefinition } from "./definition-insert";
|
||||
import { customWidgetAdminProcedure } from "./feature-flags";
|
||||
import { permissionRequiredProcedure } from "../../trpc";
|
||||
import { assertSecretSources, requiredSecretKinds } from "./secret-policy";
|
||||
import { parseStoredCustomWidgetDefinition, serializeCustomWidgetDefinition } from "./stored-definition";
|
||||
|
||||
const logger = createLogger({ module: "custom-widget" });
|
||||
|
||||
export const transferProcedures = {
|
||||
export: customWidgetAdminProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
|
||||
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.id),
|
||||
});
|
||||
if (!definition) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
return parseStoredCustomWidgetDefinition(definition);
|
||||
}),
|
||||
export: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.id),
|
||||
});
|
||||
if (!definition) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
return parseStoredCustomWidgetDefinition(definition);
|
||||
}),
|
||||
|
||||
import: customWidgetAdminProcedure
|
||||
import: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(z.object({ widget: customWidgetImportSchema, secrets: customWidgetSecretsInputSchema.default([]) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
assertSecretSources(input.widget.sources, input.secrets);
|
||||
@@ -32,7 +36,8 @@ export const transferProcedures = {
|
||||
return { id };
|
||||
}),
|
||||
|
||||
migrateLegacy: customWidgetAdminProcedure
|
||||
migrateLegacy: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
|
||||
@@ -15,7 +15,7 @@ import { WorkshopBackend } from "@homarr/workshop/backend";
|
||||
import { resolveHomarrUrlConfig, validateWorkshopWidget } from "@homarr/workshop/schema";
|
||||
|
||||
import { env } from "../../env";
|
||||
import { workshopAdminProcedure } from "./feature-flags";
|
||||
import { permissionRequiredProcedure } from "../../trpc";
|
||||
import { insertCustomWidgetDefinition } from "./definition-insert";
|
||||
import { assertSecretSources } from "./secret-policy";
|
||||
|
||||
@@ -75,7 +75,8 @@ const sourceOverridesSchema = z.record(
|
||||
);
|
||||
|
||||
export const workshopProcedures = {
|
||||
workshopSearch: workshopAdminProcedure
|
||||
workshopSearch: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({ mcp: { enabled: true, description: "Search Workshop for Custom JSX widgets." } })
|
||||
.input(workshopSearchInputSchema)
|
||||
.query(async ({ input }) => {
|
||||
@@ -107,7 +108,8 @@ export const workshopProcedures = {
|
||||
}
|
||||
}),
|
||||
|
||||
workshopGet: workshopAdminProcedure
|
||||
workshopGet: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({ mcp: { enabled: true, description: "Get and validate one Workshop Custom JSX widget." } })
|
||||
.input(z.object({ submissionId: z.string().min(1) }))
|
||||
.query(async ({ input }) => {
|
||||
@@ -131,7 +133,8 @@ export const workshopProcedures = {
|
||||
};
|
||||
}),
|
||||
|
||||
workshopInstall: workshopAdminProcedure
|
||||
workshopInstall: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({ mcp: { enabled: true, description: "Install one validated Workshop Custom JSX widget." } })
|
||||
.input(
|
||||
z.object({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import SuperJSON from "superjson";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
@@ -7,9 +7,6 @@ import { eq } from "@homarr/db";
|
||||
import { boards, items, users } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ env: { CUSTOM_WIDGETS_ENABLED: true } }));
|
||||
vi.mock("../../../env", () => ({ env: mocks.env }));
|
||||
|
||||
import { optionsRouter } from "../../widgets/options";
|
||||
|
||||
const createSession = (userId: string, isAdmin = false): Session => ({
|
||||
@@ -42,10 +39,6 @@ async function setup(kind: "customApi" | "weather") {
|
||||
}
|
||||
|
||||
describe("saveItemOptions Custom Widget access", () => {
|
||||
beforeEach(() => {
|
||||
mocks.env.CUSTOM_WIDGETS_ENABLED = true;
|
||||
});
|
||||
|
||||
test("rejects direct Custom Widget reconfiguration by a non-admin board modifier", async () => {
|
||||
const { db, userId, boardId, itemId } = await setup("customApi");
|
||||
const caller = optionsRouter.createCaller({ db, deviceType: undefined, session: createSession(userId) });
|
||||
@@ -82,16 +75,6 @@ describe("saveItemOptions Custom Widget access", () => {
|
||||
expect(SuperJSON.parse(stored?.options ?? "")).toMatchObject({ refreshInterval: 60 });
|
||||
});
|
||||
|
||||
test("rejects direct admin reconfiguration while the emergency switch is off", async () => {
|
||||
const { db, userId, boardId, itemId } = await setup("customApi");
|
||||
mocks.env.CUSTOM_WIDGETS_ENABLED = false;
|
||||
const caller = optionsRouter.createCaller({ db, deviceType: undefined, session: createSession(userId, true) });
|
||||
|
||||
await expect(
|
||||
caller.saveItemOptions({ boardId, itemId, newOptions: { refreshInterval: 60 } }),
|
||||
).rejects.toMatchObject({ code: "SERVICE_UNAVAILABLE" });
|
||||
});
|
||||
|
||||
test("allows normalized missing defaults without treating them as reconfiguration", async () => {
|
||||
const { db, userId, boardId, itemId } = await setup("customApi");
|
||||
const stored = await db.query.items.findFirst({ where: eq(items.id, itemId) });
|
||||
|
||||
@@ -16,7 +16,6 @@ import { CUSTOM_WIDGET_USER_ITEM_CONCURRENCY_LIMIT } from "@homarr/custom-widget
|
||||
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
import { throwIfActionForbiddenAsync } from "../board/board-access";
|
||||
import { assertCustomWidgetsEnabled } from "../custom-widget/feature-flags";
|
||||
import { executeCustomWidgetRequest, invalidateCustomWidgetResponseCache } from "../custom-widget/request-executor";
|
||||
import {
|
||||
hashRuntimeParams,
|
||||
@@ -73,7 +72,6 @@ const parseItemOptions = (raw: string): CustomWidgetItemOptions => {
|
||||
};
|
||||
|
||||
async function resolvePlacedDefinitionAsync(ctx: RouterContext, itemId: string) {
|
||||
assertCustomWidgetsEnabled();
|
||||
const item = await ctx.db.query.items.findFirst({
|
||||
where: eq(items.id, itemId),
|
||||
columns: { id: true, boardId: true, kind: true, options: true },
|
||||
|
||||
@@ -7,7 +7,6 @@ import { getServerSettingsAsync } from "@homarr/db/queries";
|
||||
import { boards, items } from "@homarr/db/schema";
|
||||
|
||||
import type { WidgetOptionsSettings } from "../../../../widgets/src";
|
||||
import { env } from "../../env";
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
import { throwIfActionForbiddenAsync } from "../board/board-access";
|
||||
import { throwIfCustomWidgetPlacementChangeForbidden } from "../board/custom-widget-placement-access";
|
||||
@@ -51,7 +50,6 @@ export const optionsRouter = createTRPCRouter({
|
||||
const updatedOptions = { ...options, ...input.newOptions };
|
||||
throwIfCustomWidgetPlacementChangeForbidden({
|
||||
isAdmin: ctx.session.user.permissions.includes("admin"),
|
||||
customWidgetsEnabled: env.CUSTOM_WIDGETS_ENABLED !== false,
|
||||
submittedItems: [{ id: item.id, kind: item.kind, options: updatedOptions }],
|
||||
storedItems: [{ id: item.id, kind: item.kind, options }],
|
||||
});
|
||||
|
||||
@@ -29,13 +29,12 @@
|
||||
"generate:components": "pnpm generate:catalog",
|
||||
"generate": "pnpm generate:catalog",
|
||||
"evaluate:ai": "bun ./scripts/evaluate-ai-authoring.ts",
|
||||
"evaluate:mcp": "tsx ./scripts/evaluate-mcp-agent.ts",
|
||||
"test:ai": "bun ./scripts/evaluate-ai-authoring.ts --ci",
|
||||
"test:reference-apis": "tsx ./scripts/verify-reference-apis.ts",
|
||||
"format": "oxfmt .",
|
||||
"lint": "oxlint",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.scripts.json"
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "catalog:",
|
||||
@@ -62,14 +61,10 @@
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ai-sdk/mcp": "catalog:",
|
||||
"@ai-sdk/provider": "catalog:",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@openrouter/ai-sdk-provider": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"ai": "catalog:",
|
||||
"jsdom": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { createMCPClient } from "@ai-sdk/mcp";
|
||||
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
|
||||
|
||||
import { CUSTOM_WIDGET_AI_EVALUATION_CASES } from "./ai-evaluation-cases";
|
||||
import { DEFAULT_GENERATOR_MODEL } from "./ai-evaluation";
|
||||
import {
|
||||
buildCustomWidgetMcpAgentPrompt,
|
||||
createCustomWidgetMcpAgent,
|
||||
getCustomWidgetMcpToolExecutions,
|
||||
getCustomWidgetMcpWorkflowIssues,
|
||||
} from "./mcp-agent";
|
||||
|
||||
const openRouterApiKey = process.env.OPENROUTER_API_KEY;
|
||||
const mcpUrl = process.env.HOMARR_MCP_URL;
|
||||
const mcpApiKey = process.env.HOMARR_MCP_API_KEY;
|
||||
if (!openRouterApiKey) throw new Error("OPENROUTER_API_KEY is required");
|
||||
if (!mcpUrl) throw new Error("HOMARR_MCP_URL is required");
|
||||
if (!mcpApiKey) throw new Error("HOMARR_MCP_API_KEY is required");
|
||||
|
||||
const requestedCase = process.argv.find((value) => value.startsWith("--case="))?.slice("--case=".length) ?? "pokedex";
|
||||
const definitionId = process.argv.find((value) => value.startsWith("--definition="))?.slice("--definition=".length);
|
||||
const persist = process.argv.includes("--persist");
|
||||
const testCase = CUSTOM_WIDGET_AI_EVALUATION_CASES.find(({ id }) => id === requestedCase);
|
||||
if (!testCase) throw new Error(`Unknown MCP evaluation case '${requestedCase}'`);
|
||||
|
||||
const client = await createMCPClient({
|
||||
transport: { type: "http", url: mcpUrl, headers: { ApiKey: mcpApiKey } },
|
||||
});
|
||||
|
||||
try {
|
||||
const tools = await client.tools();
|
||||
const openrouter = createOpenRouter({
|
||||
apiKey: openRouterApiKey,
|
||||
appName: "Homarr Custom Widget MCP Evaluation",
|
||||
appUrl: "https://homarr.dev",
|
||||
});
|
||||
const agent = createCustomWidgetMcpAgent({
|
||||
model: openrouter(process.env.OPENROUTER_GENERATOR_MODEL ?? DEFAULT_GENERATOR_MODEL, {
|
||||
structuredOutputs: { strict: true },
|
||||
provider: { require_parameters: true, data_collection: "deny" },
|
||||
}),
|
||||
tools,
|
||||
});
|
||||
const result = await agent.generate({
|
||||
prompt: buildCustomWidgetMcpAgentPrompt({ testCase, persist, definitionId }),
|
||||
timeout: 300_000,
|
||||
});
|
||||
const toolExecutions = getCustomWidgetMcpToolExecutions(result.steps);
|
||||
const toolsUsed = toolExecutions.map(({ tool }) => tool);
|
||||
const issues = getCustomWidgetMcpWorkflowIssues({
|
||||
output: result.output,
|
||||
toolExecutions,
|
||||
persist,
|
||||
definitionId,
|
||||
requiredQueryCount: testCase.acceptance.requestRules.filter(({ kind }) => kind !== "action").length,
|
||||
requiredActionCount: testCase.acceptance.requestRules.filter(({ kind }) => kind === "action").length,
|
||||
});
|
||||
const summary = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
model: process.env.OPENROUTER_GENERATOR_MODEL ?? DEFAULT_GENERATOR_MODEL,
|
||||
caseId: testCase.id,
|
||||
persist,
|
||||
definitionId: definitionId ?? null,
|
||||
toolsUsed,
|
||||
failedTools: toolExecutions.filter(({ succeeded }) => !succeeded).map(({ tool }) => tool),
|
||||
output: result.output,
|
||||
workflowIssues: issues,
|
||||
};
|
||||
const outputDirectory = path.resolve(process.cwd(), ".ai-evaluations", "mcp");
|
||||
await mkdir(outputDirectory, { recursive: true });
|
||||
const outputFile = path.join(outputDirectory, `${testCase.id}-${Date.now()}.json`);
|
||||
await writeFile(outputFile, JSON.stringify(summary, null, 2), "utf8");
|
||||
process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
|
||||
if (result.output.status === "fail" || issues.length > 0) process.exitCode = 1;
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
@@ -1,322 +0,0 @@
|
||||
import type { LanguageModel, ToolSet } from "ai";
|
||||
import { Output, stepCountIs, ToolLoopAgent } from "ai";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { CustomWidgetAiEvaluationCase } from "./ai-evaluation-cases";
|
||||
|
||||
export const CUSTOM_WIDGET_MCP_AGENT_TOOLS = [
|
||||
"customWidget_get",
|
||||
"customWidget_getAuthoringPrompt",
|
||||
"customWidget_getSkill",
|
||||
"customWidget_schema",
|
||||
"customWidget_validate",
|
||||
"customWidget_previewCreate",
|
||||
"customWidget_previewQuery",
|
||||
"customWidget_previewAction",
|
||||
"customWidget_previewJournal",
|
||||
"customWidget_create",
|
||||
"customWidget_update",
|
||||
"customWidget_configurationRequestUser",
|
||||
] as const;
|
||||
|
||||
export const customWidgetMcpAgentResultSchema = z.strictObject({
|
||||
status: z.enum(["pass", "needs-user-configuration", "fail"]),
|
||||
summary: z.string().min(1).max(1_000),
|
||||
definitionId: z.string().min(1).nullable(),
|
||||
previewUrl: z.url().nullable(),
|
||||
iterations: z.number().int().min(1).max(20),
|
||||
evidence: z
|
||||
.array(
|
||||
z.strictObject({
|
||||
tool: z.string().min(1).max(128),
|
||||
outcome: z.enum(["passed", "repaired", "pending", "failed"]),
|
||||
detail: z.string().min(1).max(500),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(30),
|
||||
remainingIssues: z.array(z.string().min(1).max(500)).max(10),
|
||||
});
|
||||
|
||||
export type CustomWidgetMcpAgentResult = z.infer<typeof customWidgetMcpAgentResultSchema>;
|
||||
|
||||
export interface CustomWidgetMcpToolExecution {
|
||||
tool: string;
|
||||
succeeded: boolean;
|
||||
requestId?: string;
|
||||
configurationStatus?: "pending" | "completed";
|
||||
}
|
||||
|
||||
interface AgentToolStep {
|
||||
toolCalls: ReadonlyArray<{ toolCallId: string; toolName: string; input: unknown }>;
|
||||
toolResults: ReadonlyArray<{ toolCallId: string; toolName: string; output: unknown }>;
|
||||
}
|
||||
|
||||
const AGENT_INSTRUCTIONS = `You are the Homarr Custom Widget release evaluator. Use only the supplied Custom Widget MCP tools.
|
||||
|
||||
Read the live authoring prompt, skill, and schema before authoring. Never invent a tool, API route, validation result, preview result, or credential. Validate every revision. Create a preview, execute every load query that can run, simulate actions instead of causing side effects, and inspect the preview journal. Repair validation, request, runtime, and template problems and repeat the failed checks.
|
||||
|
||||
Never put plaintext credentials in a widget, response, summary, or tool argument. For an authenticated source without existing credentials, create a user configuration request with customWidget_configurationRequestUser. If the user completes it while the workflow is active, check the same request until it reports completed and then resume the preview checks. Otherwise report needs-user-configuration. Never repeat its URL or request token in the structured result. Do not call secret-writing tools.
|
||||
|
||||
Revise unsaved definitions in your working context; do not mutate a stored template while debugging. Persist only when the user prompt explicitly says persistence is allowed and all runnable checks pass. For edits, read the current definition first and update the same definition. Return the required structured result with evidence tied to successful tool calls. A pass must have no remaining issues.`;
|
||||
|
||||
export function selectCustomWidgetMcpTools(tools: Readonly<Record<string, unknown>>): ToolSet {
|
||||
return Object.fromEntries(
|
||||
CUSTOM_WIDGET_MCP_AGENT_TOOLS.flatMap((name) => (tools[name] ? [[name, tools[name]]] : [])),
|
||||
) as ToolSet;
|
||||
}
|
||||
|
||||
export function createCustomWidgetMcpAgent(args: { model: LanguageModel; tools: Readonly<Record<string, unknown>> }) {
|
||||
return new ToolLoopAgent({
|
||||
model: args.model,
|
||||
tools: selectCustomWidgetMcpTools(args.tools),
|
||||
instructions: AGENT_INSTRUCTIONS,
|
||||
stopWhen: stepCountIs(20),
|
||||
temperature: 0,
|
||||
output: Output.object({ schema: customWidgetMcpAgentResultSchema }),
|
||||
});
|
||||
}
|
||||
|
||||
export function buildCustomWidgetMcpAgentPrompt(args: {
|
||||
testCase: CustomWidgetAiEvaluationCase;
|
||||
persist: boolean;
|
||||
definitionId?: string;
|
||||
}) {
|
||||
const task = args.definitionId
|
||||
? `Edit and debug Custom Widget definition ${args.definitionId}.`
|
||||
: "Create and debug a new Custom Widget.";
|
||||
return `${task}
|
||||
|
||||
User request:
|
||||
${args.testCase.request}
|
||||
|
||||
Verified API notes:
|
||||
${args.testCase.apiNotes}
|
||||
|
||||
API documentation:
|
||||
${args.testCase.documentationUrl}
|
||||
|
||||
Persistence is ${args.persist ? "allowed after every runnable check passes" : "not allowed; stop after the validated preview"}.`;
|
||||
}
|
||||
|
||||
export function getCustomWidgetMcpToolExecutions(steps: readonly AgentToolStep[]): CustomWidgetMcpToolExecution[] {
|
||||
const results = new Map(
|
||||
steps.flatMap(({ toolResults }) => toolResults).map((result) => [result.toolCallId, result] as const),
|
||||
);
|
||||
return steps.flatMap(({ toolCalls }) =>
|
||||
toolCalls.map((call) => {
|
||||
const requestId = asRecord(call.input)?.requestId;
|
||||
const output = results.get(call.toolCallId)?.output;
|
||||
const status = asRecord(getMcpToolPayload(output))?.status;
|
||||
return {
|
||||
tool: call.toolName,
|
||||
succeeded: isSuccessfulMcpToolOutput(call.toolName, output),
|
||||
...(typeof requestId === "string" ? { requestId } : {}),
|
||||
...(call.toolName === "customWidget_configurationRequestUser" &&
|
||||
(status === "pending" || status === "completed")
|
||||
? { configurationStatus: status }
|
||||
: {}),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function getCustomWidgetMcpWorkflowIssues(args: {
|
||||
output: CustomWidgetMcpAgentResult;
|
||||
toolExecutions: readonly CustomWidgetMcpToolExecution[];
|
||||
persist: boolean;
|
||||
definitionId?: string;
|
||||
requiredQueryCount?: number;
|
||||
requiredActionCount?: number;
|
||||
}) {
|
||||
const issues: string[] = [];
|
||||
const firstIndex = (tool: string) => args.toolExecutions.findIndex((entry) => entry.tool === tool);
|
||||
const firstSuccessfulIndex = (tool: string) =>
|
||||
args.toolExecutions.findIndex((entry) => entry.tool === tool && entry.succeeded);
|
||||
const lastSuccessfulIndex = (tool: string) =>
|
||||
args.toolExecutions.findLastIndex((entry) => entry.tool === tool && entry.succeeded);
|
||||
const hasTool = (tool: string) => firstIndex(tool) >= 0;
|
||||
const hasSuccessfulTool = (tool: string) => firstSuccessfulIndex(tool) >= 0;
|
||||
const required = [
|
||||
"customWidget_getAuthoringPrompt",
|
||||
"customWidget_getSkill",
|
||||
"customWidget_schema",
|
||||
"customWidget_validate",
|
||||
];
|
||||
if (args.definitionId) required.unshift("customWidget_get");
|
||||
if (args.output.status === "pass") {
|
||||
required.push("customWidget_previewCreate", "customWidget_previewQuery", "customWidget_previewJournal");
|
||||
if ((args.requiredActionCount ?? 0) > 0) required.push("customWidget_previewAction");
|
||||
if (args.persist) required.push(args.definitionId ? "customWidget_update" : "customWidget_create");
|
||||
}
|
||||
if (args.output.status === "needs-user-configuration") {
|
||||
required.push("customWidget_configurationRequestUser");
|
||||
if (!args.persist && !args.definitionId) required.push("customWidget_previewCreate");
|
||||
if (args.persist && !args.definitionId) required.push("customWidget_create");
|
||||
}
|
||||
if (hasTool("customWidget_configurationRequestUser") && !required.includes("customWidget_configurationRequestUser")) {
|
||||
required.push("customWidget_configurationRequestUser");
|
||||
}
|
||||
for (const tool of required) {
|
||||
if (!hasTool(tool)) issues.push(`Required tool was not used: ${tool}`);
|
||||
else if (!hasSuccessfulTool(tool)) issues.push(`Required tool did not complete successfully: ${tool}`);
|
||||
}
|
||||
|
||||
const firstValidation = firstSuccessfulIndex("customWidget_validate");
|
||||
const finalValidation = lastSuccessfulIndex("customWidget_validate");
|
||||
for (const tool of ["customWidget_getAuthoringPrompt", "customWidget_getSkill", "customWidget_schema"]) {
|
||||
if (hasSuccessfulTool(tool) && firstValidation >= 0 && firstSuccessfulIndex(tool) > firstValidation) {
|
||||
issues.push(`${tool} must run before the first validation`);
|
||||
}
|
||||
}
|
||||
if (
|
||||
args.definitionId &&
|
||||
hasSuccessfulTool("customWidget_get") &&
|
||||
firstValidation >= 0 &&
|
||||
firstSuccessfulIndex("customWidget_get") > firstValidation
|
||||
) {
|
||||
issues.push("customWidget_get must run before validating an edit");
|
||||
}
|
||||
const previewCreate = firstSuccessfulIndex("customWidget_previewCreate");
|
||||
const finalPreviewCreate = lastSuccessfulIndex("customWidget_previewCreate");
|
||||
if (finalValidation >= 0 && finalPreviewCreate >= 0 && finalValidation > finalPreviewCreate) {
|
||||
issues.push("The final validation must run before customWidget_previewCreate");
|
||||
}
|
||||
const previewChecks = [
|
||||
"customWidget_previewQuery",
|
||||
...((args.requiredActionCount ?? 0) > 0 ? ["customWidget_previewAction"] : []),
|
||||
"customWidget_previewJournal",
|
||||
];
|
||||
for (const tool of previewChecks) {
|
||||
if (hasSuccessfulTool(tool) && previewCreate >= 0 && firstSuccessfulIndex(tool) < previewCreate) {
|
||||
issues.push(`${tool} must run after customWidget_previewCreate`);
|
||||
}
|
||||
}
|
||||
if (args.output.status === "pass") {
|
||||
const completedConfiguration = args.toolExecutions.findLastIndex(
|
||||
(entry) =>
|
||||
entry.tool === "customWidget_configurationRequestUser" &&
|
||||
entry.succeeded &&
|
||||
entry.configurationStatus === "completed",
|
||||
);
|
||||
if (hasTool("customWidget_configurationRequestUser") && completedConfiguration < 0) {
|
||||
issues.push("A passing result requires the user configuration request to report completed");
|
||||
}
|
||||
const verifiedExecutions =
|
||||
completedConfiguration >= 0 ? args.toolExecutions.slice(completedConfiguration + 1) : args.toolExecutions;
|
||||
for (const [tool, requiredCount] of [
|
||||
["customWidget_previewQuery", args.requiredQueryCount ?? 1],
|
||||
["customWidget_previewAction", args.requiredActionCount ?? 0],
|
||||
] as const) {
|
||||
const completed = verifiedExecutions.filter((entry) => entry.tool === tool && entry.succeeded);
|
||||
const distinctRequestIds = new Set(completed.flatMap(({ requestId }) => (requestId ? [requestId] : [])));
|
||||
const completedCount = distinctRequestIds.size > 0 ? distinctRequestIds.size : completed.length;
|
||||
if (completedCount < requiredCount) {
|
||||
issues.push(`${tool} completed ${completedCount} time(s); expected at least ${requiredCount}`);
|
||||
}
|
||||
}
|
||||
const finalJournal = lastSuccessfulIndex("customWidget_previewJournal");
|
||||
if (finalJournal >= 0) {
|
||||
for (const tool of ["customWidget_previewQuery", "customWidget_previewAction"]) {
|
||||
if (lastSuccessfulIndex(tool) > finalJournal) {
|
||||
issues.push("customWidget_previewJournal must run after all preview requests");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const persistenceTool = args.definitionId ? "customWidget_update" : "customWidget_create";
|
||||
if (args.persist && args.output.status === "pass" && hasSuccessfulTool(persistenceTool)) {
|
||||
const lastPreviewCheck = Math.max(...previewChecks.map(lastSuccessfulIndex));
|
||||
if (lastSuccessfulIndex(persistenceTool) < lastPreviewCheck) {
|
||||
issues.push(`${persistenceTool} must run after all required preview checks`);
|
||||
}
|
||||
}
|
||||
if (!args.persist) {
|
||||
for (const tool of ["customWidget_create", "customWidget_update"]) {
|
||||
if (hasTool(tool)) issues.push(`${tool} was used even though persistence was not allowed`);
|
||||
}
|
||||
}
|
||||
|
||||
const evidenceTools = new Set(args.output.evidence.map(({ tool }) => tool));
|
||||
for (const evidence of args.output.evidence) {
|
||||
const tool = evidence.tool;
|
||||
if (!hasTool(tool)) issues.push(`Structured evidence names a tool that was not used: ${tool}`);
|
||||
else if (evidence.outcome !== "failed" && !hasSuccessfulTool(tool)) {
|
||||
issues.push(`Structured evidence claims a successful outcome for a failed tool: ${tool}`);
|
||||
}
|
||||
}
|
||||
for (const tool of required) {
|
||||
if (hasSuccessfulTool(tool) && !evidenceTools.has(tool)) {
|
||||
issues.push(`Structured evidence is missing the required tool: ${tool}`);
|
||||
}
|
||||
}
|
||||
if (args.output.status === "pass" && args.output.remainingIssues.length > 0) {
|
||||
issues.push("A passing result cannot contain remaining issues");
|
||||
}
|
||||
if (args.output.status === "pass" && args.output.previewUrl === null) {
|
||||
issues.push("A passing result must include the tested preview URL");
|
||||
}
|
||||
if (args.persist && args.output.definitionId === null) {
|
||||
issues.push("A persisted result must include the definition ID");
|
||||
}
|
||||
if (args.output.status === "needs-user-configuration") {
|
||||
if (!hasSuccessfulTool("customWidget_configurationRequestUser")) {
|
||||
issues.push("User configuration status requires customWidget_configurationRequestUser");
|
||||
} else if (!evidenceTools.has("customWidget_configurationRequestUser")) {
|
||||
issues.push("Structured evidence is missing customWidget_configurationRequestUser");
|
||||
}
|
||||
const finalConfiguration = args.toolExecutions.findLast(
|
||||
(entry) => entry.tool === "customWidget_configurationRequestUser" && entry.succeeded,
|
||||
);
|
||||
if (finalConfiguration?.configurationStatus !== "pending") {
|
||||
issues.push("User configuration status requires a pending configuration request");
|
||||
}
|
||||
const configurationRequest = firstSuccessfulIndex("customWidget_configurationRequestUser");
|
||||
const targetCreation = args.persist ? lastSuccessfulIndex(persistenceTool) : previewCreate;
|
||||
if (!args.definitionId && targetCreation >= 0 && configurationRequest < targetCreation) {
|
||||
issues.push("customWidget_configurationRequestUser must run after its definition or preview target exists");
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
function isSuccessfulMcpToolOutput(tool: string, output: unknown) {
|
||||
const record = asRecord(output);
|
||||
if (!record || record.isError === true) return false;
|
||||
const payload = getMcpToolPayload(output);
|
||||
if (tool === "customWidget_validate") return asRecord(payload)?.valid === true;
|
||||
if (tool === "customWidget_previewCreate") return asRecord(payload)?.success === true;
|
||||
if (tool === "customWidget_previewQuery" || tool === "customWidget_previewAction") {
|
||||
return asRecord(payload)?.ok === true;
|
||||
}
|
||||
return output !== undefined;
|
||||
}
|
||||
|
||||
function getMcpToolPayload(output: unknown) {
|
||||
const record = asRecord(output);
|
||||
return record ? (decodeMcpTextPayload(record) ?? record) : undefined;
|
||||
}
|
||||
|
||||
function decodeMcpTextPayload(output: Record<string, unknown>) {
|
||||
if (!Array.isArray(output.content)) return undefined;
|
||||
const text = output.content
|
||||
.flatMap((part) => {
|
||||
const record = asRecord(part);
|
||||
return record?.type === "text" && typeof record.text === "string" ? [record.text] : [];
|
||||
})
|
||||
.join("");
|
||||
if (!text) return undefined;
|
||||
try {
|
||||
return JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
@@ -1,519 +0,0 @@
|
||||
import type { LanguageModelV3GenerateResult } from "@ai-sdk/provider";
|
||||
import { tool } from "ai";
|
||||
import { MockLanguageModelV3 } from "ai/test";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
createCustomWidgetMcpAgent,
|
||||
getCustomWidgetMcpToolExecutions,
|
||||
getCustomWidgetMcpWorkflowIssues,
|
||||
} from "../../scripts/mcp-agent";
|
||||
|
||||
const usage = {
|
||||
inputTokens: { total: 10, noCache: 10, cacheRead: undefined, cacheWrite: undefined },
|
||||
outputTokens: { total: 10, text: 10, reasoning: undefined },
|
||||
};
|
||||
|
||||
const toolCall = (
|
||||
toolName: string,
|
||||
index: number,
|
||||
input: Record<string, unknown> = {},
|
||||
): LanguageModelV3GenerateResult => ({
|
||||
content: [{ type: "tool-call", toolCallId: `call-${index}`, toolName, input: JSON.stringify(input) }],
|
||||
finishReason: { unified: "tool-calls", raw: undefined },
|
||||
usage,
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
const finalResult = (value: unknown): LanguageModelV3GenerateResult => ({
|
||||
content: [{ type: "text", text: JSON.stringify(value) }],
|
||||
finishReason: { unified: "stop", raw: undefined },
|
||||
usage,
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
const successfulExecutions = (tools: readonly string[]) =>
|
||||
tools.map((toolName) => ({ tool: toolName, succeeded: true }));
|
||||
const mcpOutput = (value: unknown) => ({ content: [{ type: "text" as const, text: JSON.stringify(value) }] });
|
||||
|
||||
describe("Custom Widget MCP agent", () => {
|
||||
it("runs a repair, preview, and persistence workflow before structured success", async () => {
|
||||
const calls: string[] = [];
|
||||
let validationCount = 0;
|
||||
const makeTool = (name: string) =>
|
||||
tool({
|
||||
description: name,
|
||||
inputSchema: z.object({ requestId: z.string().optional() }),
|
||||
execute: vi.fn(() => {
|
||||
calls.push(name);
|
||||
if (name === "customWidget_validate") {
|
||||
validationCount += 1;
|
||||
return mcpOutput({ valid: validationCount > 1 });
|
||||
}
|
||||
if (name === "customWidget_previewCreate") return mcpOutput({ success: true });
|
||||
if (name === "customWidget_previewQuery" || name === "customWidget_previewAction") {
|
||||
return mcpOutput({ ok: true });
|
||||
}
|
||||
return mcpOutput({});
|
||||
}),
|
||||
});
|
||||
const sequence = [
|
||||
"customWidget_getAuthoringPrompt",
|
||||
"customWidget_getSkill",
|
||||
"customWidget_schema",
|
||||
"customWidget_validate",
|
||||
"customWidget_validate",
|
||||
"customWidget_previewCreate",
|
||||
"customWidget_previewQuery",
|
||||
"customWidget_previewQuery",
|
||||
"customWidget_previewAction",
|
||||
"customWidget_previewJournal",
|
||||
"customWidget_create",
|
||||
];
|
||||
const output = {
|
||||
status: "pass" as const,
|
||||
summary: "The repaired widget passed validation and preview checks and was persisted.",
|
||||
definitionId: "widget-1",
|
||||
previewUrl: "http://localhost:3000/manage/custom-widgets/preview/session-1",
|
||||
iterations: 2,
|
||||
evidence: [...new Set(sequence)].map((name) => ({
|
||||
tool: name,
|
||||
outcome: name === "customWidget_validate" ? ("repaired" as const) : ("passed" as const),
|
||||
detail: `${name} completed.`,
|
||||
})),
|
||||
remainingIssues: [],
|
||||
};
|
||||
let queryIndex = 0;
|
||||
const responses = [
|
||||
...sequence.map((toolName, index) => {
|
||||
if (toolName === "customWidget_previewQuery") {
|
||||
queryIndex += 1;
|
||||
return toolCall(toolName, index, { requestId: `query-${queryIndex}` });
|
||||
}
|
||||
if (toolName === "customWidget_previewAction") return toolCall(toolName, index, { requestId: "action-1" });
|
||||
return toolCall(toolName, index);
|
||||
}),
|
||||
finalResult(output),
|
||||
];
|
||||
let responseIndex = 0;
|
||||
const model = new MockLanguageModelV3({
|
||||
doGenerate: async () => responses[responseIndex++] ?? finalResult(output),
|
||||
});
|
||||
const tools = Object.fromEntries(sequence.map((name) => [name, makeTool(name)]));
|
||||
|
||||
const result = await createCustomWidgetMcpAgent({ model, tools }).generate({ prompt: "Build a widget" });
|
||||
expect(result.output).toEqual(output);
|
||||
expect(calls).toEqual(sequence);
|
||||
expect(model.doGenerateCalls.at(-1)?.responseFormat).toMatchObject({ type: "json" });
|
||||
const toolExecutions = getCustomWidgetMcpToolExecutions(result.steps);
|
||||
expect(toolExecutions.filter(({ tool: toolName }) => toolName === "customWidget_validate")).toEqual([
|
||||
{ tool: "customWidget_validate", succeeded: false },
|
||||
{ tool: "customWidget_validate", succeeded: true },
|
||||
]);
|
||||
expect(
|
||||
toolExecutions
|
||||
.filter(({ tool: toolName }) => toolName === "customWidget_previewQuery")
|
||||
.map(({ requestId }) => requestId),
|
||||
).toEqual(["query-1", "query-2"]);
|
||||
expect(
|
||||
getCustomWidgetMcpWorkflowIssues({
|
||||
output: result.output,
|
||||
toolExecutions,
|
||||
persist: true,
|
||||
requiredQueryCount: 2,
|
||||
requiredActionCount: 1,
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses the current definition and updates it in the edit workflow", async () => {
|
||||
const calls: string[] = [];
|
||||
const sequence = [
|
||||
"customWidget_getAuthoringPrompt",
|
||||
"customWidget_getSkill",
|
||||
"customWidget_schema",
|
||||
"customWidget_get",
|
||||
"customWidget_validate",
|
||||
"customWidget_previewCreate",
|
||||
"customWidget_previewQuery",
|
||||
"customWidget_previewJournal",
|
||||
"customWidget_update",
|
||||
];
|
||||
const output = {
|
||||
status: "pass" as const,
|
||||
summary: "The existing widget passed its edited preview and was updated.",
|
||||
definitionId: "widget-1",
|
||||
previewUrl: "http://localhost:3000/manage/custom-widgets/preview/session-2",
|
||||
iterations: 1,
|
||||
evidence: sequence.map((name) => ({ tool: name, outcome: "passed" as const, detail: `${name} completed.` })),
|
||||
remainingIssues: [],
|
||||
};
|
||||
const responses = [
|
||||
...sequence.map((toolName, index) =>
|
||||
toolCall(toolName, index, toolName === "customWidget_previewQuery" ? { requestId: "query-1" } : {}),
|
||||
),
|
||||
finalResult(output),
|
||||
];
|
||||
let responseIndex = 0;
|
||||
const model = new MockLanguageModelV3({
|
||||
doGenerate: async () => responses[responseIndex++] ?? finalResult(output),
|
||||
});
|
||||
const tools = Object.fromEntries(
|
||||
sequence.map((name) => [
|
||||
name,
|
||||
tool({
|
||||
description: name,
|
||||
inputSchema: z.object({ requestId: z.string().optional() }),
|
||||
execute: () => {
|
||||
calls.push(name);
|
||||
if (name === "customWidget_validate") return mcpOutput({ valid: true });
|
||||
if (name === "customWidget_previewCreate") return mcpOutput({ success: true });
|
||||
if (name === "customWidget_previewQuery") return mcpOutput({ ok: true });
|
||||
return mcpOutput({});
|
||||
},
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await createCustomWidgetMcpAgent({ model, tools }).generate({ prompt: "Edit widget-1" });
|
||||
expect(result.output).toEqual(output);
|
||||
expect(
|
||||
getCustomWidgetMcpWorkflowIssues({
|
||||
output: result.output,
|
||||
toolExecutions: getCustomWidgetMcpToolExecutions(result.steps),
|
||||
persist: true,
|
||||
definitionId: "widget-1",
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects a passing summary that skipped real MCP checks", () => {
|
||||
expect(
|
||||
getCustomWidgetMcpWorkflowIssues({
|
||||
output: {
|
||||
status: "pass",
|
||||
summary: "Looks good.",
|
||||
definitionId: null,
|
||||
previewUrl: null,
|
||||
iterations: 1,
|
||||
evidence: [{ tool: "customWidget_validate", outcome: "passed", detail: "Valid." }],
|
||||
remainingIssues: ["Preview was not tested."],
|
||||
},
|
||||
toolExecutions: successfulExecutions(["customWidget_validate"]),
|
||||
persist: false,
|
||||
}),
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining("customWidget_previewCreate"),
|
||||
"A passing result cannot contain remaining issues",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects fabricated evidence and persistence before the final preview checks", () => {
|
||||
const toolsUsed = [
|
||||
"customWidget_getAuthoringPrompt",
|
||||
"customWidget_getSkill",
|
||||
"customWidget_schema",
|
||||
"customWidget_validate",
|
||||
"customWidget_create",
|
||||
"customWidget_previewCreate",
|
||||
"customWidget_previewQuery",
|
||||
"customWidget_previewJournal",
|
||||
];
|
||||
|
||||
expect(
|
||||
getCustomWidgetMcpWorkflowIssues({
|
||||
output: {
|
||||
status: "pass",
|
||||
summary: "The widget was tested.",
|
||||
definitionId: "widget-1",
|
||||
previewUrl: "http://localhost:3000/manage/custom-widgets/preview/session-3",
|
||||
iterations: 1,
|
||||
evidence: [
|
||||
...toolsUsed.map((name) => ({ tool: name, outcome: "passed" as const, detail: `${name} completed.` })),
|
||||
{ tool: "customWidget_previewAction", outcome: "passed", detail: "The model claimed an action passed." },
|
||||
],
|
||||
remainingIssues: [],
|
||||
},
|
||||
toolExecutions: successfulExecutions(toolsUsed),
|
||||
persist: true,
|
||||
}),
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
"customWidget_create must run after all required preview checks",
|
||||
"Structured evidence names a tool that was not used: customWidget_previewAction",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts a repaired preview followed by final validation and a fresh preview", () => {
|
||||
const toolsUsed = [
|
||||
"customWidget_getAuthoringPrompt",
|
||||
"customWidget_getSkill",
|
||||
"customWidget_schema",
|
||||
"customWidget_validate",
|
||||
"customWidget_previewCreate",
|
||||
"customWidget_validate",
|
||||
"customWidget_previewCreate",
|
||||
"customWidget_previewQuery",
|
||||
"customWidget_previewJournal",
|
||||
];
|
||||
|
||||
expect(
|
||||
getCustomWidgetMcpWorkflowIssues({
|
||||
output: {
|
||||
status: "pass",
|
||||
summary: "The repaired widget passed a fresh preview.",
|
||||
definitionId: null,
|
||||
previewUrl: "http://localhost:3000/manage/custom-widgets/preview/session-repaired",
|
||||
iterations: 2,
|
||||
evidence: [...new Set(toolsUsed)].map((name) => ({
|
||||
tool: name,
|
||||
outcome: name === "customWidget_validate" ? ("repaired" as const) : ("passed" as const),
|
||||
detail: `${name} completed.`,
|
||||
})),
|
||||
remainingIssues: [],
|
||||
},
|
||||
toolExecutions: successfulExecutions(toolsUsed),
|
||||
persist: false,
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not report journal ordering when the journal was never run", () => {
|
||||
const toolsUsed = [
|
||||
"customWidget_getAuthoringPrompt",
|
||||
"customWidget_getSkill",
|
||||
"customWidget_schema",
|
||||
"customWidget_validate",
|
||||
"customWidget_previewCreate",
|
||||
"customWidget_previewQuery",
|
||||
];
|
||||
|
||||
const issues = getCustomWidgetMcpWorkflowIssues({
|
||||
output: {
|
||||
status: "pass",
|
||||
summary: "The agent incorrectly skipped the journal.",
|
||||
definitionId: null,
|
||||
previewUrl: "http://localhost:3000/manage/custom-widgets/preview/session-no-journal",
|
||||
iterations: 1,
|
||||
evidence: toolsUsed.map((name) => ({
|
||||
tool: name,
|
||||
outcome: "passed" as const,
|
||||
detail: `${name} completed.`,
|
||||
})),
|
||||
remainingIssues: [],
|
||||
},
|
||||
toolExecutions: successfulExecutions(toolsUsed),
|
||||
persist: false,
|
||||
});
|
||||
|
||||
expect(issues).toContain("Required tool was not used: customWidget_previewJournal");
|
||||
expect(issues).not.toContain("customWidget_previewJournal must run after all preview requests");
|
||||
});
|
||||
|
||||
it("allows secure preview configuration to pause before authenticated requests can run", () => {
|
||||
const toolsUsed = [
|
||||
"customWidget_getAuthoringPrompt",
|
||||
"customWidget_getSkill",
|
||||
"customWidget_schema",
|
||||
"customWidget_validate",
|
||||
"customWidget_previewCreate",
|
||||
"customWidget_configurationRequestUser",
|
||||
];
|
||||
|
||||
expect(
|
||||
getCustomWidgetMcpWorkflowIssues({
|
||||
output: {
|
||||
status: "needs-user-configuration",
|
||||
summary: "The definition is valid and the authenticated preview is waiting for secure user input.",
|
||||
definitionId: null,
|
||||
previewUrl: "http://localhost:3000/manage/custom-widgets/preview/session-4",
|
||||
iterations: 1,
|
||||
evidence: [...new Set(toolsUsed)].map((name) => ({
|
||||
tool: name,
|
||||
outcome: "passed" as const,
|
||||
detail: `${name} completed.`,
|
||||
})),
|
||||
remainingIssues: [],
|
||||
},
|
||||
toolExecutions: successfulExecutions(toolsUsed).map((entry) =>
|
||||
entry.tool === "customWidget_configurationRequestUser"
|
||||
? { ...entry, configurationStatus: "pending" as const }
|
||||
: entry,
|
||||
),
|
||||
persist: false,
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("resumes preview checks after secure user configuration reports completion", async () => {
|
||||
let configurationCalls = 0;
|
||||
const sequence = [
|
||||
"customWidget_getAuthoringPrompt",
|
||||
"customWidget_getSkill",
|
||||
"customWidget_schema",
|
||||
"customWidget_validate",
|
||||
"customWidget_previewCreate",
|
||||
"customWidget_configurationRequestUser",
|
||||
"customWidget_configurationRequestUser",
|
||||
"customWidget_previewQuery",
|
||||
"customWidget_previewJournal",
|
||||
];
|
||||
const output = {
|
||||
status: "pass" as const,
|
||||
summary: "The user completed secure source configuration and the authenticated preview passed.",
|
||||
definitionId: null,
|
||||
previewUrl: "http://localhost:3000/manage/custom-widgets/preview/session-configured",
|
||||
iterations: 2,
|
||||
evidence: [...new Set(sequence)].map((name) => ({
|
||||
tool: name,
|
||||
outcome: "passed" as const,
|
||||
detail: `${name} completed.`,
|
||||
})),
|
||||
remainingIssues: [],
|
||||
};
|
||||
const responses = [
|
||||
...sequence.map((toolName, index) => {
|
||||
if (toolName === "customWidget_configurationRequestUser") {
|
||||
return toolCall(
|
||||
toolName,
|
||||
index,
|
||||
configurationCalls++ === 0
|
||||
? { previewSessionId: "preview-1", sourceId: "default" }
|
||||
: { requestId: "configuration-1" },
|
||||
);
|
||||
}
|
||||
return toolCall(toolName, index, toolName === "customWidget_previewQuery" ? { requestId: "query-1" } : {});
|
||||
}),
|
||||
finalResult(output),
|
||||
];
|
||||
let responseIndex = 0;
|
||||
let configurationResults = 0;
|
||||
const model = new MockLanguageModelV3({
|
||||
doGenerate: async () => responses[responseIndex++] ?? finalResult(output),
|
||||
});
|
||||
const tools = Object.fromEntries(
|
||||
sequence.map((name) => [
|
||||
name,
|
||||
tool({
|
||||
description: name,
|
||||
inputSchema: z.object({
|
||||
requestId: z.string().optional(),
|
||||
previewSessionId: z.string().optional(),
|
||||
sourceId: z.string().optional(),
|
||||
}),
|
||||
execute: () => {
|
||||
if (name === "customWidget_validate") return mcpOutput({ valid: true });
|
||||
if (name === "customWidget_previewCreate") return mcpOutput({ success: true });
|
||||
if (name === "customWidget_previewQuery") return mcpOutput({ ok: true });
|
||||
if (name === "customWidget_configurationRequestUser") {
|
||||
configurationResults += 1;
|
||||
return mcpOutput({
|
||||
requestId: "configuration-1",
|
||||
status: configurationResults === 1 ? "pending" : "completed",
|
||||
});
|
||||
}
|
||||
return mcpOutput({});
|
||||
},
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await createCustomWidgetMcpAgent({ model, tools }).generate({ prompt: "Build a Jellyfin widget" });
|
||||
const toolExecutions = getCustomWidgetMcpToolExecutions(result.steps);
|
||||
expect(
|
||||
toolExecutions
|
||||
.filter(({ tool: toolName }) => toolName === "customWidget_configurationRequestUser")
|
||||
.map(({ configurationStatus }) => configurationStatus),
|
||||
).toEqual(["pending", "completed"]);
|
||||
expect(
|
||||
getCustomWidgetMcpWorkflowIssues({
|
||||
output: result.output,
|
||||
toolExecutions,
|
||||
persist: false,
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects success while secure user configuration is still pending", () => {
|
||||
const toolsUsed = [
|
||||
"customWidget_getAuthoringPrompt",
|
||||
"customWidget_getSkill",
|
||||
"customWidget_schema",
|
||||
"customWidget_validate",
|
||||
"customWidget_previewCreate",
|
||||
"customWidget_configurationRequestUser",
|
||||
"customWidget_previewQuery",
|
||||
"customWidget_previewJournal",
|
||||
];
|
||||
const toolExecutions = successfulExecutions(toolsUsed).map((entry) =>
|
||||
entry.tool === "customWidget_configurationRequestUser"
|
||||
? { ...entry, configurationStatus: "pending" as const }
|
||||
: entry,
|
||||
);
|
||||
|
||||
expect(
|
||||
getCustomWidgetMcpWorkflowIssues({
|
||||
output: {
|
||||
status: "pass",
|
||||
summary: "The agent claimed the authenticated preview passed before configuration completed.",
|
||||
definitionId: null,
|
||||
previewUrl: "http://localhost:3000/manage/custom-widgets/preview/session-pending",
|
||||
iterations: 1,
|
||||
evidence: toolsUsed.map((name) => ({
|
||||
tool: name,
|
||||
outcome: "passed" as const,
|
||||
detail: `${name} completed.`,
|
||||
})),
|
||||
remainingIssues: [],
|
||||
},
|
||||
toolExecutions,
|
||||
persist: false,
|
||||
}),
|
||||
).toContain("A passing result requires the user configuration request to report completed");
|
||||
});
|
||||
|
||||
it("never exposes a credential-writing tool to the model", async () => {
|
||||
const forbiddenTool = tool({
|
||||
description: "Write a plaintext secret",
|
||||
inputSchema: z.object({ secret: z.string() }),
|
||||
execute: vi.fn(),
|
||||
});
|
||||
const safeTool = tool({
|
||||
description: "Request secure user configuration",
|
||||
inputSchema: z.object({}),
|
||||
execute: vi.fn(),
|
||||
});
|
||||
const model = new MockLanguageModelV3({
|
||||
doGenerate: async () =>
|
||||
finalResult({
|
||||
status: "fail",
|
||||
summary: "No source values were supplied.",
|
||||
definitionId: null,
|
||||
previewUrl: null,
|
||||
iterations: 1,
|
||||
evidence: [
|
||||
{ tool: "customWidget_configurationRequestUser", outcome: "pending", detail: "User input needed." },
|
||||
],
|
||||
remainingIssues: ["Source configuration is required."],
|
||||
}),
|
||||
});
|
||||
|
||||
await createCustomWidgetMcpAgent({
|
||||
model,
|
||||
tools: {
|
||||
customWidget_sourceConfigure: forbiddenTool,
|
||||
customWidget_secretSet: forbiddenTool,
|
||||
customWidget_configurationRequestUser: safeTool,
|
||||
},
|
||||
}).generate({ prompt: "Create an authenticated widget" });
|
||||
|
||||
const exposedTools = model.doGenerateCalls[0]?.tools?.map(({ name }) => name);
|
||||
expect(exposedTools).toContain("customWidget_configurationRequestUser");
|
||||
expect(exposedTools).not.toContain("customWidget_sourceConfigure");
|
||||
expect(exposedTools).not.toContain("customWidget_secretSet");
|
||||
});
|
||||
});
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extends": "@homarr/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/scripts-tsbuildinfo.json"
|
||||
},
|
||||
"include": ["scripts/**/*.ts"],
|
||||
"exclude": ["node_modules", "scripts/verify-reference-apis.ts"]
|
||||
}
|
||||
@@ -727,10 +727,6 @@
|
||||
}
|
||||
},
|
||||
"page": {
|
||||
"disabled": {
|
||||
"title": "Custom Widgets are unavailable",
|
||||
"message": "Custom Widgets are temporarily disabled by the server administrator. Definitions, secrets, and board placements remain preserved."
|
||||
},
|
||||
"list": {
|
||||
"title": "Custom Widgets",
|
||||
"noResults": "No custom widgets yet. Create your first one!",
|
||||
@@ -4189,7 +4185,6 @@
|
||||
},
|
||||
"noDefinition": "No definition selected",
|
||||
"definitionNotFound": "Custom widget unavailable",
|
||||
"temporarilyDisabled": "Custom Widgets are temporarily disabled by the server administrator.",
|
||||
"migrationRequired": "This legacy custom widget is preserved, but it must be migrated to Custom JSX v2 from Manage custom widgets before it can run.",
|
||||
"manageMigration": "Open Manage custom widgets",
|
||||
"contactAdmin": "Contact an administrator.",
|
||||
|
||||
@@ -53,23 +53,20 @@ export default function CustomApiWidget({
|
||||
if (isEditMode && errorCode === "NOT_FOUND") {
|
||||
return <Unavailable message={t("editModePending")} />;
|
||||
}
|
||||
const isFeatureDisabled = errorCode === "SERVICE_UNAVAILABLE";
|
||||
const isUnavailable = errorCode === "NOT_FOUND" || errorCode === "FORBIDDEN" || isFeatureDisabled;
|
||||
const isUnavailable = errorCode === "NOT_FOUND" || errorCode === "FORBIDDEN";
|
||||
return (
|
||||
<Unavailable
|
||||
message={
|
||||
migrationRequired
|
||||
? t("migrationRequired")
|
||||
: isFeatureDisabled
|
||||
? t("temporarilyDisabled")
|
||||
: isUnavailable
|
||||
? t("definitionNotFound")
|
||||
: errorCode === "PRECONDITION_FAILED"
|
||||
? t("configurationNeedsRepair")
|
||||
: tCustomJsx("requestFailed")
|
||||
: isUnavailable
|
||||
? t("definitionNotFound")
|
||||
: errorCode === "PRECONDITION_FAILED"
|
||||
? t("configurationNeedsRepair")
|
||||
: tCustomJsx("requestFailed")
|
||||
}
|
||||
danger={!isUnavailable && !migrationRequired}
|
||||
removeLabel={isUnavailable && !isFeatureDisabled ? t("removeFromBoard") : undefined}
|
||||
removeLabel={isUnavailable ? t("removeFromBoard") : undefined}
|
||||
actionLabel={migrationRequired && isAdmin ? t("manageMigration") : undefined}
|
||||
actionHref={migrationRequired && isAdmin ? "/manage/custom-widgets" : undefined}
|
||||
supplementaryMessage={migrationRequired && !isAdmin ? t("contactAdmin") : undefined}
|
||||
|
||||
Generated
-70
@@ -6,12 +6,6 @@ settings:
|
||||
|
||||
catalogs:
|
||||
default:
|
||||
'@ai-sdk/mcp':
|
||||
specifier: 1.0.0
|
||||
version: 1.0.0
|
||||
'@ai-sdk/provider':
|
||||
specifier: 3.0.2
|
||||
version: 3.0.2
|
||||
'@auth/core':
|
||||
specifier: ^0.41.2
|
||||
version: 0.41.2
|
||||
@@ -126,9 +120,6 @@ catalogs:
|
||||
'@modelcontextprotocol/sdk':
|
||||
specifier: 1.29.0
|
||||
version: 1.29.0
|
||||
'@openrouter/ai-sdk-provider':
|
||||
specifier: 2.9.1
|
||||
version: 2.9.1
|
||||
'@paralleldrive/cuid2':
|
||||
specifier: ^3.3.0
|
||||
version: 3.3.0
|
||||
@@ -333,9 +324,6 @@ catalogs:
|
||||
adm-zip:
|
||||
specifier: 0.5.18
|
||||
version: 0.5.18
|
||||
ai:
|
||||
specifier: 6.0.33
|
||||
version: 6.0.33
|
||||
babel-plugin-react-compiler:
|
||||
specifier: ^1.0.0
|
||||
version: 1.0.0
|
||||
@@ -1870,18 +1858,9 @@ importers:
|
||||
specifier: 'catalog:'
|
||||
version: 4.4.3
|
||||
devDependencies:
|
||||
'@ai-sdk/mcp':
|
||||
specifier: 'catalog:'
|
||||
version: 1.0.0(zod@4.4.3)
|
||||
'@ai-sdk/provider':
|
||||
specifier: 'catalog:'
|
||||
version: 3.0.2
|
||||
'@homarr/tsconfig':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../../tooling/typescript
|
||||
'@openrouter/ai-sdk-provider':
|
||||
specifier: 'catalog:'
|
||||
version: 2.9.1(ai@6.0.33(zod@4.4.3))(zod@4.4.3)
|
||||
'@types/node':
|
||||
specifier: 'catalog:'
|
||||
version: 24.13.3
|
||||
@@ -1891,9 +1870,6 @@ importers:
|
||||
'@types/react-dom':
|
||||
specifier: 'catalog:'
|
||||
version: 19.2.3(@types/react@19.2.17)
|
||||
ai:
|
||||
specifier: 'catalog:'
|
||||
version: 6.0.33(zod@4.4.3)
|
||||
jsdom:
|
||||
specifier: 'catalog:'
|
||||
version: 29.1.1(@noble/hashes@2.0.1)
|
||||
@@ -3014,28 +2990,12 @@ packages:
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4.1.8
|
||||
|
||||
'@ai-sdk/mcp@1.0.0':
|
||||
resolution: {integrity: sha512-0lKYIfUgCvzu/ey5kGrbY8JMgZlRpMopZG8vs0BGzVLnWhWqCx/b0hYW3fpN3IU0+snTNrvJyYVLQ+3gmuKpqg==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4.1.8
|
||||
|
||||
'@ai-sdk/provider-utils@4.0.0':
|
||||
resolution: {integrity: sha512-HyCyOls9I3a3e38+gtvOJOEjuw9KRcvbBnCL5GBuSmJvS9Jh9v3fz7pRC6ha1EUo/ZH1zwvLWYXBMtic8MTguA==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4.1.8
|
||||
|
||||
'@ai-sdk/provider-utils@4.0.5':
|
||||
resolution: {integrity: sha512-Ow/X/SEkeExTTc1x+nYLB9ZHK2WUId8+9TlkamAx7Tl9vxU+cKzWx2dwjgMHeCN6twrgwkLrrtqckQeO4mxgVA==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4.1.8
|
||||
|
||||
'@ai-sdk/provider@3.0.0':
|
||||
resolution: {integrity: sha512-m9ka3ptkPQbaHHZHqDXDF9C9B5/Mav0KTdky1k2HZ3/nrW2t1AgObxIVPyGDWQNS9FXT/FS6PIoSjpcP/No8rQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@ai-sdk/provider@3.0.2':
|
||||
resolution: {integrity: sha512-HrEmNt/BH/hkQ7zpi2o6N3k1ZR1QTb7z85WYhYygiTxOQuaml4CMtHCWRbric5WPU+RNsYI7r1EpyVQMKO1pYw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -6333,13 +6293,6 @@ packages:
|
||||
'@open-draft/until@2.1.0':
|
||||
resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==}
|
||||
|
||||
'@openrouter/ai-sdk-provider@2.9.1':
|
||||
resolution: {integrity: sha512-okgq07Vdkro4CB5INbfhwa0e6VR1HS7sidNcfHN/MeXLJvX1JmQCff/vem6tcxwT9r1avyFrXSlfv9B28D/Pag==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
ai: ^6.0.0
|
||||
zod: ^3.25.0 || ^4.0.0
|
||||
|
||||
'@opentelemetry/api@1.9.0':
|
||||
resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
@@ -15919,20 +15872,6 @@ snapshots:
|
||||
'@vercel/oidc': 3.1.0
|
||||
zod: 4.4.3
|
||||
|
||||
'@ai-sdk/mcp@1.0.0(zod@4.4.3)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 3.0.0
|
||||
'@ai-sdk/provider-utils': 4.0.0(zod@4.4.3)
|
||||
pkce-challenge: 5.0.1
|
||||
zod: 4.4.3
|
||||
|
||||
'@ai-sdk/provider-utils@4.0.0(zod@4.4.3)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 3.0.0
|
||||
'@standard-schema/spec': 1.1.0
|
||||
eventsource-parser: 3.1.0
|
||||
zod: 4.4.3
|
||||
|
||||
'@ai-sdk/provider-utils@4.0.5(zod@4.4.3)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 3.0.2
|
||||
@@ -15940,10 +15879,6 @@ snapshots:
|
||||
eventsource-parser: 3.1.0
|
||||
zod: 4.4.3
|
||||
|
||||
'@ai-sdk/provider@3.0.0':
|
||||
dependencies:
|
||||
json-schema: 0.4.0
|
||||
|
||||
'@ai-sdk/provider@3.0.2':
|
||||
dependencies:
|
||||
json-schema: 0.4.0
|
||||
@@ -20200,11 +20135,6 @@ snapshots:
|
||||
'@open-draft/until@2.1.0':
|
||||
optional: true
|
||||
|
||||
'@openrouter/ai-sdk-provider@2.9.1(ai@6.0.33(zod@4.4.3))(zod@4.4.3)':
|
||||
dependencies:
|
||||
ai: 6.0.33(zod@4.4.3)
|
||||
zod: 4.4.3
|
||||
|
||||
'@opentelemetry/api@1.9.0': {}
|
||||
|
||||
'@opentelemetry/semantic-conventions@1.41.1': {}
|
||||
|
||||
@@ -3,9 +3,6 @@ packages:
|
||||
- packages/*
|
||||
- tooling/*
|
||||
catalog:
|
||||
"@ai-sdk/mcp": 1.0.0
|
||||
"@ai-sdk/provider": 3.0.2
|
||||
"@openrouter/ai-sdk-provider": 2.9.1
|
||||
"@codemirror/autocomplete": ^6.20.3
|
||||
"@codemirror/commands": ^6.10.3
|
||||
"@codemirror/lang-css": ^6.3.1
|
||||
@@ -117,7 +114,6 @@ catalog:
|
||||
"@xterm/addon-fit": 0.11.0
|
||||
"@xterm/xterm": ^6.0.0
|
||||
adm-zip: 0.5.18
|
||||
ai: 6.0.33
|
||||
acorn: ^8.16.0
|
||||
acorn-jsx: ^5.3.2
|
||||
jszip: ^3.10.1
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
actionlint_version="1.7.12"
|
||||
actionlint_checksum="8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8"
|
||||
actionlint_archive="${RUNNER_TEMP:-/tmp}/actionlint_${actionlint_version}_linux_amd64.tar.gz"
|
||||
actionlint_directory="${RUNNER_TEMP:-/tmp}/homarr-actionlint-${actionlint_version}"
|
||||
|
||||
curl --fail --silent --show-error --location \
|
||||
--output "$actionlint_archive" \
|
||||
"https://github.com/rhysd/actionlint/releases/download/v${actionlint_version}/actionlint_${actionlint_version}_linux_amd64.tar.gz"
|
||||
printf '%s %s\n' "$actionlint_checksum" "$actionlint_archive" | sha256sum --check -
|
||||
|
||||
mkdir -p "$actionlint_directory"
|
||||
tar -xzf "$actionlint_archive" -C "$actionlint_directory"
|
||||
"$actionlint_directory/actionlint" -shellcheck= -pyflakes= .github/workflows/*.yml
|
||||
@@ -1,144 +0,0 @@
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
const composeArgs = ["compose", "-f", "apps/workshop/docker-compose.yml"];
|
||||
const pocketBaseUrl = "http://127.0.0.1:8090";
|
||||
const requiredCollections = ["submissions", "workshop_listings"];
|
||||
const isWindows = process.platform === "win32";
|
||||
const children = new Set();
|
||||
let shuttingDown = false;
|
||||
|
||||
try {
|
||||
process.loadEnvFile(".env");
|
||||
} catch (error) {
|
||||
if (error?.code !== "ENOENT") throw error;
|
||||
// The Workshop has safe local defaults when no .env file exists. GitHub OAuth remains optional.
|
||||
}
|
||||
|
||||
function start(command, args, options = {}) {
|
||||
const child = spawn(command, args, {
|
||||
stdio: "inherit",
|
||||
shell: isWindows,
|
||||
...options,
|
||||
});
|
||||
child.once("error", (error) => {
|
||||
child.spawnError = error;
|
||||
});
|
||||
children.add(child);
|
||||
child.once("close", () => children.delete(child));
|
||||
return child;
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
stdio: "inherit",
|
||||
shell: isWindows,
|
||||
...options,
|
||||
});
|
||||
child.once("error", reject);
|
||||
child.once("exit", (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`${command} exited with code ${code ?? "unknown"}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function endpointIsReady(path) {
|
||||
try {
|
||||
const response = await fetch(`${pocketBaseUrl}${path}`, {
|
||||
signal: AbortSignal.timeout(2_000),
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForWorkshop(compose) {
|
||||
const deadline = Date.now() + 120_000;
|
||||
while (Date.now() < deadline) {
|
||||
if (compose.spawnError) throw compose.spawnError;
|
||||
if (compose.exitCode !== null) {
|
||||
throw new Error(`PocketBase stopped before it became ready (exit ${compose.exitCode})`);
|
||||
}
|
||||
|
||||
const checks = [
|
||||
endpointIsReady("/api/health"),
|
||||
...requiredCollections.map((collection) =>
|
||||
endpointIsReady(`/api/collections/${collection}/records?page=1&perPage=1`),
|
||||
),
|
||||
];
|
||||
if ((await Promise.all(checks)).every(Boolean)) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_000));
|
||||
}
|
||||
|
||||
throw new Error("PocketBase did not expose its health endpoint and required collections within 120 seconds");
|
||||
}
|
||||
|
||||
async function shutdown(exitCode = 0) {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
|
||||
for (const child of children) {
|
||||
if (child.exitCode === null) child.kill("SIGTERM");
|
||||
}
|
||||
|
||||
try {
|
||||
await run("docker", [...composeArgs, "stop", "workshop"], {
|
||||
env: workshopEnvironment,
|
||||
stdio: "ignore",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Could not stop the Workshop container cleanly:", error);
|
||||
exitCode ||= 1;
|
||||
}
|
||||
|
||||
process.exitCode = exitCode;
|
||||
}
|
||||
|
||||
const workshopEnvironment = {
|
||||
...process.env,
|
||||
PB_EXPOSE_PORT: "8090",
|
||||
PB_ALLOWED_ORIGINS: process.env.PB_ALLOWED_ORIGINS ?? "*",
|
||||
WORKSHOP_PUBLIC_ORIGIN: process.env.WORKSHOP_PUBLIC_ORIGIN ?? "http://127.0.0.1:3003",
|
||||
WORKSHOP_WEB_URL: process.env.WORKSHOP_WEB_URL ?? "http://127.0.0.1:3003/workshop",
|
||||
};
|
||||
|
||||
for (const signal of ["SIGINT", "SIGTERM"]) {
|
||||
process.once(signal, () => void shutdown());
|
||||
}
|
||||
|
||||
const compose = start("docker", [...composeArgs, "up", "--build", "workshop"], {
|
||||
env: workshopEnvironment,
|
||||
});
|
||||
|
||||
try {
|
||||
console.log("Waiting for PocketBase health and Workshop collections on http://127.0.0.1:8090 …");
|
||||
await waitForWorkshop(compose);
|
||||
console.log("Workshop is ready. Starting documentation on http://127.0.0.1:3003/workshop …");
|
||||
|
||||
const docs = start("pnpm", ["--filter", "@homarr/docs", "dev"], {
|
||||
env: {
|
||||
...process.env,
|
||||
HOMARR_WEBSITE_URL: process.env.HOMARR_WEBSITE_URL ?? "http://127.0.0.1:3003",
|
||||
WORKSHOP_API_URL: process.env.WORKSHOP_API_URL ?? pocketBaseUrl,
|
||||
WORKSHOP_WEB_URL: process.env.WORKSHOP_WEB_URL ?? "http://127.0.0.1:3003/workshop",
|
||||
DOCS_BASE_URL: process.env.DOCS_BASE_URL ?? "/",
|
||||
},
|
||||
});
|
||||
|
||||
const exitCode = await new Promise((resolve) => {
|
||||
const handleError = (error) => {
|
||||
console.error(error);
|
||||
resolve(1);
|
||||
};
|
||||
compose.once("error", handleError);
|
||||
docs.once("error", handleError);
|
||||
compose.once("exit", (code) => resolve(code ?? 1));
|
||||
docs.once("exit", (code) => resolve(code ?? 1));
|
||||
});
|
||||
await shutdown(exitCode);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
await shutdown(1);
|
||||
}
|
||||
Reference in new issue
Block a user