diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 000000000..fb012bfb4 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,6 @@ +self-hosted-runner: + # Custom labels from third-party runner providers used in our workflows. + # Listed here so actionlint doesn't flag them as unknown. + labels: + - namespace-profile-default + - blacksmith-8vcpu-ubuntu-2204 diff --git a/.github/actions/release-binaries/action.yml b/.github/actions/release-binaries/action.yml new file mode 100644 index 000000000..d75335ccd --- /dev/null +++ b/.github/actions/release-binaries/action.yml @@ -0,0 +1,189 @@ +name: Release binaries +description: | + Build, sign, and publish release binaries for a Vikunja sub-project. + + Derives every per-project path, cache key, artifact name, and S3 target + from the `project` input. Callers only need to provide the project name, + the raw `git describe` value, and pass through the GPG/S3 secrets as + inputs (composite actions can't read the `secrets` context directly). + +inputs: + project: + description: 'Which project to build: "vikunja" or "veans".' + required: true + release-version: + description: | + Raw git describe value (e.g. v1.2.3 or v2.3.0-408-ge053d317). Always + passed through to the build so the binary embeds the precise commit. + Filenames and the S3 directory use "unstable" instead whenever + github.ref_type isn't "tag". + required: true + # Secrets — composite actions can't read the `secrets` context directly, so + # the caller threads them through as inputs. + gpg-passphrase: + required: true + gpg-sign-key: + required: true + s3-access-key-id: + required: true + s3-secret-access-key: + required: true + s3-endpoint: + required: true + s3-bucket: + required: true + s3-region: + required: true + +runs: + using: composite + steps: + - name: Set project paths + shell: bash + env: + PROJECT: ${{ inputs.project }} + RELEASE_VERSION_INPUT: ${{ inputs.release-version }} + VERSION_OR_UNSTABLE: ${{ github.ref_type == 'tag' && inputs.release-version || 'unstable' }} + run: | + set -euo pipefail + + case "$PROJECT" in + vikunja|veans) ;; + *) + echo "::error::Unknown project '$PROJECT'. Expected 'vikunja' or 'veans'." >&2 + exit 1 + ;; + esac + + case "$PROJECT" in + vikunja) + output_dir="." + dist_prefix="dist" + ;; + veans) + output_dir="veans" + dist_prefix="veans/dist" + ;; + esac + + { + echo "PROJECT=$PROJECT" + echo "RELEASE_VERSION=$RELEASE_VERSION_INPUT" + echo "VERSION_OR_UNSTABLE=$VERSION_OR_UNSTABLE" + echo "XGO_OUT_NAME=${PROJECT}-${VERSION_OR_UNSTABLE}" + echo "OUTPUT_DIR=$output_dir" + echo "DIST_PREFIX=$dist_prefix" + echo "S3_TARGET_PATH=/${PROJECT}/${VERSION_OR_UNSTABLE}" + echo "ARTIFACT_BINARIES_NAME=${PROJECT}_bins" + echo "ARTIFACT_ZIPS_NAME=${PROJECT}_bin_packages" + } >> "$GITHUB_ENV" + + - name: Download Mage binary + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: mage_bin + + - name: Make mage-static executable + shell: bash + run: chmod +x ./mage-static + + - name: Download frontend dist (vikunja only) + if: inputs.project == 'vikunja' + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: frontend_dist + path: frontend/dist + + - name: Generate config.yml.sample (vikunja only) + if: inputs.project == 'vikunja' + shell: bash + run: ./mage-static generate:config-yaml 1 + + - name: Install upx + shell: bash + run: | + set -euo pipefail + wget -q https://github.com/upx/upx/releases/download/v5.0.0/upx-5.0.0-amd64_linux.tar.xz + echo 'b32abf118d721358a50f1aa60eacdbf3298df379c431c3a86f139173ab8289a1 upx-5.0.0-amd64_linux.tar.xz' > upx-5.0.0-amd64_linux.tar.xz.sha256 + sha256sum -c upx-5.0.0-amd64_linux.tar.xz.sha256 + tar xf upx-5.0.0-amd64_linux.tar.xz + sudo mv upx-5.0.0-amd64_linux/upx /usr/local/bin + + - name: Setup xgo cache + uses: useblacksmith/cache@71c7c918062ba3861252d84b07fe5ab2a6b467a6 # v5 + with: + path: /home/runner/.xgo-cache + key: xgo-${{ inputs.project }}-${{ hashFiles('**/go.sum') }} + restore-keys: | + xgo-${{ inputs.project }}- + + - name: Install mage for the build module + shell: bash + run: go install github.com/magefile/mage@v1.17.2 + + - name: Build release artifacts + shell: bash + env: + RELEASE_VERSION: ${{ env.RELEASE_VERSION }} + XGO_OUT_NAME: ${{ env.XGO_OUT_NAME }} + PROJECT: ${{ env.PROJECT }} + run: | + set -euo pipefail + export PATH="$PATH:$(go env GOPATH)/bin" + cd build && mage release:build "$PROJECT" + + - name: GPG setup + uses: kolaente/action-gpg@main + with: + gpg-passphrase: ${{ inputs.gpg-passphrase }} + gpg-sign-key: ${{ inputs.gpg-sign-key }} + + - name: Sign zips + shell: bash + env: + DIST_PREFIX: ${{ env.DIST_PREFIX }} + RELEASE_GPG_PASSPHRASE: ${{ inputs.gpg-passphrase }} + run: | + set -euo pipefail + zip_dir="${DIST_PREFIX}/zip" + echo "=== GPG agent status ===" + gpg-connect-agent 'keyinfo --list' /bye || true + echo "=== GPG secret keys ===" + gpg -K --with-keygrip + echo "=== GPG public keys ===" + gpg --list-keys + echo "=== Signing files in $zip_dir ===" + ls -hal "$zip_dir"/* + for file in "$zip_dir"/*; do + gpg -v \ + --default-key 7D061A4AA61436B40713D42EFF054DACD908493A \ + -b --batch --yes \ + --passphrase "$RELEASE_GPG_PASSPHRASE" \ + --pinentry-mode loopback \ + --sign "$file" + done + + - name: Upload zips to S3 + uses: kolaente/s3-action@main + with: + s3-access-key-id: ${{ inputs.s3-access-key-id }} + s3-secret-access-key: ${{ inputs.s3-secret-access-key }} + s3-endpoint: ${{ inputs.s3-endpoint }} + s3-bucket: ${{ inputs.s3-bucket }} + s3-region: ${{ inputs.s3-region }} + target-path: ${{ env.S3_TARGET_PATH }} + files: ${{ env.DIST_PREFIX }}/zip/* + strip-path-prefix: ${{ env.DIST_PREFIX }}/zip/ + + - name: Store binaries + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: ${{ env.ARTIFACT_BINARIES_NAME }} + path: ./${{ env.DIST_PREFIX }}/binaries/* + + - name: Store binary packages + if: github.ref_type == 'tag' + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: ${{ env.ARTIFACT_ZIPS_NAME }} + path: ./${{ env.DIST_PREFIX }}/zip/* diff --git a/.github/actions/release-os-package/action.yml b/.github/actions/release-os-package/action.yml new file mode 100644 index 000000000..8203babcc --- /dev/null +++ b/.github/actions/release-os-package/action.yml @@ -0,0 +1,204 @@ +name: Release OS package +description: > + Build a single deb/rpm/apk/archlinux package for the given project + arch + via nfpm, optionally GPG-sign it (archlinux is signed inline; rpm is signed + by nfpm itself), upload it to S3, and store it as a workflow artifact. + + Most paths and names are derived from `project`; the matrix only needs to + supply the per-arch and per-format inputs. + +inputs: + project: + description: 'Project name (vikunja | veans). Drives all derived paths.' + required: true + release-version: + description: | + RELEASE_VERSION env value — the same version that ended up in the + binaries artifact. Always embedded in the package metadata via + nfpm; filenames and the S3 directory use "unstable" instead + whenever github.ref_type isn't "tag". + required: true + packager: + description: 'nfpm packager: rpm | deb | apk | archlinux.' + required: true + nfpm-arch: + description: 'nfpm arch field (amd64 | arm64 | arm7).' + required: true + pkg-arch: + description: 'Package-format arch used in the output filename (x86_64 | aarch64 | armv7).' + required: true + go-name: + description: 'Go-style arch token used in the binary filename (linux-amd64 | linux-arm64 | linux-arm-7).' + required: true + # Secrets — composite actions can't read `${{ secrets.* }}` directly, so the + # caller threads them through as inputs. + gpg-passphrase: + required: true + gpg-sign-key: + required: true + s3-access-key-id: + required: true + s3-secret-access-key: + required: true + s3-endpoint: + required: true + s3-bucket: + required: true + s3-region: + required: true + +runs: + using: composite + steps: + - name: Set project paths + shell: bash + env: + PROJECT: ${{ inputs.project }} + RELEASE_VERSION: ${{ inputs.release-version }} + VERSION_OR_UNSTABLE: ${{ github.ref_type == 'tag' && inputs.release-version || 'unstable' }} + PACKAGER: ${{ inputs.packager }} + PKG_ARCH: ${{ inputs.pkg-arch }} + GO_NAME: ${{ inputs.go-name }} + run: | + case "$PROJECT" in + vikunja) + echo "BINARIES_DOWNLOAD_PATH=." >> "$GITHUB_ENV" + echo "STAGED_BINARY_PATH=./vikunja" >> "$GITHUB_ENV" + echo "NFPM_BIN_PATH=" >> "$GITHUB_ENV" + echo "NFPM_CONFIG_PATH=./nfpm.yaml" >> "$GITHUB_ENV" + # No leading "./" — the s3-action's strip-path-prefix must + # match the glob output exactly, and the glob doesn't emit it. + echo "PACKAGE_OUTPUT_DIR=dist/os-packages" >> "$GITHUB_ENV" + ;; + veans) + echo "BINARIES_DOWNLOAD_PATH=./veans-binaries" >> "$GITHUB_ENV" + echo "STAGED_BINARY_PATH=./veans/veans-bin" >> "$GITHUB_ENV" + echo "NFPM_BIN_PATH=./veans/veans-bin" >> "$GITHUB_ENV" + echo "NFPM_CONFIG_PATH=./veans/nfpm.yaml" >> "$GITHUB_ENV" + echo "PACKAGE_OUTPUT_DIR=veans/dist/os-packages" >> "$GITHUB_ENV" + ;; + *) + echo "::error::unknown project '$PROJECT' (expected vikunja|veans)" + exit 1 + ;; + esac + + echo "VERSION_OR_UNSTABLE=$VERSION_OR_UNSTABLE" >> "$GITHUB_ENV" + echo "BINARIES_ARTIFACT_NAME=${PROJECT}_bins" >> "$GITHUB_ENV" + echo "BINARY_GLOB=${PROJECT}-*-${GO_NAME}" >> "$GITHUB_ENV" + echo "PACKAGE_FILENAME=${PROJECT}-${VERSION_OR_UNSTABLE}-${PKG_ARCH}.${PACKAGER}" >> "$GITHUB_ENV" + echo "ARTIFACT_NAME=${PROJECT}_os_package_${PACKAGER}_${PKG_ARCH}" >> "$GITHUB_ENV" + echo "S3_TARGET_PATH=/${PROJECT}/${VERSION_OR_UNSTABLE}" >> "$GITHUB_ENV" + + - name: Download project binaries + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: ${{ env.BINARIES_ARTIFACT_NAME }} + path: ${{ env.BINARIES_DOWNLOAD_PATH }} + + - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6 + with: + go-version: stable + + - name: Install mage + shell: bash + run: go install github.com/magefile/mage@v1.17.2 + + - name: Generate config.yml.sample (vikunja only) + # vikunja's nfpm.yaml ships ./config.yml.sample as /etc/vikunja/config.yml. + # release-binaries generates it for the zip bundles, but this job runs on a + # fresh runner, so we regenerate it here before nfpm packs it. + if: inputs.project == 'vikunja' + shell: bash + run: | + export PATH=$PATH:$GOPATH/bin + mage generate:config-yaml 1 + + - name: Write GPG key for nfpm + if: inputs.packager == 'rpm' + shell: bash + env: + RELEASE_GPG_SIGN_KEY: ${{ inputs.gpg-sign-key }} + run: printf '%s' "$RELEASE_GPG_SIGN_KEY" > /tmp/nfpm-signing-key.gpg + + - name: GPG setup for archlinux signing + if: inputs.packager == 'archlinux' + uses: kolaente/action-gpg@main + with: + gpg-passphrase: ${{ inputs.gpg-passphrase }} + gpg-sign-key: ${{ inputs.gpg-sign-key }} + + - name: Prepare nfpm config + shell: bash + working-directory: build + env: + RELEASE_VERSION: ${{ inputs.release-version }} + NFPM_ARCH: ${{ inputs.nfpm-arch }} + NFPM_BIN_PATH: ${{ env.NFPM_BIN_PATH }} + PROJECT: ${{ inputs.project }} + run: | + export PATH=$PATH:$GOPATH/bin + mage release:prepare-nfpm-config "$PROJECT" "$NFPM_ARCH" + + - name: Stage binary + shell: bash + run: | + # Resolve the single matching binary and mv it into place. + matched=() + for f in $BINARIES_DOWNLOAD_PATH/$BINARY_GLOB; do + [ -e "$f" ] || continue + matched+=("$f") + done + if [ ${#matched[@]} -ne 1 ]; then + echo "::error::expected exactly 1 binary matching '$BINARIES_DOWNLOAD_PATH/$BINARY_GLOB', found ${#matched[@]}" + ls -la "$BINARIES_DOWNLOAD_PATH" || true + exit 1 + fi + mkdir -p "$(dirname "$STAGED_BINARY_PATH")" + mv "${matched[0]}" "$STAGED_BINARY_PATH" + chmod +x "$STAGED_BINARY_PATH" + + - name: Ensure package output dir exists + shell: bash + run: mkdir -p "$PACKAGE_OUTPUT_DIR" + + - name: Create package + uses: kolaente/action-gh-nfpm@master + with: + packager: ${{ inputs.packager }} + target: ${{ env.PACKAGE_OUTPUT_DIR }}/${{ env.PACKAGE_FILENAME }} + config: ${{ env.NFPM_CONFIG_PATH }} + env: + NFPM_GPG_KEY_FILE: ${{ inputs.packager == 'rpm' && '/tmp/nfpm-signing-key.gpg' || '' }} + NFPM_PASSPHRASE: ${{ inputs.packager == 'rpm' && inputs.gpg-passphrase || '' }} + + - name: Sign archlinux package + if: inputs.packager == 'archlinux' + shell: bash + env: + GPG_PASSPHRASE: ${{ inputs.gpg-passphrase }} + run: | + gpg --default-key 7D061A4AA61436B40713D42EFF054DACD908493A \ + --batch --yes \ + --passphrase "$GPG_PASSPHRASE" \ + --pinentry-mode loopback \ + --detach-sign \ + "$PACKAGE_OUTPUT_DIR/$PACKAGE_FILENAME" + + - name: Upload to S3 + uses: kolaente/s3-action@main + with: + s3-access-key-id: ${{ inputs.s3-access-key-id }} + s3-secret-access-key: ${{ inputs.s3-secret-access-key }} + s3-endpoint: ${{ inputs.s3-endpoint }} + s3-bucket: ${{ inputs.s3-bucket }} + s3-region: ${{ inputs.s3-region }} + target-path: ${{ env.S3_TARGET_PATH }} + files: ${{ env.PACKAGE_OUTPUT_DIR }}/* + strip-path-prefix: ${{ env.PACKAGE_OUTPUT_DIR }}/ + + - name: Store OS package + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: ${{ env.ARTIFACT_NAME }} + path: ${{ env.PACKAGE_OUTPUT_DIR }}/* diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d86576881..910be41fd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,40 @@ on: workflow_call: jobs: + build-mage: + runs-on: ubuntu-latest + name: prepare-build-mage + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 + - name: Set up Go + uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6 + with: + go-version: stable + - name: Cache build mage + id: cache-build-mage + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5 + with: + key: ${{ runner.os }}-build-mage-build-${{ hashFiles('build/magefile.go') }} + path: | + ./build/build-mage-static + # Statically compile build/magefile.go so publish-repos can run repo + # metadata targets inside ubuntu/fedora/archlinux containers without + # needing a Go toolchain available there. + - name: Install mage + if: ${{ steps.cache-build-mage.outputs.cache-hit != 'true' }} + run: go install github.com/magefile/mage@v1.17.2 + - name: Compile build mage + if: ${{ steps.cache-build-mage.outputs.cache-hit != 'true' }} + working-directory: build + run: | + export PATH=$PATH:$GOPATH/bin + mage -compile ./build-mage-static + - name: Store build mage binary + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: build_mage_bin + path: ./build/build-mage-static + docker: runs-on: namespace-profile-default steps: @@ -63,83 +97,36 @@ jobs: - name: Git describe id: ghd uses: proudust/gh-describe@v2 - - uses: useblacksmith/setup-go@647ac649bd5b480f2a262e3e3e5f4d150ed452ad # v6 - with: - go-version: stable - - name: Download Mage Binary - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 - with: - name: mage_bin - - name: get frontend - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 - with: - name: frontend_dist - path: frontend/dist - - run: chmod +x ./mage-static - - name: install upx - run: | - wget https://github.com/upx/upx/releases/download/v5.0.0/upx-5.0.0-amd64_linux.tar.xz - echo 'b32abf118d721358a50f1aa60eacdbf3298df379c431c3a86f139173ab8289a1 upx-5.0.0-amd64_linux.tar.xz' > upx-5.0.0-amd64_linux.tar.xz.sha256 - sha256sum -c upx-5.0.0-amd64_linux.tar.xz.sha256 - tar xf upx-5.0.0-amd64_linux.tar.xz - mv upx-5.0.0-amd64_linux/upx /usr/local/bin - - name: setup xgo cache - uses: useblacksmith/cache@71c7c918062ba3861252d84b07fe5ab2a6b467a6 # v5 - with: - path: /home/runner/.xgo-cache - key: ${{ hashFiles('**/go.sum') }} - restore-keys: | - ${{ runner.os }}-go- - - name: build and release - env: - RELEASE_VERSION: ${{ steps.ghd.outputs.describe }} - XGO_OUT_NAME: vikunja-${{ github.ref_type == 'tag' && steps.ghd.outputs.describe || 'unstable' }} - run: | - export PATH=$PATH:$GOPATH/bin - ./mage-static release - - name: GPG setup - uses: kolaente/action-gpg@main - with: - gpg-passphrase: "${{ secrets.RELEASE_GPG_PASSPHRASE }}" - gpg-sign-key: "${{ secrets.RELEASE_GPG_SIGN_KEY }}" - - name: sign - run: | - echo "=== GPG agent status ===" - gpg-connect-agent 'keyinfo --list' /bye || true - echo "=== GPG secret keys ===" - gpg -K --with-keygrip - echo "=== GPG public keys ===" - gpg --list-keys - echo "=== GNUPG directory contents ===" - ls -la ~/.gnupg/ - ls -la ~/.gnupg/private-keys-v1.d/ || true - echo "=== Signing files ===" - ls -hal dist/zip/* - for file in dist/zip/*; do - gpg -v --default-key 7D061A4AA61436B40713D42EFF054DACD908493A -b --batch --yes --passphrase "${{ secrets.RELEASE_GPG_PASSPHRASE }}" --pinentry-mode loopback --sign "$file" - done - - name: Upload - uses: kolaente/s3-action@main + - uses: ./.github/actions/release-binaries with: + project: vikunja + release-version: ${{ steps.ghd.outputs.describe }} + gpg-passphrase: ${{ secrets.RELEASE_GPG_PASSPHRASE }} + gpg-sign-key: ${{ secrets.RELEASE_GPG_SIGN_KEY }} s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }} s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }} s3-endpoint: ${{ secrets.S3_ENDPOINT }} s3-bucket: ${{ secrets.S3_BUCKET }} s3-region: ${{ secrets.S3_REGION }} - target-path: /vikunja/${{ github.ref_type == 'tag' && steps.ghd.outputs.describe || 'unstable' }} - files: "dist/zip/*" - strip-path-prefix: dist/zip/ - - name: Store Binaries - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + + veans-binaries: + runs-on: blacksmith-8vcpu-ubuntu-2204 + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 + - name: Git describe + id: ghd + uses: proudust/gh-describe@v2 + - uses: ./.github/actions/release-binaries with: - name: vikunja_bins - path: ./dist/binaries/* - - name: Store Binary Packages - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 - if: ${{ github.ref_type == 'tag' }} - with: - name: vikunja_bin_packages - path: ./dist/zip/* + project: veans + release-version: ${{ steps.ghd.outputs.describe }} + gpg-passphrase: ${{ secrets.RELEASE_GPG_PASSPHRASE }} + gpg-sign-key: ${{ secrets.RELEASE_GPG_SIGN_KEY }} + s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }} + s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }} + s3-endpoint: ${{ secrets.S3_ENDPOINT }} + s3-bucket: ${{ secrets.S3_BUCKET }} + s3-region: ${{ secrets.S3_REGION }} os-package: runs-on: ubuntu-latest @@ -147,11 +134,7 @@ jobs: - binaries strategy: matrix: - package: - - rpm - - deb - - apk - - archlinux + package: [rpm, deb, apk, archlinux] arch: - go_name: linux-amd64 nfpm: amd64 @@ -165,76 +148,70 @@ jobs: steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 - - name: Download Vikunja Binary - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 - with: - name: vikunja_bins - name: Git describe id: ghd uses: proudust/gh-describe@v2 - - name: Download Mage Binary - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 - with: - name: mage_bin - - name: Write GPG key for nfpm - if: matrix.package == 'rpm' - run: echo -n "${{ secrets.RELEASE_GPG_SIGN_KEY }}" > /tmp/nfpm-signing-key.gpg - - name: GPG setup for package signing - if: matrix.package == 'archlinux' - uses: kolaente/action-gpg@main - with: - gpg-passphrase: "${{ secrets.RELEASE_GPG_PASSPHRASE }}" - gpg-sign-key: "${{ secrets.RELEASE_GPG_SIGN_KEY }}" - - name: Prepare - env: - RELEASE_VERSION: ${{ steps.ghd.outputs.describe }} - NFPM_ARCH: ${{ matrix.arch.nfpm }} - run: | - chmod +x ./mage-static - ./mage-static release:prepare-nfpm-config - mkdir -p ./dist/os-packages - mv ./vikunja-*-${{ matrix.arch.go_name }} ./vikunja - chmod +x ./vikunja - - name: Create package - id: nfpm - uses: kolaente/action-gh-nfpm@master + - uses: ./.github/actions/release-os-package with: + project: vikunja + release-version: ${{ steps.ghd.outputs.describe }} packager: ${{ matrix.package }} - target: ./dist/os-packages/vikunja-${{ github.ref_type == 'tag' && steps.ghd.outputs.describe || 'unstable' }}-${{ matrix.arch.pkg }}.${{ matrix.package }} - config: ./nfpm.yaml - env: - NFPM_GPG_KEY_FILE: ${{ (matrix.package == 'rpm') && '/tmp/nfpm-signing-key.gpg' || '' }} - NFPM_PASSPHRASE: ${{ (matrix.package == 'rpm') && secrets.RELEASE_GPG_PASSPHRASE || '' }} - - name: Sign package - if: matrix.package == 'archlinux' - run: | - gpg --default-key 7D061A4AA61436B40713D42EFF054DACD908493A \ - --batch --yes \ - --passphrase "${{ secrets.RELEASE_GPG_PASSPHRASE }}" \ - --pinentry-mode loopback \ - --detach-sign \ - ./dist/os-packages/vikunja-${{ github.ref_type == 'tag' && steps.ghd.outputs.describe || 'unstable' }}-${{ matrix.arch.pkg }}.${{ matrix.package }} - - name: Upload - uses: kolaente/s3-action@main - with: + nfpm-arch: ${{ matrix.arch.nfpm }} + pkg-arch: ${{ matrix.arch.pkg }} + go-name: ${{ matrix.arch.go_name }} + gpg-passphrase: ${{ secrets.RELEASE_GPG_PASSPHRASE }} + gpg-sign-key: ${{ secrets.RELEASE_GPG_SIGN_KEY }} s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }} s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }} s3-endpoint: ${{ secrets.S3_ENDPOINT }} s3-bucket: ${{ secrets.S3_BUCKET }} s3-region: ${{ secrets.S3_REGION }} - target-path: /vikunja/${{ github.ref_type == 'tag' && steps.ghd.outputs.describe || 'unstable' }} - files: "dist/os-packages/*" - strip-path-prefix: dist/os-packages/ - - name: Store OS Packages - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + + veans-os-package: + runs-on: ubuntu-latest + needs: + - veans-binaries + strategy: + matrix: + package: [rpm, deb, apk, archlinux] + arch: + - go_name: linux-amd64 + nfpm: amd64 + pkg: x86_64 + - go_name: linux-arm64 + nfpm: arm64 + pkg: aarch64 + - go_name: linux-arm-7 + nfpm: arm7 + pkg: armv7 + + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 + - name: Git describe + id: ghd + uses: proudust/gh-describe@v2 + - uses: ./.github/actions/release-os-package with: - name: vikunja_os_package_${{ matrix.package }}_${{ matrix.arch.pkg }} - path: ./dist/os-packages/* + project: veans + release-version: ${{ steps.ghd.outputs.describe }} + packager: ${{ matrix.package }} + nfpm-arch: ${{ matrix.arch.nfpm }} + pkg-arch: ${{ matrix.arch.pkg }} + go-name: ${{ matrix.arch.go_name }} + gpg-passphrase: ${{ secrets.RELEASE_GPG_PASSPHRASE }} + gpg-sign-key: ${{ secrets.RELEASE_GPG_SIGN_KEY }} + s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }} + s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }} + s3-endpoint: ${{ secrets.S3_ENDPOINT }} + s3-bucket: ${{ secrets.S3_BUCKET }} + s3-region: ${{ secrets.S3_REGION }} publish-repos: runs-on: ubuntu-latest needs: + - build-mage - os-package + - veans-os-package - desktop strategy: fail-fast: false @@ -260,10 +237,14 @@ jobs: steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 - - name: Download Mage Binary + - name: Download build mage binary + # Statically compiled in test.yml's build-mage job so it runs inside + # ubuntu/fedora/archlinux containers without a Go toolchain. + if: matrix.format != 'apk' uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: - name: mage_bin + name: build_mage_bin + path: build - name: Download all server OS packages uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 @@ -272,6 +253,16 @@ jobs: merge-multiple: true path: dist/repo-work/incoming + - name: Download all veans OS packages + # Merged into the same incoming dir so reprepro / createrepo_c / + # repo-add / the apk loop pick them up alongside vikunja's packages + # — same suite, same arch fan-out, no extra source entry for users. + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + pattern: veans_os_package_* + merge-multiple: true + path: dist/repo-work/incoming + - name: Download desktop packages (Linux) uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: @@ -338,12 +329,13 @@ jobs: - name: Generate repo metadata if: matrix.format != 'apk' + working-directory: build env: RELEASE_GPG_KEY: 7D061A4AA61436B40713D42EFF054DACD908493A RELEASE_GPG_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }} run: | - chmod +x ./mage-static - ./mage-static ${{ matrix.mage_target }} + chmod +x ./build-mage-static + ./build-mage-static ${{ matrix.mage_target }} - name: Generate APK repo metadata if: matrix.format == 'apk' @@ -538,6 +530,8 @@ jobs: needs: - binaries - os-package + - veans-binaries + - veans-os-package - desktop - publish-repos if: ${{ github.ref_type == 'tag' }} @@ -555,6 +549,17 @@ jobs: pattern: vikunja_os_package_* merge-multiple: true + - name: Download Veans Binaries + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: veans_bin_packages + + - name: Download Veans OS Packages + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + pattern: veans_os_package_* + merge-multiple: true + - name: Download Desktop Package Linux uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: @@ -581,4 +586,9 @@ jobs: vikunja*.deb vikunja*.apk vikunja*.archlinux + veans*.zip + veans*.rpm + veans*.deb + veans*.apk + veans*.archlinux Vikunja Desktop* diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 557fbb57f..65e0bff12 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -78,6 +78,39 @@ jobs: with: version: v2.10.1 + veans-lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 + - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6 + with: + go-version: stable + - name: golangci-lint + uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9 + with: + version: v2.10.1 + working-directory: veans + + veans-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 + - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6 + with: + go-version: stable + - name: Install mage + # The cached mage-static artifact has the parent magefile compiled + # in — we need a generic mage binary to pick up veans/magefile.go. + run: go install github.com/magefile/mage@v1.17.2 + - name: Run unit tests + # `mage test` is the Aliases entry for Test.All which passes + # `-short` — the e2e package's TestMain skips under -short, + # mirroring the parent monorepo's pkg/webtests convention. The + # heavier test-veans-e2e job runs the full suite against the + # api-build artifact. + working-directory: veans + run: mage test + check-translations: runs-on: ubuntu-latest needs: mage @@ -404,6 +437,76 @@ jobs: name: frontend_dist path: ./frontend/dist + test-veans-e2e: + runs-on: ubuntu-latest + needs: + - api-build + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 + - name: Download Vikunja Binary + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: vikunja_bin + - name: Set up Go + uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6 + with: + go-version: stable + - name: Install mage + # The cached mage-static artifact has the parent magefile compiled + # in — we need a generic mage binary to pick up veans/magefile.go. + run: go install github.com/magefile/mage@v1.17.2 + - run: chmod +x ./vikunja + - name: Run veans e2e against ephemeral Vikunja + env: + VIKUNJA_SERVICE_INTERFACE: ":3456" + VIKUNJA_SERVICE_PUBLICURL: "http://127.0.0.1:3456/" + VIKUNJA_SERVICE_JWTSECRET: "veans-e2e-jwt-secret-do-not-use-in-production" + # Enables PATCH /api/v1/test/{table} — the e2e suite seeds its + # own admin via this endpoint (see veans/e2e/helpers.go), same + # mechanism the playwright suite uses. + VIKUNJA_SERVICE_TESTINGTOKEN: averyLongSecretToSe33dtheDB + VIKUNJA_DATABASE_TYPE: sqlite + VIKUNJA_DATABASE_PATH: memory + VIKUNJA_LOG_LEVEL: WARNING + VIKUNJA_MAILER_ENABLED: "false" + VIKUNJA_REDIS_ENABLED: "false" + VIKUNJA_RATELIMIT_NOAUTHLIMIT: "1000" + VEANS_E2E_API_URL: http://127.0.0.1:3456 + # Same value as VIKUNJA_SERVICE_TESTINGTOKEN above — pass-through + # so the test harness can authenticate against /api/v1/test/. + VEANS_E2E_TESTING_TOKEN: averyLongSecretToSe33dtheDB + run: | + set -e + # Boot the prebuilt API and tests in one shell — backgrounded + # processes don't survive step boundaries on GH runners. + nohup ./vikunja web > /tmp/vikunja.log 2>&1 & + API_PID=$! + trap "kill $API_PID 2>/dev/null || true" EXIT + for i in $(seq 1 60); do + if curl -sf http://127.0.0.1:3456/api/v1/info >/dev/null 2>&1; then + echo "API ready after ${i}s" + break + fi + sleep 1 + done + if ! curl -sf http://127.0.0.1:3456/api/v1/info >/dev/null; then + echo "::error::API failed to start; log:" + cat /tmp/vikunja.log + exit 1 + fi + # `mage test:e2e` builds the binary once and exports VEANS_BINARY + # so each subtest reuses it (plain `mage test` would rebuild per + # test via buildOrLocate()). The suite seeds its own admin + # internally — no curl seeding here. + (cd veans && mage test:e2e) + - name: Upload API log on failure + if: failure() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: veans-e2e-vikunja-log + path: /tmp/vikunja.log + retention-days: 7 + test-frontend-e2e-playwright: runs-on: ubuntu-latest needs: diff --git a/Dockerfile b/Dockerfile index 560303236..8075aeee2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,7 +28,7 @@ ENV RELEASE_VERSION=$RELEASE_VERSION RUN export PATH=$PATH:$GOPATH/bin && \ mage build:clean && \ - mage release:xgo "${TARGETOS}/${TARGETARCH}/${TARGETVARIANT}" + (cd build && mage release:xgo vikunja "${TARGETOS}/${TARGETARCH}/${TARGETVARIANT}") RUN mkdir -p /tmp && chmod 1777 /tmp diff --git a/build/go.mod b/build/go.mod new file mode 100644 index 000000000..17999956b --- /dev/null +++ b/build/go.mod @@ -0,0 +1,5 @@ +module code.vikunja.io/build + +go 1.25.0 + +require github.com/magefile/mage v1.17.2 diff --git a/build/go.sum b/build/go.sum new file mode 100644 index 000000000..a4324314b --- /dev/null +++ b/build/go.sum @@ -0,0 +1,2 @@ +github.com/magefile/mage v1.17.2 h1:fyXVu1eadI8Ap1HCCNgEhJ5McIWiYhLR8uol64ZZc40= +github.com/magefile/mage v1.17.2/go.mod h1:Yj51kqllmsgFpvvSzgrZPK9WtluG3kUhFaBUVLo4feA= diff --git a/build/magefile.go b/build/magefile.go new file mode 100644 index 000000000..d76990d32 --- /dev/null +++ b/build/magefile.go @@ -0,0 +1,757 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//go:build mage + +// Centralized release pipeline for every Go binary in this monorepo. +// +// Both vikunja and veans cross-compile through the same code: xgo for the full +// OS/arch matrix, upx where the binary supports it, sha256 alongside each +// artifact, per-target zip bundle, and nfpm.yaml templating for deb/rpm/apk/ +// archlinux packaging. Repository-metadata targets (apt/rpm/pacman) consume +// the merged ../dist/repo-work/incoming/ tree the CI populates from both +// projects' packages. +// +// The module is intentionally separate from the project magefiles so the +// release tooling can evolve without touching them. The small filesystem +// helpers (copyFile, moveFile, sha256File) are duplicated rather than +// imported — this magefile depends on nothing but stdlib + mage. +package main + +import ( + "context" + "crypto/sha256" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + + "github.com/magefile/mage/mg" + "github.com/magefile/mage/sh" +) + +// ----------------------------------------------------------------------------- +// project definitions + +// project describes one releasable Go binary in this monorepo. Adding a new +// project means adding an entry to projectByName plus a constructor below. +type project struct { + // Name is the short identifier used on the CLI: `mage release:build `. + Name string + // Root is the project root, relative to this build/ directory. + Root string + // BuildPath is the Go package to build, relative to Root (e.g. "." or "./cmd/foo"). + BuildPath string + // Executable is the output binary name (sans -- suffix). + Executable string + // BuildTags are the base build tags applied to every cross-compile. + BuildTags string + // Ldflags returns the full -X flag string for the given version. + Ldflags func(version string) string + // NfpmConfigPath is the nfpm.yaml location, relative to Root. + NfpmConfigPath string + // NfpmBinPathDefault is the default substitution. Empty + // means use the Executable name as-is. + NfpmBinPathDefault string + // OsPackageExtras hook copies any extra files (LICENSE, sample config…) + // into each per-target bundle folder. Called once per binary. + OsPackageExtras func(folder string, p *project) error +} + +func projectByName(name string) (*project, error) { + switch name { + case "vikunja": + return vikunjaProject(), nil + case "veans": + return veansProject(), nil + default: + return nil, fmt.Errorf("unknown project %q (known: vikunja, veans)", name) + } +} + +func vikunjaProject() *project { + return &project{ + Name: "vikunja", + Root: "../", + BuildPath: ".", + Executable: "vikunja", + BuildTags: "osusergo netgo", + Ldflags: func(v string) string { + // Matches the parent magefile's pre-refactor ldflags. The + // main.Tags value is the literal build-tag string baked in + // for `vikunja info` to report. + return fmt.Sprintf(`-X "code.vikunja.io/api/pkg/version.Version=%s" -X "main.Tags=osusergo netgo"`, v) + }, + NfpmConfigPath: "nfpm.yaml", + NfpmBinPathDefault: "vikunja", + OsPackageExtras: func(folder string, p *project) error { + // config.yml.sample must be generated by the CI (or local dev) + // before this runs — we don't want to vendor the + // config-raw.json→YAML logic. The workflow does + // `mage generate:config-yaml 1` in the project root before + // invoking release:build. + if err := copyFile(filepath.Join(p.Root, "config.yml.sample"), filepath.Join(folder, "config.yml.sample")); err != nil { + return fmt.Errorf("copy config.yml.sample (run `mage generate:config-yaml 1` first): %w", err) + } + return copyFile(filepath.Join(p.Root, "LICENSE"), filepath.Join(folder, "LICENSE")) + }, + } +} + +func veansProject() *project { + return &project{ + Name: "veans", + Root: "../veans/", + BuildPath: "./cmd/veans", + Executable: "veans", + BuildTags: "osusergo netgo", + Ldflags: func(v string) string { + return fmt.Sprintf(`-X main.version=%s`, v) + }, + NfpmConfigPath: "nfpm.yaml", + NfpmBinPathDefault: "./veans", + OsPackageExtras: func(folder string, _ *project) error { + // veans intentionally doesn't carry its own LICENSE — the + // AGPLv3 at the repo root applies to both. + return copyFile("../LICENSE", filepath.Join(folder, "LICENSE")) + }, + } +} + +// ----------------------------------------------------------------------------- +// version resolution + +func releaseVersion(ctx context.Context) (string, error) { + if v := os.Getenv("RELEASE_VERSION"); v != "" { + return v, nil + } + out, err := exec.CommandContext(ctx, "git", "describe", "--tags", "--always", "--abbrev=10").Output() + if err != nil { + return "", fmt.Errorf("git describe: %w", err) + } + return strings.Replace(strings.TrimSpace(string(out)), "-g", "-", 1), nil +} + +func versionTagOrUnstable(v string) string { + switch v { + case "", "main": + return "unstable" + default: + return v + } +} + +// ----------------------------------------------------------------------------- +// Release namespace + +type Release mg.Namespace + +// Build runs the full release pipeline for the named project: dirs → xgo +// (windows/linux/darwin in parallel) → upx → copy → sha256 → per-target +// bundle dir → zip. +func (Release) Build(ctx context.Context, name string) error { + p, err := projectByName(name) + if err != nil { + return err + } + version, err := releaseVersion(ctx) + if err != nil { + return err + } + if err := releaseDirs(p); err != nil { + return err + } + if err := prepareXgo(ctx); err != nil { + return err + } + if err := xgoAllOS(ctx, p, version); err != nil { + return err + } + if err := compressBinaries(p); err != nil { + return err + } + if err := copyBinaries(p); err != nil { + return err + } + if err := writeChecksums(p); err != nil { + return err + } + if err := bundleOsPackages(p); err != nil { + return err + } + return zipBundles(ctx, p) +} + +// Xgo cross-compiles a single os/arch[/variant] target for the named project. +// Variant follows the parent magefile convention: `linux/arm/7` → arm-7. +// +// Unlike Release.Build, this skips prepareXgo on purpose: the only caller +// that hits this path in CI is the Dockerfile, which runs inside the xgo +// image (xgo binary already present, docker daemon not available). Local +// users invoking `mage release:xgo` need to install xgo themselves. +func (Release) Xgo(ctx context.Context, name, target string) error { + p, err := projectByName(name) + if err != nil { + return err + } + version, err := releaseVersion(ctx) + if err != nil { + return err + } + parts := strings.Split(target, "/") + if len(parts) < 2 { + return fmt.Errorf("invalid target %q (expected os/arch[/variant])", target) + } + variant := "" + if len(parts) > 2 && parts[2] != "" { + variant = "-" + strings.ReplaceAll(parts[2], "v", "") + } + return runXgo(ctx, p, version, parts[0]+"/"+parts[1]+variant) +} + +// PrepareNFPMConfig templates the named project's nfpm.yaml in place for the +// given nfpm arch (amd64|arm64|arm7|386). Destructive — CI checks out a fresh +// copy per matrix shard so the trampling is fine. +func (Release) PrepareNFPMConfig(ctx context.Context, name, arch string) error { + p, err := projectByName(name) + if err != nil { + return err + } + version, err := releaseVersion(ctx) + if err != nil { + return err + } + cfgPath := filepath.Join(p.Root, p.NfpmConfigPath) + raw, err := os.ReadFile(cfgPath) + if err != nil { + return err + } + binLocation := os.Getenv("NFPM_BIN_PATH") + if binLocation == "" { + binLocation = p.NfpmBinPathDefault + if binLocation == "" { + binLocation = p.Executable + } + } + out := strings.ReplaceAll(string(raw), "", version) + out = strings.ReplaceAll(out, "", arch) + out = strings.ReplaceAll(out, "", binLocation) + return os.WriteFile(cfgPath, []byte(out), 0o600) +} + +// ----------------------------------------------------------------------------- +// Repo-metadata targets — project-agnostic; operate on the merged tree at +// ../dist/repo-work/incoming and ../dist/repo-output. + +// RepoApt generates an APT repository (reprepro) for every .deb in the +// incoming tree. REPO_SUITE (stable|unstable) selects the target suite; +// RELEASE_GPG_KEY + RELEASE_GPG_PASSPHRASE drive the Release file signing. +func (Release) RepoApt(ctx context.Context) error { + suite := repoSuite() + incomingDir := filepath.Join(repoRootDist, "repo-work", "incoming") + outputBase := filepath.Join(repoRootDist, "repo-output", "apt") + confDir := filepath.Join(outputBase, "conf") + if err := os.MkdirAll(confDir, 0o755); err != nil { + return fmt.Errorf("creating reprepro conf dir: %w", err) + } + distConf, err := os.ReadFile("reprepro-dist-conf") + if err != nil { + return fmt.Errorf("reading reprepro-dist-conf: %w", err) + } + if err := os.WriteFile(filepath.Join(confDir, "distributions"), distConf, 0o600); err != nil { + return fmt.Errorf("writing distributions config: %w", err) + } + + debs, err := filepath.Glob(filepath.Join(incomingDir, "*.deb")) + if err != nil { + return err + } + for _, deb := range debs { + abs, _ := filepath.Abs(deb) + if err := sh.RunV("reprepro", "-b", outputBase, "includedeb", suite, abs); err != nil { + return fmt.Errorf("reprepro includedeb %s: %w", filepath.Base(deb), err) + } + } + + gpgKey := os.Getenv("RELEASE_GPG_KEY") + gpgPassphrase := os.Getenv("RELEASE_GPG_PASSPHRASE") + releaseFile := filepath.Join(outputBase, "dists", suite, "Release") + if _, err := os.Stat(releaseFile); err == nil { + if err := sh.RunV("gpg", + "--default-key", gpgKey, + "--batch", "--yes", + "--passphrase", gpgPassphrase, + "--pinentry-mode", "loopback", + "--detach-sign", "--armor", + "-o", releaseFile+".gpg", + releaseFile, + ); err != nil { + return fmt.Errorf("signing Release (detached): %w", err) + } + if err := sh.RunV("gpg", + "--default-key", gpgKey, + "--batch", "--yes", + "--passphrase", gpgPassphrase, + "--pinentry-mode", "loopback", + "--clearsign", + "-o", filepath.Join(filepath.Dir(releaseFile), "InRelease"), + releaseFile, + ); err != nil { + return fmt.Errorf("signing Release (clearsign): %w", err) + } + } + fmt.Println("APT repo metadata generated in", outputBase) + return nil +} + +// RepoRpm generates an RPM repository (createrepo_c) per arch in +// ../dist/repo-work/incoming/. +func (Release) RepoRpm(ctx context.Context) error { + suite := repoSuite() + incomingDir := filepath.Join(repoRootDist, "repo-work", "incoming") + outputBase := filepath.Join(repoRootDist, "repo-output", "rpm", suite) + gpgKey := os.Getenv("RELEASE_GPG_KEY") + gpgPassphrase := os.Getenv("RELEASE_GPG_PASSPHRASE") + + for _, arch := range []string{"x86_64", "aarch64", "armv7"} { + repoDir := filepath.Join(outputBase, arch) + if err := os.MkdirAll(repoDir, 0o755); err != nil { + return err + } + rpms, _ := filepath.Glob(filepath.Join(incomingDir, "*-"+arch+".rpm")) + if len(rpms) == 0 { + continue + } + for _, rpm := range rpms { + abs, _ := filepath.Abs(rpm) + dst := filepath.Join(repoDir, filepath.Base(rpm)) + _ = os.Remove(dst) + if err := os.Symlink(abs, dst); err != nil { + return err + } + } + args := []string{repoDir} + if _, err := os.Stat(filepath.Join(repoDir, "repodata")); err == nil { + args = []string{"--update", repoDir} + } + if err := sh.RunV("createrepo_c", args...); err != nil { + return fmt.Errorf("createrepo_c for %s: %w", arch, err) + } + if err := sh.RunV("gpg", + "--default-key", gpgKey, + "--batch", "--yes", + "--passphrase", gpgPassphrase, + "--pinentry-mode", "loopback", + "--detach-sign", "--armor", + "-o", filepath.Join(repoDir, "repodata", "repomd.xml.asc"), + filepath.Join(repoDir, "repodata", "repomd.xml"), + ); err != nil { + return fmt.Errorf("signing repomd.xml for %s: %w", arch, err) + } + } + fmt.Println("RPM repo metadata generated in", outputBase) + return nil +} + +// RepoPacman generates a Pacman repository (repo-add) per arch. +func (Release) RepoPacman(ctx context.Context) error { + suite := repoSuite() + incomingDir := filepath.Join(repoRootDist, "repo-work", "incoming") + outputBase := filepath.Join(repoRootDist, "repo-output", "pacman", suite) + gpgKey := os.Getenv("RELEASE_GPG_KEY") + gpgPassphrase := os.Getenv("RELEASE_GPG_PASSPHRASE") + + for _, arch := range []string{"x86_64", "aarch64", "armv7"} { + repoDir := filepath.Join(outputBase, arch) + if err := os.MkdirAll(repoDir, 0o755); err != nil { + return err + } + pkgs, _ := filepath.Glob(filepath.Join(incomingDir, "*-"+arch+".archlinux")) + if len(pkgs) == 0 { + continue + } + for _, pkg := range pkgs { + abs, _ := filepath.Abs(pkg) + dst := filepath.Join(repoDir, filepath.Base(pkg)) + _ = os.Remove(dst) + if err := os.Symlink(abs, dst); err != nil { + return err + } + } + dbPath := filepath.Join(repoDir, "vikunja.db.tar.gz") + repoPkgs, _ := filepath.Glob(filepath.Join(repoDir, "*.archlinux")) + repoAddArgs := append([]string{dbPath}, repoPkgs...) + if err := sh.RunV("repo-add", repoAddArgs...); err != nil { + return fmt.Errorf("repo-add for %s: %w", arch, err) + } + for _, name := range []string{"vikunja.db", "vikunja.files"} { + link := filepath.Join(repoDir, name) + _ = os.Remove(link) + if err := os.Symlink(name+".tar.gz", link); err != nil { + return fmt.Errorf("creating symlink %s: %w", name, err) + } + } + if err := sh.RunV("gpg", + "--default-key", gpgKey, + "--batch", "--yes", + "--passphrase", gpgPassphrase, + "--pinentry-mode", "loopback", + "--detach-sign", + "-o", filepath.Join(repoDir, "vikunja.db.sig"), + dbPath, + ); err != nil { + return fmt.Errorf("signing db for %s: %w", arch, err) + } + } + fmt.Println("Pacman repo metadata generated in", outputBase) + return nil +} + +// ----------------------------------------------------------------------------- +// pipeline internals + +const ( + distSubdir = "dist" + subBin = "binaries" + subRelease = "release" + subZip = "zip" + + // repoRootDist is where the repo-publish targets read and write — it's + // the dist/ directory at the repo root, not under build/. The CI + // populates dist/repo-work/incoming with packages from every project. + repoRootDist = "../dist" +) + +func projectDist(p *project, sub string) string { + return filepath.Join(p.Root, distSubdir, sub) +} + +func releaseDirs(p *project) error { + for _, d := range []string{subBin, subRelease, subZip} { + if err := os.MkdirAll(projectDist(p, d), 0o755); err != nil { + return err + } + } + return nil +} + +func prepareXgo(_ context.Context) error { + if _, err := exec.LookPath("xgo"); err != nil { + fmt.Println("xgo not found, installing src.techknowlogick.com/xgo...") + if err := sh.RunV("go", "install", "src.techknowlogick.com/xgo@latest"); err != nil { + return fmt.Errorf("installing xgo: %w", err) + } + } + fmt.Println("Pulling latest xgo docker image...") + return sh.RunV("docker", "pull", "ghcr.io/techknowlogick/xgo:latest") +} + +func xgoOutName(p *project, version string) string { + if v := os.Getenv("XGO_OUT_NAME"); v != "" { + return v + } + return p.Executable + "-" + versionTagOrUnstable(version) +} + +func runXgo(ctx context.Context, p *project, version, targets string) error { + extraLdflags := `-linkmode external -extldflags "-static" ` + // xgo's darwin builds can't use the static external linker. + if strings.HasPrefix(targets, "darwin") { + extraLdflags = "" + } + // xgo resolves its last arg as a Go package path. Running it from build/ + // with `../` confuses the module resolution (it tries to find a package + // inside this build module). Invoke xgo from the project root so we can + // pass p.BuildPath ("." or "./cmd/veans") just like the original + // per-project magefiles did. + absRoot, err := filepath.Abs(p.Root) + if err != nil { + return fmt.Errorf("resolve project root: %w", err) + } + absDest, err := filepath.Abs(projectDist(p, subBin)) + if err != nil { + return fmt.Errorf("resolve dest dir: %w", err) + } + //nolint:gosec // mage helper; args are derived from the static project table above. + cmd := exec.CommandContext(ctx, "xgo", + "-dest", absDest, + "-tags", p.BuildTags, + "-ldflags", extraLdflags+p.Ldflags(version), + "-targets", targets, + "-out", xgoOutName(p, version), + p.BuildPath, + ) + cmd.Dir = absRoot + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func xgoAllOS(ctx context.Context, p *project, version string) error { + groups := []string{ + "windows/*", + strings.Join([]string{ + "linux/amd64", + "linux/arm-5", + "linux/arm-6", + "linux/arm-7", + "linux/arm64", + "linux/mips", + "linux/mipsle", + "linux/mips64", + "linux/mips64le", + "linux/riscv64", + }, ","), + "darwin-10.15/*", + } + var ( + wg sync.WaitGroup + mu sync.Mutex + firstErr error + ) + record := func(err error) { + if err == nil { + return + } + mu.Lock() + if firstErr == nil { + firstErr = err + } + mu.Unlock() + } + for _, targets := range groups { + wg.Add(1) + go func(t string) { + defer wg.Done() + record(runXgo(ctx, p, version, t)) + }(targets) + } + wg.Wait() + return firstErr +} + +// compressBinaries runs upx -9 over each binary that upx can handle. The skip +// list matches the parent magefile's behavior. +func compressBinaries(p *project) error { + var ( + wg sync.WaitGroup + mu sync.Mutex + firstErr error + ) + record := func(err error) { + if err == nil { + return + } + mu.Lock() + if firstErr == nil { + firstErr = err + } + mu.Unlock() + } + walkErr := filepath.Walk(projectDist(p, subBin), func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return err + } + name := info.Name() + if !strings.Contains(name, p.Executable) { + return nil + } + if strings.Contains(name, "mips") || + strings.Contains(name, "s390x") || + strings.Contains(name, "riscv64") || + strings.Contains(name, "darwin") || + (strings.Contains(name, "windows") && strings.Contains(name, "arm64")) { + return nil + } + wg.Add(1) + go func(pp string) { + defer wg.Done() + if err := sh.RunV("chmod", "+x", pp); err != nil { + record(err) + return + } + record(sh.RunV("upx", "-9", pp)) + }(path) + return nil + }) + if walkErr != nil { + return walkErr + } + wg.Wait() + return firstErr +} + +func copyBinaries(p *project) error { + return filepath.Walk(projectDist(p, subBin), func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return err + } + if !strings.Contains(info.Name(), p.Executable) { + return nil + } + return copyFile(path, filepath.Join(projectDist(p, subRelease), info.Name())) + }) +} + +func writeChecksums(p *project) error { + release := projectDist(p, subRelease) + return filepath.Walk(release, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return err + } + if strings.HasSuffix(info.Name(), ".sha256") { + return nil + } + sum, err := sha256File(path) + if err != nil { + return err + } + return os.WriteFile(path+".sha256", []byte(sum+" "+info.Name()+"\n"), 0o644) + }) +} + +func bundleOsPackages(p *project) error { + release := projectDist(p, subRelease) + bins := map[string]os.FileInfo{} + if err := filepath.Walk(release, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return err + } + if strings.HasSuffix(info.Name(), ".sha256") { + return nil + } + bins[path] = info + return nil + }); err != nil { + return err + } + for binPath, info := range bins { + folder := filepath.Join(release, info.Name()+"-full") + if err := os.MkdirAll(folder, 0o755); err != nil { + return err + } + if err := moveFile(binPath+".sha256", filepath.Join(folder, info.Name()+".sha256")); err != nil { + return err + } + if err := moveFile(binPath, filepath.Join(folder, info.Name())); err != nil { + return err + } + if p.OsPackageExtras != nil { + if err := p.OsPackageExtras(folder, p); err != nil { + return err + } + } + } + return nil +} + +func zipBundles(ctx context.Context, p *project) error { + zipDirAbs, err := filepath.Abs(projectDist(p, subZip)) + if err != nil { + return err + } + release := projectDist(p, subRelease) + return filepath.Walk(release, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if !info.IsDir() || filepath.Base(path) == subRelease { + return nil + } + fmt.Printf("Zipping %s...\n", info.Name()) + zipFile := filepath.Join(zipDirAbs, info.Name()+".zip") + //nolint:gosec // mage helper; args derive from the local filesystem walk above. + c := exec.CommandContext(ctx, "zip", "-r", zipFile, ".", "-i", "*") + c.Dir = path + c.Stdout, c.Stderr = os.Stdout, os.Stderr + return c.Run() + }) +} + +// repoSuite validates the REPO_SUITE env var; defaults to "stable". Limiting +// the values prevents path traversal via the suite name flowing into a +// filesystem path. +func repoSuite() string { + switch os.Getenv("REPO_SUITE") { + case "stable", "unstable": + return os.Getenv("REPO_SUITE") + default: + return "stable" + } +} + +// ----------------------------------------------------------------------------- +// helpers — duplicated from the project magefiles so this module depends on +// nothing but stdlib + mage. Don't import these from elsewhere; rewrite them +// here if they need to change. + +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.Create(dst) + if err != nil { + return err + } + defer out.Close() + if _, err := io.Copy(out, in); err != nil { + return err + } + si, err := os.Stat(src) + if err != nil { + return err + } + if err := os.Chmod(dst, si.Mode()); err != nil { + return err + } + return out.Close() +} + +func moveFile(src, dst string) error { + if err := copyFile(src, dst); err != nil { + return err + } + return os.Remove(src) +} + +func sha256File(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return fmt.Sprintf("%x", h.Sum(nil)), nil +} + +// Aliases for kebab-case spelling at the CLI. +var Aliases = map[string]any{ + "release": Release.Build, + "release:build": Release.Build, + "release:xgo": Release.Xgo, + "release:prepare-nfpm-config": Release.PrepareNFPMConfig, + "release:repo-apt": Release.RepoApt, + "release:repo-rpm": Release.RepoRpm, + "release:repo-pacman": Release.RepoPacman, +} diff --git a/desktop/package.json b/desktop/package.json index 1bc0909fa..e5025761d 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -61,7 +61,7 @@ } }, "devDependencies": { - "electron": "40.10.0", + "electron": "40.10.2", "electron-builder": "26.8.1", "unzipper": "0.12.3" }, @@ -76,7 +76,9 @@ "minimatch": "^10.2.3", "tar": "^7.5.11", "@tootallnate/once": "^3.0.1", - "picomatch": ">=4.0.4" + "picomatch": ">=4.0.4", + "tmp": ">=0.2.6", + "ip-address": ">=10.1.1" } } } diff --git a/desktop/pnpm-lock.yaml b/desktop/pnpm-lock.yaml index cd660881c..0676b6157 100644 --- a/desktop/pnpm-lock.yaml +++ b/desktop/pnpm-lock.yaml @@ -9,6 +9,8 @@ overrides: tar: ^7.5.11 '@tootallnate/once': ^3.0.1 picomatch: '>=4.0.4' + tmp: '>=0.2.6' + ip-address: '>=10.1.1' importers: @@ -19,8 +21,8 @@ importers: version: 5.2.1 devDependencies: electron: - specifier: 40.10.0 - version: 40.10.0 + specifier: 40.10.2 + version: 40.10.2 electron-builder: specifier: 26.8.1 version: 26.8.1(electron-builder-squirrel-windows@24.13.3) @@ -567,8 +569,8 @@ packages: electron-publish@26.8.1: resolution: {integrity: sha512-q+jrSTIh/Cv4eGZa7oVR+grEJo/FoLMYBAnSL5GCtqwUpr1T+VgKB/dn1pnzxIxqD8S/jP1yilT9VrwCqINR4w==} - electron@40.10.0: - resolution: {integrity: sha512-e7XVcAfyWoFQGS7ZhgxeNn0AijHaqgRCa6uA6TYOrvBWv8smI6JILvMR/8DYBIn07oqvxDLRC90tu/xa2cJCow==} + electron@40.10.2: + resolution: {integrity: sha512-Xj3Hy0Imbu4g0gDIW55w/jJYz94nMO2JRSGYA3LyAn5SwaERCelgZrA21vfH+Bi//SWAWQXddHsMwCqauyMT8g==} engines: {node: '>= 12.20.55'} hasBin: true @@ -840,8 +842,8 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - ip-address@10.1.0: - resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} engines: {node: '>= 12'} ipaddr.js@1.9.1: @@ -1210,8 +1212,8 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - qs@6.15.0: - resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} + qs@6.15.2: + resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} engines: {node: '>=0.6'} quick-lru@5.1.1: @@ -1318,6 +1320,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.1: + resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==} + engines: {node: '>=10'} + hasBin: true + send@1.2.0: resolution: {integrity: sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==} engines: {node: '>= 18'} @@ -1461,8 +1468,8 @@ packages: tmp-promise@3.0.3: resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==} - tmp@0.2.3: - resolution: {integrity: sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==} + tmp@0.2.6: + resolution: {integrity: sha512-5sJPdPjfI5Kx+qbrDesxkglRBxW//g7hCsqspEjwkewGvBMGIKMOTKzLt1hFVJzyadba3lDUN20O9qhvbQUSTA==} engines: {node: '>=14.14'} toidentifier@1.0.1: @@ -1757,7 +1764,7 @@ snapshots: '@npmcli/fs@4.0.0': dependencies: - semver: 7.7.4 + semver: 7.8.1 '@pkgjs/parseargs@0.11.0': optional: true @@ -1893,7 +1900,7 @@ snapshots: minimatch: 10.2.5 read-config-file: 6.3.2 sanitize-filename: 1.6.4 - semver: 7.8.0 + semver: 7.8.1 tar: 7.5.15 temp-file: 3.4.0 transitivePeerDependencies: @@ -2018,7 +2025,7 @@ snapshots: http-errors: 2.0.1 iconv-lite: 0.7.0 on-finished: 2.4.1 - qs: 6.15.0 + qs: 6.15.2 raw-body: 3.0.2 type-is: 2.0.1 transitivePeerDependencies: @@ -2386,7 +2393,7 @@ snapshots: transitivePeerDependencies: - supports-color - electron@40.10.0: + electron@40.10.2: dependencies: '@electron/get': 2.0.3 '@types/node': 24.10.9 @@ -2464,7 +2471,7 @@ snapshots: once: 1.4.0 parseurl: 1.3.3 proxy-addr: 2.0.7 - qs: 6.15.0 + qs: 6.15.2 range-parser: 1.2.1 router: 2.2.0 send: 1.2.0 @@ -2621,7 +2628,7 @@ snapshots: es6-error: 4.1.1 matcher: 3.0.0 roarr: 2.15.4 - semver: 7.7.4 + semver: 7.8.0 serialize-error: 7.0.1 optional: true @@ -2739,7 +2746,7 @@ snapshots: inherits@2.0.4: {} - ip-address@10.1.0: {} + ip-address@10.2.0: {} ipaddr.js@1.9.1: {} @@ -2943,14 +2950,14 @@ snapshots: node-abi@4.24.0: dependencies: - semver: 7.7.4 + semver: 7.8.1 node-addon-api@1.7.2: optional: true node-api-version@0.2.1: dependencies: - semver: 7.7.4 + semver: 7.8.1 node-gyp@11.5.0: dependencies: @@ -2960,7 +2967,7 @@ snapshots: make-fetch-happen: 14.0.3 nopt: 8.1.0 proc-log: 5.0.0 - semver: 7.7.4 + semver: 7.8.1 tar: 7.5.11 tinyglobby: 0.2.15 which: 5.0.0 @@ -3076,7 +3083,7 @@ snapshots: punycode@2.3.1: {} - qs@6.15.0: + qs@6.15.2: dependencies: side-channel: 1.1.0 @@ -3192,7 +3199,10 @@ snapshots: semver@7.7.4: {} - semver@7.8.0: {} + semver@7.8.0: + optional: true + + semver@7.8.1: {} send@1.2.0: dependencies: @@ -3287,7 +3297,7 @@ snapshots: socks@2.8.7: dependencies: - ip-address: 10.1.0 + ip-address: 10.2.0 smart-buffer: 4.2.0 source-map-support@0.5.21: @@ -3386,9 +3396,9 @@ snapshots: tmp-promise@3.0.3: dependencies: - tmp: 0.2.3 + tmp: 0.2.6 - tmp@0.2.3: {} + tmp@0.2.6: {} toidentifier@1.0.1: {} diff --git a/frontend/package.json b/frontend/package.json index 25d086508..6210f46cd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -117,8 +117,8 @@ "@types/node": "24.12.4", "@types/sortablejs": "1.15.9", "@types/ws": "8.18.1", - "@typescript-eslint/eslint-plugin": "8.59.4", - "@typescript-eslint/parser": "8.59.4", + "@typescript-eslint/eslint-plugin": "8.60.0", + "@typescript-eslint/parser": "8.60.0", "@vitejs/plugin-vue": "6.0.7", "@vue/eslint-config-typescript": "14.7.0", "@vue/test-utils": "2.4.10", @@ -135,14 +135,14 @@ "happy-dom": "20.9.0", "histoire": "1.0.0-beta.1", "otplib": "12.0.1", - "postcss": "8.5.14", + "postcss": "8.5.15", "postcss-easing-gradients": "3.0.1", "postcss-html": "1.8.1", "postcss-preset-env": "11.3.0", "rollup": "4.60.4", "rollup-plugin-visualizer": "6.0.11", - "sass-embedded": "1.99.0", - "stylelint": "17.11.1", + "sass-embedded": "1.100.0", + "stylelint": "17.12.0", "stylelint-config-property-sort-order-smacss": "10.0.0", "stylelint-config-recommended-vue": "1.6.1", "stylelint-config-standard-scss": "17.0.0", @@ -154,11 +154,11 @@ "vite-plugin-pwa": "1.3.0", "vite-plugin-vue-devtools": "8.1.2", "vite-svg-loader": "5.1.1", - "vitest": "4.1.6", - "vue-tsc": "3.3.0", + "vitest": "4.1.7", + "vue-tsc": "3.3.2", "wait-on": "9.0.10", "workbox-cli": "7.4.1", - "ws": "8.20.1" + "ws": "8.21.0" }, "pnpm": { "onlyBuiltDependencies": [ @@ -175,7 +175,8 @@ "serialize-javascript": "^7.0.5", "flatted": "^3.4.1", "ip-address": ">=10.1.1", - "postcss": ">=8.5.10" + "postcss": ">=8.5.10", + "tmp": ">=0.2.6" } } } diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index fcef694c7..2e75d6a5a 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -12,6 +12,7 @@ overrides: flatted: ^3.4.1 ip-address: '>=10.1.1' postcss: '>=8.5.10' + tmp: '>=0.2.6' importers: @@ -179,10 +180,10 @@ importers: version: 10.4.0 '@histoire/plugin-screenshot': specifier: 1.0.0-beta.1 - version: 1.0.0-beta.1(histoire@1.0.0-beta.1(@types/node@24.12.4)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3))(yaml@2.8.3))(typescript@5.9.3) + version: 1.0.0-beta.1(histoire@1.0.0-beta.1(@types/node@24.12.4)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(yaml@2.8.3))(typescript@5.9.3) '@histoire/plugin-vue': specifier: 1.0.0-beta.1 - version: 1.0.0-beta.1(histoire@1.0.0-beta.1(@types/node@24.12.4)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3))(yaml@2.8.3))(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3))(vue@3.5.27(typescript@5.9.3)) + version: 1.0.0-beta.1(histoire@1.0.0-beta.1(@types/node@24.12.4)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(yaml@2.8.3))(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(vue@3.5.27(typescript@5.9.3)) '@playwright/test': specifier: 1.58.2 version: 1.58.2 @@ -191,7 +192,7 @@ importers: version: 3.6.1 '@tailwindcss/vite': specifier: 4.3.0 - version: 4.3.0(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3)) + version: 4.3.0(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) '@tsconfig/node24': specifier: 24.0.4 version: 24.0.4 @@ -211,17 +212,17 @@ importers: specifier: 8.18.1 version: 8.18.1 '@typescript-eslint/eslint-plugin': - specifier: 8.59.4 - version: 8.59.4(@typescript-eslint/parser@8.59.4(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + specifier: 8.60.0 + version: 8.60.0(@typescript-eslint/parser@8.60.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/parser': - specifier: 8.59.4 - version: 8.59.4(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + specifier: 8.60.0 + version: 8.60.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) '@vitejs/plugin-vue': specifier: 6.0.7 - version: 6.0.7(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3))(vue@3.5.27(typescript@5.9.3)) + version: 6.0.7(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(vue@3.5.27(typescript@5.9.3)) '@vue/eslint-config-typescript': specifier: 14.7.0 - version: 14.7.0(eslint-plugin-vue@10.9.1(@typescript-eslint/parser@8.59.4(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + version: 14.7.0(eslint-plugin-vue@10.9.1(@typescript-eslint/parser@8.60.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) '@vue/test-utils': specifier: 2.4.10 version: 2.4.10(@vue/compiler-dom@3.5.27)(@vue/server-renderer@3.5.27(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3)) @@ -254,13 +255,13 @@ importers: version: 1.5.0(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-vue: specifier: 10.9.1 - version: 10.9.1(@typescript-eslint/parser@8.59.4(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))) + version: 10.9.1(@typescript-eslint/parser@8.60.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))) happy-dom: specifier: 20.9.0 version: 20.9.0 histoire: specifier: 1.0.0-beta.1 - version: 1.0.0-beta.1(@types/node@24.12.4)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3))(yaml@2.8.3) + version: 1.0.0-beta.1(@types/node@24.12.4)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(yaml@2.8.3) otplib: specifier: 12.0.1 version: 12.0.1 @@ -283,23 +284,23 @@ importers: specifier: 6.0.11 version: 6.0.11(rollup@4.60.4) sass-embedded: - specifier: 1.99.0 - version: 1.99.0 + specifier: 1.100.0 + version: 1.100.0 stylelint: - specifier: 17.11.1 - version: 17.11.1(typescript@5.9.3) + specifier: 17.12.0 + version: 17.12.0(typescript@5.9.3) stylelint-config-property-sort-order-smacss: specifier: 10.0.0 - version: 10.0.0(stylelint@17.11.1(typescript@5.9.3)) + version: 10.0.0(stylelint@17.12.0(typescript@5.9.3)) stylelint-config-recommended-vue: specifier: 1.6.1 - version: 1.6.1(postcss-html@1.8.1)(stylelint@17.11.1(typescript@5.9.3)) + version: 1.6.1(postcss-html@1.8.1)(stylelint@17.12.0(typescript@5.9.3)) stylelint-config-standard-scss: specifier: 17.0.0 - version: 17.0.0(postcss@8.5.14)(stylelint@17.11.1(typescript@5.9.3)) + version: 17.0.0(postcss@8.5.14)(stylelint@17.12.0(typescript@5.9.3)) stylelint-use-logical: specifier: 2.1.3 - version: 2.1.3(stylelint@17.11.1(typescript@5.9.3)) + version: 2.1.3(stylelint@17.12.0(typescript@5.9.3)) tailwindcss: specifier: 4.3.0 version: 4.3.0 @@ -311,22 +312,22 @@ importers: version: 3.0.0 vite: specifier: 7.3.3 - version: 7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3) + version: 7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) vite-plugin-pwa: specifier: 1.3.0 - version: 1.3.0(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3))(workbox-build@7.4.1)(workbox-window@7.4.1) + version: 1.3.0(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(workbox-build@7.4.1)(workbox-window@7.4.1) vite-plugin-vue-devtools: specifier: 8.1.2 - version: 8.1.2(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3))(vue@3.5.27(typescript@5.9.3)) + version: 8.1.2(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(vue@3.5.27(typescript@5.9.3)) vite-svg-loader: specifier: 5.1.1 version: 5.1.1(vue@3.5.27(typescript@5.9.3)) vitest: - specifier: 4.1.6 - version: 4.1.6(@types/node@24.12.4)(happy-dom@20.9.0)(jsdom@27.4.0)(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3)) + specifier: 4.1.7 + version: 4.1.7(@types/node@24.12.4)(happy-dom@20.9.0)(jsdom@27.4.0)(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) vue-tsc: - specifier: 3.3.0 - version: 3.3.0(typescript@5.9.3) + specifier: 3.3.2 + version: 3.3.2(typescript@5.9.3) wait-on: specifier: 9.0.10 version: 9.0.10 @@ -334,8 +335,8 @@ importers: specifier: 7.4.1 version: 7.4.1 ws: - specifier: 8.20.1 - version: 8.20.1 + specifier: 8.21.0 + version: 8.21.0 packages: @@ -939,11 +940,11 @@ packages: '@bufbuild/protobuf@2.5.2': resolution: {integrity: sha512-foZ7qr0IsUBjzWIq+SuBLfdQCpJ1j8cTuNNT4owngTHoN5KsJb8L9t65fzz7SCeSWzescoOil/0ldqiL041ABg==} - '@cacheable/memory@2.0.7': - resolution: {integrity: sha512-RbxnxAMf89Tp1dLhXMS7ceft/PGsDl1Ip7T20z5nZ+pwIAsQ1p2izPjVG69oCLv/jfQ7HDPHTWK0c9rcAWXN3A==} + '@cacheable/memory@2.0.9': + resolution: {integrity: sha512-HdMx6DoGywB30vacDbBsITbIX4pgFqj1zsrV58jZBUw3klzkNoXhj7qOqAgledhxG7YZI5rBSJg7Zp8/VG0DuA==} - '@cacheable/utils@2.3.4': - resolution: {integrity: sha512-knwKUJEYgIfwShABS1BX6JyJJTglAFcEU7EXqzTdiGCXur4voqkiJkdgZIQtWNFhynzDWERcTYv/sETMu3uJWA==} + '@cacheable/utils@2.4.1': + resolution: {integrity: sha512-eiFgzCbIneyMlLOmNG4g9xzF7Hv3Mga4LjxjcSC/ues6VYq2+gUbQI8JqNuw/ZM8tJIeIaBGpswAsqV2V7ApgA==} '@codemirror/commands@6.8.1': resolution: {integrity: sha512-KlGVYufHMQzxbdQONiLyGQDUW0itrLZwq3CcY7xpv9ZLRHqzkBSoteocBHtMCoY7/Ci4xhzSrToIeLg7FxHuaw==} @@ -988,13 +989,6 @@ packages: '@csstools/css-parser-algorithms': ^3.0.5 '@csstools/css-tokenizer': ^3.0.4 - '@csstools/css-calc@3.2.0': - resolution: {integrity: sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==} - engines: {node: '>=20.19.0'} - peerDependencies: - '@csstools/css-parser-algorithms': ^4.0.0 - '@csstools/css-tokenizer': ^4.0.0 - '@csstools/css-calc@3.2.1': resolution: {integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==} engines: {node: '>=20.19.0'} @@ -2054,11 +2048,11 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@keyv/bigmap@1.3.0': - resolution: {integrity: sha512-KT01GjzV6AQD5+IYrcpoYLkCu1Jod3nau1Z7EsEuViO3TZGRacSbO9MfHmbJ1WaOXFtWLxPVj169cn2WNKPkIg==} + '@keyv/bigmap@1.3.1': + resolution: {integrity: sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==} engines: {node: '>= 18'} peerDependencies: - keyv: ^5.5.4 + keyv: ^5.6.0 '@keyv/serialize@1.1.1': resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} @@ -2926,11 +2920,11 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/eslint-plugin@8.59.4': - resolution: {integrity: sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==} + '@typescript-eslint/eslint-plugin@8.60.0': + resolution: {integrity: sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.59.4 + '@typescript-eslint/parser': ^8.60.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' @@ -2941,8 +2935,8 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/parser@8.59.4': - resolution: {integrity: sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==} + '@typescript-eslint/parser@8.60.0': + resolution: {integrity: sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -2960,8 +2954,8 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.59.4': - resolution: {integrity: sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==} + '@typescript-eslint/project-service@8.60.0': + resolution: {integrity: sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' @@ -2974,8 +2968,8 @@ packages: resolution: {integrity: sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/scope-manager@8.59.4': - resolution: {integrity: sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==} + '@typescript-eslint/scope-manager@8.60.0': + resolution: {integrity: sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@typescript-eslint/tsconfig-utils@8.56.0': @@ -2990,14 +2984,14 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/tsconfig-utils@8.59.3': - resolution: {integrity: sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==} + '@typescript-eslint/tsconfig-utils@8.59.4': + resolution: {integrity: sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/tsconfig-utils@8.59.4': - resolution: {integrity: sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==} + '@typescript-eslint/tsconfig-utils@8.60.0': + resolution: {integrity: sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' @@ -3009,8 +3003,8 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/type-utils@8.59.4': - resolution: {integrity: sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==} + '@typescript-eslint/type-utils@8.60.0': + resolution: {integrity: sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -3024,14 +3018,14 @@ packages: resolution: {integrity: sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/types@8.59.3': - resolution: {integrity: sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/types@8.59.4': resolution: {integrity: sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/types@8.60.0': + resolution: {integrity: sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/typescript-estree@8.56.0': resolution: {integrity: sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3044,8 +3038,8 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/typescript-estree@8.59.4': - resolution: {integrity: sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==} + '@typescript-eslint/typescript-estree@8.60.0': + resolution: {integrity: sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' @@ -3064,8 +3058,8 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.59.4': - resolution: {integrity: sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==} + '@typescript-eslint/utils@8.60.0': + resolution: {integrity: sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -3079,8 +3073,8 @@ packages: resolution: {integrity: sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/visitor-keys@8.59.4': - resolution: {integrity: sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==} + '@typescript-eslint/visitor-keys@8.60.0': + resolution: {integrity: sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@ungap/structured-clone@1.3.0': @@ -3094,11 +3088,11 @@ packages: vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 vue: ^3.2.25 - '@vitest/expect@4.1.6': - resolution: {integrity: sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==} + '@vitest/expect@4.1.7': + resolution: {integrity: sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==} - '@vitest/mocker@4.1.6': - resolution: {integrity: sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==} + '@vitest/mocker@4.1.7': + resolution: {integrity: sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -3108,20 +3102,20 @@ packages: vite: optional: true - '@vitest/pretty-format@4.1.6': - resolution: {integrity: sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==} + '@vitest/pretty-format@4.1.7': + resolution: {integrity: sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==} - '@vitest/runner@4.1.6': - resolution: {integrity: sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==} + '@vitest/runner@4.1.7': + resolution: {integrity: sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==} - '@vitest/snapshot@4.1.6': - resolution: {integrity: sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==} + '@vitest/snapshot@4.1.7': + resolution: {integrity: sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==} - '@vitest/spy@4.1.6': - resolution: {integrity: sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==} + '@vitest/spy@4.1.7': + resolution: {integrity: sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==} - '@vitest/utils@4.1.6': - resolution: {integrity: sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==} + '@vitest/utils@4.1.7': + resolution: {integrity: sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==} '@volar/language-core@2.4.28': resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} @@ -3194,8 +3188,8 @@ packages: typescript: optional: true - '@vue/language-core@3.3.0': - resolution: {integrity: sha512-EyUxq1b8Yoxk6hQ6X33BIRnfFLb9Rbm9w/8G8y6uMxlQu7CW7yy9JS/z54xSpIvBvVWX6Lt5v1aBGwmrqD4aJw==} + '@vue/language-core@3.3.2': + resolution: {integrity: sha512-CLwjSfHlPLhjd2qhuS3tTFtnOIWHXAM5u4X1DxmzlQ8j5bmOYlKCsSusOP7jCRJnlVg0mCTQtHU3vwFvopZGoQ==} '@vue/reactivity@3.5.27': resolution: {integrity: sha512-vvorxn2KXfJ0nBEnj4GYshSgsyMNFnIQah/wczXlsNXt+ijhugmW+PpJ2cNPe4V6jpnBcs0MhCODKllWG+nvoQ==} @@ -3505,8 +3499,8 @@ packages: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} - cacheable@2.3.2: - resolution: {integrity: sha512-w+ZuRNmex9c1TR9RcsxbfTKCjSL0rh1WA5SABbrWprIHeNBdmyQLSYonlDy9gpD+63XT8DgZ/wNh1Smvc9WnJA==} + cacheable@2.3.5: + resolution: {integrity: sha512-EQfaKe09tl615iNvq/TBRWTFf1AKJNXYQSsMx0Z3EI0nA+pVsVPS8wJhnRlkbdacKPh1d0qVIhwTc2zsQNFEEg==} call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} @@ -3578,6 +3572,10 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + chroma-js@1.4.1: resolution: {integrity: sha512-jTwQiT859RTFN/vIf7s+Vl/Z2LcMrvMv3WUFmd/4u76AdlFC0NTNgqEEFPcRiHmAswPsMiQEDZLM8vX8qXpZNQ==} @@ -4194,8 +4192,8 @@ packages: resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} engines: {node: '>=8'} - file-entry-cache@11.1.2: - resolution: {integrity: sha512-N2WFfK12gmrK1c1GXOqiAJ1tc5YE+R53zvQ+t5P8S5XhnmKYVB5eZEiLNZKDSmoG8wqqbF9EXYBBW/nef19log==} + file-entry-cache@11.1.3: + resolution: {integrity: sha512-oMbq0PD6VIiIwMF6LIa7MEwd/l9huKwmqRKXqmrkqIZv8CvRbfowL+L0ryAl8h//HfAS0zS+4SbYoRyAoA6BJA==} file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} @@ -4228,8 +4226,8 @@ packages: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} - flat-cache@6.1.20: - resolution: {integrity: sha512-AhHYqwvN62NVLp4lObVXGVluiABTHapoB57EyegZVmazN+hhGhLTn3uZbOofoTw4DSDvVCadzzyChXhOAvy8uQ==} + flat-cache@6.1.22: + resolution: {integrity: sha512-N2dnzVJIphnNsjHcrxGW7DePckJ6haPrSFqpsBUhHYgwtKGVq4JrBGielEGD2fCVnsGm1zlBVZ8wGhkyuetgug==} flatpickr@4.6.13: resolution: {integrity: sha512-97PMG/aywoYpB4IvbvUJi0RQi8vearvU0oov1WW3k0WZPBMrTQVqekSX5CjSG/M4Q3i6A/0FKXC7RyAoAUUSPw==} @@ -4458,6 +4456,10 @@ packages: resolution: {integrity: sha512-Wn2i1In6XFxl8Az55kkgnFRiAlIAushzh26PTjL2AKtQcEfXrcLa7Hn5QOWGZEf3LU057P9TwwZjFyxfS1VuvQ==} engines: {node: '>=20'} + hashery@1.5.1: + resolution: {integrity: sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==} + engines: {node: '>=20'} + hasown@2.0.3: resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} engines: {node: '>= 0.4'} @@ -4484,6 +4486,9 @@ packages: hookified@1.15.1: resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==} + hookified@2.2.0: + resolution: {integrity: sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==} + hosted-git-info@2.8.9: resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} @@ -4810,9 +4815,9 @@ packages: engines: {node: '>=14'} hasBin: true - js-cookie@3.0.5: - resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==} - engines: {node: '>=14'} + js-cookie@3.0.7: + resolution: {integrity: sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==} + engines: {node: '>=20'} js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -5297,10 +5302,6 @@ packages: orderedmap@2.1.1: resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==} - os-tmpdir@1.0.2: - resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==} - engines: {node: '>=0.10.0'} - otplib@12.0.1: resolution: {integrity: sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==} @@ -5753,8 +5754,8 @@ packages: deprecated: < 24.15.0 is no longer supported hasBin: true - qified@0.6.0: - resolution: {integrity: sha512-tsSGN1x3h569ZSU1u6diwhltLyfUWDp3YbFHedapTmpBl0B3P6U3+Qptg7xu+v+1io1EwhdPyyRHYbEw0KN2FA==} + qified@0.10.1: + resolution: {integrity: sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==} engines: {node: '>=20'} queue-microtask@1.2.3: @@ -5788,6 +5789,10 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + redent@3.0.0: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} @@ -5932,118 +5937,118 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - sass-embedded-all-unknown@1.99.0: - resolution: {integrity: sha512-qPIRG8Uhjo6/OKyAKixTnwMliTz+t9K6Duk0mx5z+K7n0Ts38NSJz2sjDnc7cA/8V9Lb3q09H38dZ1CLwD+ssw==} + sass-embedded-all-unknown@1.100.0: + resolution: {integrity: sha512-auFtXY/kwYILmSVjtBDwyj0axcLbYYiffOKWoaXHnI5bsYwiRbBh3EneR1rpbX2ZIZCrwX93i5pxKLTZF/662Q==} cpu: ['!arm', '!arm64', '!riscv64', '!x64'] - sass-embedded-android-arm64@1.99.0: - resolution: {integrity: sha512-fNHhdnP23yqqieCbAdym4N47AleSwjbNt6OYIYx4DdACGdtERjQB4iOX/TaKsW034MupfF7SjnAAK8w7Ptldtg==} + sass-embedded-android-arm64@1.100.0: + resolution: {integrity: sha512-W+Ru9JwTnfU0UX3jSZcbqFdtKFMcYdfFwytc57h2DgnqCOIiAqI2E06mABZBZC+r3LwXCBuS5GbXAGeVgvVDkA==} engines: {node: '>=14.0.0'} cpu: [arm64] os: [android] - sass-embedded-android-arm@1.99.0: - resolution: {integrity: sha512-EHvJ0C7/VuP78Qr6f8gIUVUmCqIorEQpw2yp3cs3SMg02ZuumlhjXvkTcFBxHmFdFR23vTNk1WnhY6QSeV1nFQ==} + sass-embedded-android-arm@1.100.0: + resolution: {integrity: sha512-70f3HgX2pFNmzpGQ86n5e6QfWn2fP4QUQGfFQK0P1XH73ZLIzLo2YqygrGKGKeeqtc5eU2Wl1/xQzhzuKnO4kw==} engines: {node: '>=14.0.0'} cpu: [arm] os: [android] - sass-embedded-android-riscv64@1.99.0: - resolution: {integrity: sha512-4zqDFRvgGDTL5vTHuIhRxUpXFoh0Cy7Gm5Ywk19ASd8Settmd14YdPRZPmMxfgS1GH292PofV1fq1ifiSEJWBw==} + sass-embedded-android-riscv64@1.100.0: + resolution: {integrity: sha512-icU3o0V/uCSytSpf+tX5Lf51BvyQEbLzDUJfUi9etSauYBGHpPKkdtdZH0si4v98phq11Kl8rSV1SggksxF1Hg==} engines: {node: '>=14.0.0'} cpu: [riscv64] os: [android] - sass-embedded-android-x64@1.99.0: - resolution: {integrity: sha512-Uk53k/dGYt04RjOL4gFjZ0Z9DH9DKh8IA8WsXUkNqsxerAygoy3zqRBS2zngfE9K2jiOM87q+1R1p87ory9oQQ==} + sass-embedded-android-x64@1.100.0: + resolution: {integrity: sha512-mevF9VQk6gEYByy8+jusaHGmd7Usb2ytX/DsEOd0JtOGCtcf1kh575xJ6OUBDIcJ15uLnbau/0iy1eP6WVBvWA==} engines: {node: '>=14.0.0'} cpu: [x64] os: [android] - sass-embedded-darwin-arm64@1.99.0: - resolution: {integrity: sha512-u61/7U3IGLqoO6gL+AHeiAtlTPFwJK1+964U8gp45ZN0hzh1yrARf5O1mivXv8NnNgJvbG2wWJbiNZP0lG/lTg==} + sass-embedded-darwin-arm64@1.100.0: + resolution: {integrity: sha512-1PVlYi61POo93IT/FfrG1mc1tAHxeSTyUALF2aOFmXGWjVXr3bQzEQiBGCOvQbj/ix+5hNyXFXcEMEyKvtUJJA==} engines: {node: '>=14.0.0'} cpu: [arm64] os: [darwin] - sass-embedded-darwin-x64@1.99.0: - resolution: {integrity: sha512-j/kkk/NcXdIameLezSfXjgCiBkVcA+G60AXrX768/3g0miK1g7M9dj7xOhCb1i7/wQeiEI3rw2LLuO63xRIn4A==} + sass-embedded-darwin-x64@1.100.0: + resolution: {integrity: sha512-x97o3JnGyImZNCIVs9wQHJUE5QCvmVIKaH1cwrz/5dK7OT1FpeNiW+u9TUomP9hG6Ekjd8EL8NBHpxTfIhdjmg==} engines: {node: '>=14.0.0'} cpu: [x64] os: [darwin] - sass-embedded-linux-arm64@1.99.0: - resolution: {integrity: sha512-btNcFpItcB56L40n8hDeL7sRSMLDXQ56nB5h2deddJx1n60rpKSElJmkaDGHtpkrY+CTtDRV0FZDjHeTJddYew==} + sass-embedded-linux-arm64@1.100.0: + resolution: {integrity: sha512-Dwjmj8Z6VRy7rAi53JAdEwIyUjpfl7PhpSc2/LpQPQx+aO5Dp7Spaipkax0ufJl1SoDUdchCsM4y/88YaluorQ==} engines: {node: '>=14.0.0'} cpu: [arm64] os: [linux] - sass-embedded-linux-arm@1.99.0: - resolution: {integrity: sha512-d4IjJZrX2+AwB2YCy1JySwdptJECNP/WfAQLUl8txI3ka8/d3TUI155GtelnoZUkio211PwIeFvvAeZ9RXPQnw==} + sass-embedded-linux-arm@1.100.0: + resolution: {integrity: sha512-9Ul7O1eKrc5YlhwWjkp8tZPSe3UEwSZ1uwUZOQom1HL0pRlBA6F/IlGZYFTLwnHMIP1fc77MMNaBRfc05mKMpw==} engines: {node: '>=14.0.0'} cpu: [arm] os: [linux] - sass-embedded-linux-musl-arm64@1.99.0: - resolution: {integrity: sha512-Hi2bt/IrM5P4FBKz6EcHAlniwfpoz9mnTdvSd58y+avA3SANM76upIkAdSayA8ZGwyL3gZokru1AKDPF9lJDNw==} + sass-embedded-linux-musl-arm64@1.100.0: + resolution: {integrity: sha512-XpACJB2KjSLjf2e9uuvGVdOURsoNrFqgRiihhXyUHK9W0t3LIHb7z5MA/7XGPIT9bWSOO2zyw+rH/FHtDV/Yrg==} engines: {node: '>=14.0.0'} cpu: [arm64] os: [linux] - sass-embedded-linux-musl-arm@1.99.0: - resolution: {integrity: sha512-2gvHOupgIw3ytatXT4nFUow71LFbuOZPEwG+HUzcNQDH8ue4Ez8cr03vsv5MDv3lIjOKcXwDvWD980t18MwkoQ==} + sass-embedded-linux-musl-arm@1.100.0: + resolution: {integrity: sha512-sl0JgbGloPyJg66XXx5UDSDScZ0oU85DpMQU4JU/sCUCFj1Z8zZ69SJWKTCNE4/jwnce7WI2zPCV5AG+RHOZJw==} engines: {node: '>=14.0.0'} cpu: [arm] os: [linux] - sass-embedded-linux-musl-riscv64@1.99.0: - resolution: {integrity: sha512-mKqGvVaJ9rHMqyZsF0kikQe4NO0f4osb67+X6nLhBiVDKvyazQHJ3zJQreNefIE36yL2sjHIclSB//MprzaQDg==} + sass-embedded-linux-musl-riscv64@1.100.0: + resolution: {integrity: sha512-ShvI0Kx04mwoCARwZ0UjiT97isQvzO80tAt91zmFyHLN9kelc/IrQi940farSm2xQVPCKdeVyeG0ekBsokSpYQ==} engines: {node: '>=14.0.0'} cpu: [riscv64] os: [linux] - sass-embedded-linux-musl-x64@1.99.0: - resolution: {integrity: sha512-huhgOMmOc30r7CH7qbRbT9LerSEGSnWuS4CYNOskr9BvNeQp4dIneFufNRGZ7hkOAxUM8DglxIZJN/cyAT95Ew==} + sass-embedded-linux-musl-x64@1.100.0: + resolution: {integrity: sha512-TDBCRWNuS4RDLQXvRc1gjZlWiWTWaWGp0Bwu/IKwJxov81lsvrCs3TihTyNXtW7V5aoN4Ky3r0QOkNb3mwmBnA==} engines: {node: '>=14.0.0'} cpu: [x64] os: [linux] - sass-embedded-linux-riscv64@1.99.0: - resolution: {integrity: sha512-mevFPIFAVhrH90THifxLfOntFmHtcEKOcdWnep2gJ0X4DVva4AiVIRlQe/7w9JFx5+gnDRE1oaJJkzuFUuYZsA==} + sass-embedded-linux-riscv64@1.100.0: + resolution: {integrity: sha512-j4ENJGOheO+fm3j/yorLxCjBP6/XskrZx7dTLlT+lXYwN/qqCqoA/gsNLI0McS3DFM6GBwPiffzWsdWS8t6sEQ==} engines: {node: '>=14.0.0'} cpu: [riscv64] os: [linux] - sass-embedded-linux-x64@1.99.0: - resolution: {integrity: sha512-9k7IkULqIZdCIVt4Mboryt6vN8Mjmm3EhI1P3mClU5y5i3wLK5ExC3cbVWk047KsID/fvB1RLslqghXJx5BoxA==} + sass-embedded-linux-x64@1.100.0: + resolution: {integrity: sha512-0vUSN8j0WGtCJIOPh//EmUvYGHW0QOe5iul8qyhPk50MAcw49MA0r34AhftjDdx94ILPF6vApFs0gwHPQRlpVA==} engines: {node: '>=14.0.0'} cpu: [x64] os: [linux] - sass-embedded-unknown-all@1.99.0: - resolution: {integrity: sha512-P7MxiUtL/XzGo3PX0CaB8lNNEFLQWKikPA8pbKytx9ZCLZSDkt2NJcdAbblB/sqMs4AV3EK2NadV8rI/diq3xg==} + sass-embedded-unknown-all@1.100.0: + resolution: {integrity: sha512-c+naBgWId4MIpToXcI0DgqetjdAkwTTAxFAuOaBz7HUXLdyG1oZRrEvSsbe41nEdQOKH0vgofVFCeSQgoXOG9A==} os: ['!android', '!darwin', '!linux', '!win32'] - sass-embedded-win32-arm64@1.99.0: - resolution: {integrity: sha512-8whpsW7S+uO8QApKfQuc36m3P9EISzbVZOgC79goob4qGy09u8Gz/rYvw8h1prJDSjltpHGhOzBE6LDz7WvzVw==} + sass-embedded-win32-arm64@1.100.0: + resolution: {integrity: sha512-iE+yxj+hUXwwbqpHkXxgAWTzeRfcWxJ7SSTQEPMk48lwq3oCrWLlz5sQuWHbuTK/i0GKQfROdP+hOmPi89yjUg==} engines: {node: '>=14.0.0'} cpu: [arm64] os: [win32] - sass-embedded-win32-x64@1.99.0: - resolution: {integrity: sha512-ipuOv1R2K4MHeuCEAZGpuUbAgma4gb0sdacyrTjJtMOy/OY9UvWfVlwErdB09KIkp4fPDpQJDJfvYN6bC8jeNg==} + sass-embedded-win32-x64@1.100.0: + resolution: {integrity: sha512-qI4F8MI7/KYoy9NdjJfhSspG42WPkADSNDvwEV7qWvCSFC83koJssRsKO2/PfY+niZz6BG65Ic/D+A11h959hw==} engines: {node: '>=14.0.0'} cpu: [x64] os: [win32] - sass-embedded@1.99.0: - resolution: {integrity: sha512-gF/juR1aX02lZHkvwxdF80SapkQeg2fetoDF6gIQkNbSw5YEUFspMkyGTjPjgZSgIHuZpy+Wz4PlebKnLXMjdg==} + sass-embedded@1.100.0: + resolution: {integrity: sha512-Ut8wlQSk19tm7jMK6mz6cF1+e+E7tUnW2tM02zQDPnOTcVbV8qCQG8UWxZkkNlY50+hV3hqP24OOkUlMz8xBpw==} engines: {node: '>=16.0.0'} hasBin: true - sass@1.99.0: - resolution: {integrity: sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q==} - engines: {node: '>=14.0.0'} + sass@1.100.0: + resolution: {integrity: sha512-B5j0rYMlinhhOo9tjQebMVVn0TfyXAF+wB3b2ggZUuJ/is/Y+7+JGjirAMxHZ9Z3hIP98NPfamlAkBHa1lAaXQ==} + engines: {node: '>=20.19.0'} hasBin: true sax@1.5.0: @@ -6367,8 +6372,8 @@ packages: peerDependencies: stylelint: '>= 11 < 18' - stylelint@17.11.1: - resolution: {integrity: sha512-+smN/HqVTggUx3iuAzOi9fPh8SrH+cJWlZrYVldXoJ06orWBhZ4Ue/QEp64oei6pVrAh4w3tG+Y12Vw7MbCFRQ==} + stylelint@17.12.0: + resolution: {integrity: sha512-KIlzWXMHUvgfPUR0R7TK3H80yCIi0uoivUwf+6Az4yrHJD1Q3c1qIkh/H5Z0i/K3QXgtq/UMEkWyBUSUwnpnOg==} engines: {node: '>=20.19.0'} hasBin: true @@ -6487,9 +6492,9 @@ packages: resolution: {integrity: sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==} hasBin: true - tmp@0.0.33: - resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} - engines: {node: '>=0.6.0'} + tmp@0.2.6: + resolution: {integrity: sha512-5sJPdPjfI5Kx+qbrDesxkglRBxW//g7hCsqspEjwkewGvBMGIKMOTKzLt1hFVJzyadba3lDUN20O9qhvbQUSTA==} + engines: {node: '>=14.14'} to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} @@ -6815,20 +6820,20 @@ packages: yaml: optional: true - vitest@4.1.6: - resolution: {integrity: sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==} + vitest@4.1.7: + resolution: {integrity: sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.6 - '@vitest/browser-preview': 4.1.6 - '@vitest/browser-webdriverio': 4.1.6 - '@vitest/coverage-istanbul': 4.1.6 - '@vitest/coverage-v8': 4.1.6 - '@vitest/ui': 4.1.6 + '@vitest/browser-playwright': 4.1.7 + '@vitest/browser-preview': 4.1.7 + '@vitest/browser-webdriverio': 4.1.7 + '@vitest/coverage-istanbul': 4.1.7 + '@vitest/coverage-v8': 4.1.7 + '@vitest/ui': 4.1.7 happy-dom: '*' jsdom: '*' vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -6907,8 +6912,8 @@ packages: peerDependencies: vue: ^3.5.0 - vue-tsc@3.3.0: - resolution: {integrity: sha512-kY8RcoTOENASi0P1GLPvJgA2+hoGF+t8We1UGgmnAb1r/GjTUMSE3zz+WGfjPORZNnBHdAt67sVPhBLXWunkeg==} + vue-tsc@3.3.2: + resolution: {integrity: sha512-n7nQoA3YWW/eiDR8jMiv/uJvlg0uLGs+YgUrsTrf9EZaYSt3tuvMZb5V8+7Mvh/EH5pnY/hoVdgfjH+XcK+wwA==} hasBin: true peerDependencies: typescript: '>=5.0.0' @@ -7097,8 +7102,8 @@ packages: resolution: {integrity: sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg==} engines: {node: ^20.17.0 || >=22.9.0} - ws@8.20.1: - resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -7924,7 +7929,7 @@ snapshots: '@babel/template@7.26.9': dependencies: - '@babel/code-frame': 7.26.2 + '@babel/code-frame': 7.29.0 '@babel/parser': 7.28.5 '@babel/types': 7.28.5 @@ -7936,7 +7941,7 @@ snapshots: '@babel/traverse@7.25.9': dependencies: - '@babel/code-frame': 7.26.2 + '@babel/code-frame': 7.29.0 '@babel/generator': 7.26.0 '@babel/parser': 7.28.5 '@babel/template': 7.26.9 @@ -7970,16 +7975,16 @@ snapshots: '@bufbuild/protobuf@2.5.2': {} - '@cacheable/memory@2.0.7': + '@cacheable/memory@2.0.9': dependencies: - '@cacheable/utils': 2.3.4 - '@keyv/bigmap': 1.3.0(keyv@5.6.0) + '@cacheable/utils': 2.4.1 + '@keyv/bigmap': 1.3.1(keyv@5.6.0) hookified: 1.15.1 keyv: 5.6.0 - '@cacheable/utils@2.3.4': + '@cacheable/utils@2.4.1': dependencies: - hashery: 1.4.0 + hashery: 1.5.1 keyv: 5.6.0 '@codemirror/commands@6.8.1': @@ -8040,11 +8045,6 @@ snapshots: '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/css-calc@3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': - dependencies: - '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) - '@csstools/css-tokenizer': 4.0.0 - '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': dependencies: '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) @@ -8745,17 +8745,17 @@ snapshots: dependencies: '@hapi/hoek': 11.0.7 - '@histoire/app@1.0.0-beta.1(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3))': + '@histoire/app@1.0.0-beta.1(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))': dependencies: - '@histoire/controls': 1.0.0-beta.1(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3)) - '@histoire/shared': 1.0.0-beta.1(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3)) + '@histoire/controls': 1.0.0-beta.1(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) + '@histoire/shared': 1.0.0-beta.1(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) '@histoire/vendors': 1.0.0-beta.1 fuse.js: 7.1.0 shiki: 3.2.1 transitivePeerDependencies: - vite - '@histoire/controls@1.0.0-beta.1(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3))': + '@histoire/controls@1.0.0-beta.1(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))': dependencies: '@codemirror/commands': 6.8.1 '@codemirror/lang-json': 6.0.1 @@ -8764,17 +8764,17 @@ snapshots: '@codemirror/state': 6.5.2 '@codemirror/theme-one-dark': 6.1.2 '@codemirror/view': 6.36.5 - '@histoire/shared': 1.0.0-beta.1(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3)) + '@histoire/shared': 1.0.0-beta.1(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) '@histoire/vendors': 1.0.0-beta.1 transitivePeerDependencies: - vite - '@histoire/plugin-screenshot@1.0.0-beta.1(histoire@1.0.0-beta.1(@types/node@24.12.4)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3))(yaml@2.8.3))(typescript@5.9.3)': + '@histoire/plugin-screenshot@1.0.0-beta.1(histoire@1.0.0-beta.1(@types/node@24.12.4)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(yaml@2.8.3))(typescript@5.9.3)': dependencies: capture-website: 4.2.0(typescript@5.9.3) defu: 6.1.7 fs-extra: 11.2.0 - histoire: 1.0.0-beta.1(@types/node@24.12.4)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3))(yaml@2.8.3) + histoire: 1.0.0-beta.1(@types/node@24.12.4)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(yaml@2.8.3) pathe: 1.1.2 transitivePeerDependencies: - bare-buffer @@ -8783,21 +8783,21 @@ snapshots: - typescript - utf-8-validate - '@histoire/plugin-vue@1.0.0-beta.1(histoire@1.0.0-beta.1(@types/node@24.12.4)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3))(yaml@2.8.3))(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3))(vue@3.5.27(typescript@5.9.3))': + '@histoire/plugin-vue@1.0.0-beta.1(histoire@1.0.0-beta.1(@types/node@24.12.4)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(yaml@2.8.3))(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(vue@3.5.27(typescript@5.9.3))': dependencies: - '@histoire/controls': 1.0.0-beta.1(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3)) - '@histoire/shared': 1.0.0-beta.1(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3)) + '@histoire/controls': 1.0.0-beta.1(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) + '@histoire/shared': 1.0.0-beta.1(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) '@histoire/vendors': 1.0.0-beta.1 change-case: 5.4.4 globby: 14.1.0 - histoire: 1.0.0-beta.1(@types/node@24.12.4)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3))(yaml@2.8.3) + histoire: 1.0.0-beta.1(@types/node@24.12.4)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(yaml@2.8.3) launch-editor: 2.10.0 pathe: 1.1.2 vue: 3.5.27(typescript@5.9.3) transitivePeerDependencies: - vite - '@histoire/shared@1.0.0-beta.1(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3))': + '@histoire/shared@1.0.0-beta.1(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))': dependencies: '@histoire/vendors': 1.0.0-beta.1 '@types/fs-extra': 11.0.4 @@ -8805,7 +8805,7 @@ snapshots: chokidar: 4.0.3 pathe: 1.1.2 picocolors: 1.1.1 - vite: 7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3) + vite: 7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) '@histoire/vendors@1.0.0-beta.1': {} @@ -8927,7 +8927,7 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@keyv/bigmap@1.3.0(keyv@5.6.0)': + '@keyv/bigmap@1.3.1(keyv@5.6.0)': dependencies: hashery: 1.4.0 hookified: 1.15.1 @@ -9433,12 +9433,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.3.0 '@tailwindcss/oxide-win32-x64-msvc': 4.3.0 - '@tailwindcss/vite@4.3.0(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3))': + '@tailwindcss/vite@4.3.0(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))': dependencies: '@tailwindcss/node': 4.3.0 '@tailwindcss/oxide': 4.3.0 tailwindcss: 4.3.0 - vite: 7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3) + vite: 7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) '@tiptap/core@3.17.0(@tiptap/pm@3.17.0)': dependencies: @@ -9744,14 +9744,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/eslint-plugin@8.59.4(@typescript-eslint/parser@8.59.4(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.60.0(@typescript-eslint/parser@8.60.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.59.4(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.59.4 - '@typescript-eslint/type-utils': 8.59.4(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.59.4(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.59.4 + '@typescript-eslint/parser': 8.60.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.60.0 + '@typescript-eslint/type-utils': 8.60.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.60.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.60.0 eslint: 9.39.4(jiti@2.6.1) ignore: 7.0.5 natural-compare: 1.4.0 @@ -9772,12 +9772,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.59.4(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/parser@8.60.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.59.4 - '@typescript-eslint/types': 8.59.4 - '@typescript-eslint/typescript-estree': 8.59.4(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.59.4 + '@typescript-eslint/scope-manager': 8.60.0 + '@typescript-eslint/types': 8.60.0 + '@typescript-eslint/typescript-estree': 8.60.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.60.0 debug: 4.4.3 eslint: 9.39.4(jiti@2.6.1) typescript: 5.9.3 @@ -9786,8 +9786,8 @@ snapshots: '@typescript-eslint/project-service@8.56.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.59.3(typescript@5.9.3) - '@typescript-eslint/types': 8.59.3 + '@typescript-eslint/tsconfig-utils': 8.59.4(typescript@5.9.3) + '@typescript-eslint/types': 8.59.4 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: @@ -9795,17 +9795,17 @@ snapshots: '@typescript-eslint/project-service@8.58.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.59.3(typescript@5.9.3) - '@typescript-eslint/types': 8.59.3 + '@typescript-eslint/tsconfig-utils': 8.59.4(typescript@5.9.3) + '@typescript-eslint/types': 8.59.4 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.59.4(typescript@5.9.3)': + '@typescript-eslint/project-service@8.60.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.59.4(typescript@5.9.3) - '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/tsconfig-utils': 8.60.0(typescript@5.9.3) + '@typescript-eslint/types': 8.60.0 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: @@ -9821,10 +9821,10 @@ snapshots: '@typescript-eslint/types': 8.58.0 '@typescript-eslint/visitor-keys': 8.58.0 - '@typescript-eslint/scope-manager@8.59.4': + '@typescript-eslint/scope-manager@8.60.0': dependencies: - '@typescript-eslint/types': 8.59.4 - '@typescript-eslint/visitor-keys': 8.59.4 + '@typescript-eslint/types': 8.60.0 + '@typescript-eslint/visitor-keys': 8.60.0 '@typescript-eslint/tsconfig-utils@8.56.0(typescript@5.9.3)': dependencies: @@ -9834,11 +9834,11 @@ snapshots: dependencies: typescript: 5.9.3 - '@typescript-eslint/tsconfig-utils@8.59.3(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.59.4(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/tsconfig-utils@8.59.4(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.60.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 @@ -9854,11 +9854,11 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/type-utils@8.59.4(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.60.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.59.4 - '@typescript-eslint/typescript-estree': 8.59.4(typescript@5.9.3) - '@typescript-eslint/utils': 8.59.4(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/types': 8.60.0 + '@typescript-eslint/typescript-estree': 8.60.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.60.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) debug: 4.4.3 eslint: 9.39.4(jiti@2.6.1) ts-api-utils: 2.5.0(typescript@5.9.3) @@ -9870,10 +9870,10 @@ snapshots: '@typescript-eslint/types@8.58.0': {} - '@typescript-eslint/types@8.59.3': {} - '@typescript-eslint/types@8.59.4': {} + '@typescript-eslint/types@8.60.0': {} + '@typescript-eslint/typescript-estree@8.56.0(typescript@5.9.3)': dependencies: '@typescript-eslint/project-service': 8.56.0(typescript@5.9.3) @@ -9904,12 +9904,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@8.59.4(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.60.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.59.4(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.59.4(typescript@5.9.3) - '@typescript-eslint/types': 8.59.4 - '@typescript-eslint/visitor-keys': 8.59.4 + '@typescript-eslint/project-service': 8.60.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.60.0(typescript@5.9.3) + '@typescript-eslint/types': 8.60.0 + '@typescript-eslint/visitor-keys': 8.60.0 debug: 4.4.3 minimatch: 10.2.4 semver: 7.7.3 @@ -9941,12 +9941,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.59.4(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/utils@8.60.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.59.4 - '@typescript-eslint/types': 8.59.4 - '@typescript-eslint/typescript-estree': 8.59.4(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.60.0 + '@typescript-eslint/types': 8.60.0 + '@typescript-eslint/typescript-estree': 8.60.0(typescript@5.9.3) eslint: 9.39.4(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: @@ -9962,57 +9962,57 @@ snapshots: '@typescript-eslint/types': 8.58.0 eslint-visitor-keys: 5.0.0 - '@typescript-eslint/visitor-keys@8.59.4': + '@typescript-eslint/visitor-keys@8.60.0': dependencies: - '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/types': 8.60.0 eslint-visitor-keys: 5.0.0 '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-vue@6.0.7(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3))(vue@3.5.27(typescript@5.9.3))': + '@vitejs/plugin-vue@6.0.7(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(vue@3.5.27(typescript@5.9.3))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3) + vite: 7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) vue: 3.5.27(typescript@5.9.3) - '@vitest/expect@4.1.6': + '@vitest/expect@4.1.7': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.2 - '@vitest/spy': 4.1.6 - '@vitest/utils': 4.1.6 + '@vitest/spy': 4.1.7 + '@vitest/utils': 4.1.7 chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.6(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3))': + '@vitest/mocker@4.1.7(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))': dependencies: - '@vitest/spy': 4.1.6 + '@vitest/spy': 4.1.7 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3) + vite: 7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) - '@vitest/pretty-format@4.1.6': + '@vitest/pretty-format@4.1.7': dependencies: tinyrainbow: 3.1.0 - '@vitest/runner@4.1.6': + '@vitest/runner@4.1.7': dependencies: - '@vitest/utils': 4.1.6 + '@vitest/utils': 4.1.7 pathe: 2.0.3 - '@vitest/snapshot@4.1.6': + '@vitest/snapshot@4.1.7': dependencies: - '@vitest/pretty-format': 4.1.6 - '@vitest/utils': 4.1.6 + '@vitest/pretty-format': 4.1.7 + '@vitest/utils': 4.1.7 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.1.6': {} + '@vitest/spy@4.1.7': {} - '@vitest/utils@4.1.6': + '@vitest/utils@4.1.7': dependencies: - '@vitest/pretty-format': 4.1.6 + '@vitest/pretty-format': 4.1.7 convert-source-map: 2.0.0 tinyrainbow: 3.1.0 @@ -10049,7 +10049,7 @@ snapshots: '@vue/babel-plugin-resolve-type@1.2.5(@babel/core@7.26.0)': dependencies: - '@babel/code-frame': 7.26.2 + '@babel/code-frame': 7.29.0 '@babel/core': 7.26.0 '@babel/helper-module-imports': 7.25.9 '@babel/helper-plugin-utils': 7.25.9 @@ -10123,11 +10123,11 @@ snapshots: '@vue/devtools-shared@8.1.2': {} - '@vue/eslint-config-typescript@14.7.0(eslint-plugin-vue@10.9.1(@typescript-eslint/parser@8.59.4(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + '@vue/eslint-config-typescript@14.7.0(eslint-plugin-vue@10.9.1(@typescript-eslint/parser@8.60.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/utils': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.4(jiti@2.6.1) - eslint-plugin-vue: 10.9.1(@typescript-eslint/parser@8.59.4(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))) + eslint-plugin-vue: 10.9.1(@typescript-eslint/parser@8.60.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))) fast-glob: 3.3.3 typescript-eslint: 8.56.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) vue-eslint-parser: 10.4.0(eslint@9.39.4(jiti@2.6.1)) @@ -10136,7 +10136,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@vue/language-core@3.3.0': + '@vue/language-core@3.3.2': dependencies: '@volar/language-core': 2.4.28 '@vue/compiler-dom': 3.5.27 @@ -10460,13 +10460,13 @@ snapshots: cac@6.7.14: {} - cacheable@2.3.2: + cacheable@2.3.5: dependencies: - '@cacheable/memory': 2.0.7 - '@cacheable/utils': 2.3.4 + '@cacheable/memory': 2.0.9 + '@cacheable/utils': 2.4.1 hookified: 1.15.1 keyv: 5.6.0 - qified: 0.6.0 + qified: 0.10.1 call-bind-apply-helpers@1.0.2: dependencies: @@ -10547,6 +10547,11 @@ snapshots: dependencies: readdirp: 4.1.2 + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + optional: true + chroma-js@1.4.1: {} chromium-bidi@0.11.0(devtools-protocol@0.0.1367902): @@ -11101,7 +11106,7 @@ snapshots: module-replacements: 2.11.0 semver: 7.7.3 - eslint-plugin-vue@10.9.1(@typescript-eslint/parser@8.59.4(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))): + eslint-plugin-vue@10.9.1(@typescript-eslint/parser@8.60.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) eslint: 9.39.4(jiti@2.6.1) @@ -11112,7 +11117,7 @@ snapshots: vue-eslint-parser: 10.4.0(eslint@9.39.4(jiti@2.6.1)) xml-name-validator: 4.0.0 optionalDependencies: - '@typescript-eslint/parser': 8.59.4(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.60.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) eslint-scope@8.4.0: dependencies: @@ -11210,7 +11215,7 @@ snapshots: dependencies: chardet: 0.7.0 iconv-lite: 0.4.24 - tmp: 0.0.33 + tmp: 0.2.6 extract-zip@2.0.1: dependencies: @@ -11258,9 +11263,9 @@ snapshots: dependencies: escape-string-regexp: 1.0.5 - file-entry-cache@11.1.2: + file-entry-cache@11.1.3: dependencies: - flat-cache: 6.1.20 + flat-cache: 6.1.22 file-entry-cache@8.0.0: dependencies: @@ -11303,9 +11308,9 @@ snapshots: flatted: 3.4.2 keyv: 4.5.4 - flat-cache@6.1.20: + flat-cache@6.1.22: dependencies: - cacheable: 2.3.2 + cacheable: 2.3.5 flatted: 3.4.2 hookified: 1.15.1 @@ -11522,7 +11527,7 @@ snapshots: '@types/ws': 8.18.1 entities: 7.0.1 whatwg-mimetype: 3.0.0 - ws: 8.20.1 + ws: 8.21.0 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -11553,6 +11558,10 @@ snapshots: dependencies: hookified: 1.15.1 + hashery@1.5.1: + dependencies: + hookified: 1.15.1 + hasown@2.0.3: dependencies: function-bind: 1.1.2 @@ -11577,12 +11586,12 @@ snapshots: highlight.js@11.11.1: {} - histoire@1.0.0-beta.1(@types/node@24.12.4)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3))(yaml@2.8.3): + histoire@1.0.0-beta.1(@types/node@24.12.4)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(yaml@2.8.3): dependencies: '@akryum/tinypool': 0.3.1 - '@histoire/app': 1.0.0-beta.1(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3)) - '@histoire/controls': 1.0.0-beta.1(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3)) - '@histoire/shared': 1.0.0-beta.1(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3)) + '@histoire/app': 1.0.0-beta.1(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) + '@histoire/controls': 1.0.0-beta.1(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) + '@histoire/shared': 1.0.0-beta.1(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) '@histoire/vendors': 1.0.0-beta.1 '@types/markdown-it': 14.1.2 birpc: 0.2.19 @@ -11607,8 +11616,8 @@ snapshots: sade: 1.8.1 shiki: 3.2.1 sirv: 3.0.2 - vite: 7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3) - vite-node: 3.2.4(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3) + vite: 7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) + vite-node: 3.2.4(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) transitivePeerDependencies: - '@exodus/crypto' - '@types/node' @@ -11630,6 +11639,8 @@ snapshots: hookified@1.15.1: {} + hookified@2.2.0: {} + hosted-git-info@2.8.9: {} html-encoding-sniffer@6.0.0: @@ -11940,10 +11951,10 @@ snapshots: config-chain: 1.1.13 editorconfig: 1.0.4 glob: 10.5.0 - js-cookie: 3.0.5 + js-cookie: 3.0.7 nopt: 7.2.1 - js-cookie@3.0.5: {} + js-cookie@3.0.7: {} js-tokens@4.0.0: {} @@ -11978,7 +11989,7 @@ snapshots: webidl-conversions: 8.0.1 whatwg-mimetype: 4.0.0 whatwg-url: 15.1.0 - ws: 8.20.1 + ws: 8.21.0 xml-name-validator: 5.0.0 transitivePeerDependencies: - '@exodus/crypto' @@ -12397,8 +12408,6 @@ snapshots: orderedmap@2.1.1: {} - os-tmpdir@1.0.2: {} - otplib@12.0.1: dependencies: '@otplib/core': 12.0.1 @@ -12462,7 +12471,7 @@ snapshots: parse-json@5.2.0: dependencies: - '@babel/code-frame': 7.26.2 + '@babel/code-frame': 7.29.0 error-ex: 1.3.2 json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 @@ -12951,7 +12960,7 @@ snapshots: debug: 4.4.3 devtools-protocol: 0.0.1367902 typed-query-selector: 2.12.0 - ws: 8.20.1 + ws: 8.21.0 transitivePeerDependencies: - bare-buffer - bufferutil @@ -12973,9 +12982,9 @@ snapshots: - typescript - utf-8-validate - qified@0.6.0: + qified@0.10.1: dependencies: - hookified: 1.15.1 + hookified: 2.2.0 queue-microtask@1.2.3: {} @@ -13013,6 +13022,9 @@ snapshots: readdirp@4.1.2: {} + readdirp@5.0.0: + optional: true + redent@3.0.0: dependencies: indent-string: 4.0.0 @@ -13191,65 +13203,65 @@ snapshots: safer-buffer@2.1.2: {} - sass-embedded-all-unknown@1.99.0: + sass-embedded-all-unknown@1.100.0: dependencies: - sass: 1.99.0 + sass: 1.100.0 optional: true - sass-embedded-android-arm64@1.99.0: + sass-embedded-android-arm64@1.100.0: optional: true - sass-embedded-android-arm@1.99.0: + sass-embedded-android-arm@1.100.0: optional: true - sass-embedded-android-riscv64@1.99.0: + sass-embedded-android-riscv64@1.100.0: optional: true - sass-embedded-android-x64@1.99.0: + sass-embedded-android-x64@1.100.0: optional: true - sass-embedded-darwin-arm64@1.99.0: + sass-embedded-darwin-arm64@1.100.0: optional: true - sass-embedded-darwin-x64@1.99.0: + sass-embedded-darwin-x64@1.100.0: optional: true - sass-embedded-linux-arm64@1.99.0: + sass-embedded-linux-arm64@1.100.0: optional: true - sass-embedded-linux-arm@1.99.0: + sass-embedded-linux-arm@1.100.0: optional: true - sass-embedded-linux-musl-arm64@1.99.0: + sass-embedded-linux-musl-arm64@1.100.0: optional: true - sass-embedded-linux-musl-arm@1.99.0: + sass-embedded-linux-musl-arm@1.100.0: optional: true - sass-embedded-linux-musl-riscv64@1.99.0: + sass-embedded-linux-musl-riscv64@1.100.0: optional: true - sass-embedded-linux-musl-x64@1.99.0: + sass-embedded-linux-musl-x64@1.100.0: optional: true - sass-embedded-linux-riscv64@1.99.0: + sass-embedded-linux-riscv64@1.100.0: optional: true - sass-embedded-linux-x64@1.99.0: + sass-embedded-linux-x64@1.100.0: optional: true - sass-embedded-unknown-all@1.99.0: + sass-embedded-unknown-all@1.100.0: dependencies: - sass: 1.99.0 + sass: 1.100.0 optional: true - sass-embedded-win32-arm64@1.99.0: + sass-embedded-win32-arm64@1.100.0: optional: true - sass-embedded-win32-x64@1.99.0: + sass-embedded-win32-x64@1.100.0: optional: true - sass-embedded@1.99.0: + sass-embedded@1.100.0: dependencies: '@bufbuild/protobuf': 2.5.2 colorjs.io: 0.5.2 @@ -13259,28 +13271,28 @@ snapshots: sync-child-process: 1.0.2 varint: 6.0.0 optionalDependencies: - sass-embedded-all-unknown: 1.99.0 - sass-embedded-android-arm: 1.99.0 - sass-embedded-android-arm64: 1.99.0 - sass-embedded-android-riscv64: 1.99.0 - sass-embedded-android-x64: 1.99.0 - sass-embedded-darwin-arm64: 1.99.0 - sass-embedded-darwin-x64: 1.99.0 - sass-embedded-linux-arm: 1.99.0 - sass-embedded-linux-arm64: 1.99.0 - sass-embedded-linux-musl-arm: 1.99.0 - sass-embedded-linux-musl-arm64: 1.99.0 - sass-embedded-linux-musl-riscv64: 1.99.0 - sass-embedded-linux-musl-x64: 1.99.0 - sass-embedded-linux-riscv64: 1.99.0 - sass-embedded-linux-x64: 1.99.0 - sass-embedded-unknown-all: 1.99.0 - sass-embedded-win32-arm64: 1.99.0 - sass-embedded-win32-x64: 1.99.0 + sass-embedded-all-unknown: 1.100.0 + sass-embedded-android-arm: 1.100.0 + sass-embedded-android-arm64: 1.100.0 + sass-embedded-android-riscv64: 1.100.0 + sass-embedded-android-x64: 1.100.0 + sass-embedded-darwin-arm64: 1.100.0 + sass-embedded-darwin-x64: 1.100.0 + sass-embedded-linux-arm: 1.100.0 + sass-embedded-linux-arm64: 1.100.0 + sass-embedded-linux-musl-arm: 1.100.0 + sass-embedded-linux-musl-arm64: 1.100.0 + sass-embedded-linux-musl-riscv64: 1.100.0 + sass-embedded-linux-musl-x64: 1.100.0 + sass-embedded-linux-riscv64: 1.100.0 + sass-embedded-linux-x64: 1.100.0 + sass-embedded-unknown-all: 1.100.0 + sass-embedded-win32-arm64: 1.100.0 + sass-embedded-win32-x64: 1.100.0 - sass@1.99.0: + sass@1.100.0: dependencies: - chokidar: 4.0.3 + chokidar: 5.0.0 immutable: 5.1.5 source-map-js: 1.2.1 optionalDependencies: @@ -13574,58 +13586,58 @@ snapshots: style-mod@4.1.2: {} - stylelint-config-html@1.1.0(postcss-html@1.8.1)(stylelint@17.11.1(typescript@5.9.3)): + stylelint-config-html@1.1.0(postcss-html@1.8.1)(stylelint@17.12.0(typescript@5.9.3)): dependencies: postcss-html: 1.8.1 - stylelint: 17.11.1(typescript@5.9.3) + stylelint: 17.12.0(typescript@5.9.3) - stylelint-config-property-sort-order-smacss@10.0.0(stylelint@17.11.1(typescript@5.9.3)): + stylelint-config-property-sort-order-smacss@10.0.0(stylelint@17.12.0(typescript@5.9.3)): dependencies: css-property-sort-order-smacss: 2.2.0 - stylelint: 17.11.1(typescript@5.9.3) - stylelint-order: 6.0.4(stylelint@17.11.1(typescript@5.9.3)) + stylelint: 17.12.0(typescript@5.9.3) + stylelint-order: 6.0.4(stylelint@17.12.0(typescript@5.9.3)) - stylelint-config-recommended-scss@17.0.0(postcss@8.5.14)(stylelint@17.11.1(typescript@5.9.3)): + stylelint-config-recommended-scss@17.0.0(postcss@8.5.14)(stylelint@17.12.0(typescript@5.9.3)): dependencies: postcss-scss: 4.0.9(postcss@8.5.14) - stylelint: 17.11.1(typescript@5.9.3) - stylelint-config-recommended: 18.0.0(stylelint@17.11.1(typescript@5.9.3)) - stylelint-scss: 7.0.0(stylelint@17.11.1(typescript@5.9.3)) + stylelint: 17.12.0(typescript@5.9.3) + stylelint-config-recommended: 18.0.0(stylelint@17.12.0(typescript@5.9.3)) + stylelint-scss: 7.0.0(stylelint@17.12.0(typescript@5.9.3)) optionalDependencies: postcss: 8.5.14 - stylelint-config-recommended-vue@1.6.1(postcss-html@1.8.1)(stylelint@17.11.1(typescript@5.9.3)): + stylelint-config-recommended-vue@1.6.1(postcss-html@1.8.1)(stylelint@17.12.0(typescript@5.9.3)): dependencies: postcss-html: 1.8.1 semver: 7.7.3 - stylelint: 17.11.1(typescript@5.9.3) - stylelint-config-html: 1.1.0(postcss-html@1.8.1)(stylelint@17.11.1(typescript@5.9.3)) - stylelint-config-recommended: 18.0.0(stylelint@17.11.1(typescript@5.9.3)) + stylelint: 17.12.0(typescript@5.9.3) + stylelint-config-html: 1.1.0(postcss-html@1.8.1)(stylelint@17.12.0(typescript@5.9.3)) + stylelint-config-recommended: 18.0.0(stylelint@17.12.0(typescript@5.9.3)) - stylelint-config-recommended@18.0.0(stylelint@17.11.1(typescript@5.9.3)): + stylelint-config-recommended@18.0.0(stylelint@17.12.0(typescript@5.9.3)): dependencies: - stylelint: 17.11.1(typescript@5.9.3) + stylelint: 17.12.0(typescript@5.9.3) - stylelint-config-standard-scss@17.0.0(postcss@8.5.14)(stylelint@17.11.1(typescript@5.9.3)): + stylelint-config-standard-scss@17.0.0(postcss@8.5.14)(stylelint@17.12.0(typescript@5.9.3)): dependencies: - stylelint: 17.11.1(typescript@5.9.3) - stylelint-config-recommended-scss: 17.0.0(postcss@8.5.14)(stylelint@17.11.1(typescript@5.9.3)) - stylelint-config-standard: 40.0.0(stylelint@17.11.1(typescript@5.9.3)) + stylelint: 17.12.0(typescript@5.9.3) + stylelint-config-recommended-scss: 17.0.0(postcss@8.5.14)(stylelint@17.12.0(typescript@5.9.3)) + stylelint-config-standard: 40.0.0(stylelint@17.12.0(typescript@5.9.3)) optionalDependencies: postcss: 8.5.14 - stylelint-config-standard@40.0.0(stylelint@17.11.1(typescript@5.9.3)): + stylelint-config-standard@40.0.0(stylelint@17.12.0(typescript@5.9.3)): dependencies: - stylelint: 17.11.1(typescript@5.9.3) - stylelint-config-recommended: 18.0.0(stylelint@17.11.1(typescript@5.9.3)) + stylelint: 17.12.0(typescript@5.9.3) + stylelint-config-recommended: 18.0.0(stylelint@17.12.0(typescript@5.9.3)) - stylelint-order@6.0.4(stylelint@17.11.1(typescript@5.9.3)): + stylelint-order@6.0.4(stylelint@17.12.0(typescript@5.9.3)): dependencies: postcss: 8.5.14 postcss-sorting: 8.0.2(postcss@8.5.14) - stylelint: 17.11.1(typescript@5.9.3) + stylelint: 17.12.0(typescript@5.9.3) - stylelint-scss@7.0.0(stylelint@17.11.1(typescript@5.9.3)): + stylelint-scss@7.0.0(stylelint@17.12.0(typescript@5.9.3)): dependencies: css-tree: 3.2.1 is-plain-object: 5.0.0 @@ -13635,15 +13647,15 @@ snapshots: postcss-resolve-nested-selector: 0.1.6 postcss-selector-parser: 7.1.1 postcss-value-parser: 4.2.0 - stylelint: 17.11.1(typescript@5.9.3) + stylelint: 17.12.0(typescript@5.9.3) - stylelint-use-logical@2.1.3(stylelint@17.11.1(typescript@5.9.3)): + stylelint-use-logical@2.1.3(stylelint@17.12.0(typescript@5.9.3)): dependencies: - stylelint: 17.11.1(typescript@5.9.3) + stylelint: 17.12.0(typescript@5.9.3) - stylelint@17.11.1(typescript@5.9.3): + stylelint@17.12.0(typescript@5.9.3): dependencies: - '@csstools/css-calc': 3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-syntax-patches-for-csstree': 1.1.3(css-tree@3.2.1) '@csstools/css-tokenizer': 4.0.0 @@ -13657,7 +13669,7 @@ snapshots: debug: 4.4.3 fast-glob: 3.3.3 fastest-levenshtein: 1.0.16 - file-entry-cache: 11.1.2 + file-entry-cache: 11.1.3 global-modules: 2.0.0 globby: 16.2.0 globjoin: 0.1.4 @@ -13802,9 +13814,7 @@ snapshots: dependencies: tldts-core: 7.0.19 - tmp@0.0.33: - dependencies: - os-tmpdir: 1.0.2 + tmp@0.2.6: {} to-regex-range@5.0.1: dependencies: @@ -14048,23 +14058,23 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.2 - vite-dev-rpc@1.1.0(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3)): + vite-dev-rpc@1.1.0(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)): dependencies: birpc: 2.6.1 - vite: 7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3) - vite-hot-client: 2.1.0(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3)) + vite: 7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) + vite-hot-client: 2.1.0(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) - vite-hot-client@2.1.0(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3)): + vite-hot-client@2.1.0(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)): dependencies: - vite: 7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3) + vite: 7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) - vite-node@3.2.4(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3): + vite-node@3.2.4(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3) + vite: 7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) transitivePeerDependencies: - '@types/node' - jiti @@ -14079,7 +14089,7 @@ snapshots: - tsx - yaml - vite-plugin-inspect@11.3.3(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3)): + vite-plugin-inspect@11.3.3(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)): dependencies: ansis: 4.1.0 debug: 4.4.3 @@ -14089,37 +14099,37 @@ snapshots: perfect-debounce: 2.0.0 sirv: 3.0.2 unplugin-utils: 0.3.0 - vite: 7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3) - vite-dev-rpc: 1.1.0(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3)) + vite: 7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) + vite-dev-rpc: 1.1.0(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) transitivePeerDependencies: - supports-color - vite-plugin-pwa@1.3.0(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3))(workbox-build@7.4.1)(workbox-window@7.4.1): + vite-plugin-pwa@1.3.0(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(workbox-build@7.4.1)(workbox-window@7.4.1): dependencies: debug: 4.4.3 pretty-bytes: 6.1.1 tinyglobby: 0.2.15 - vite: 7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3) + vite: 7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) workbox-build: 7.4.1 workbox-window: 7.4.1 transitivePeerDependencies: - supports-color - vite-plugin-vue-devtools@8.1.2(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3))(vue@3.5.27(typescript@5.9.3)): + vite-plugin-vue-devtools@8.1.2(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(vue@3.5.27(typescript@5.9.3)): dependencies: '@vue/devtools-core': 8.1.2(vue@3.5.27(typescript@5.9.3)) '@vue/devtools-kit': 8.1.2 '@vue/devtools-shared': 8.1.2 sirv: 3.0.2 - vite: 7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3) - vite-plugin-inspect: 11.3.3(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3)) - vite-plugin-vue-inspector: 6.0.0(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3)) + vite: 7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) + vite-plugin-inspect: 11.3.3(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) + vite-plugin-vue-inspector: 6.0.0(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) transitivePeerDependencies: - '@nuxt/kit' - supports-color - vue - vite-plugin-vue-inspector@6.0.0(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3)): + vite-plugin-vue-inspector@6.0.0(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)): dependencies: '@babel/core': 7.26.0 '@babel/plugin-proposal-decorators': 7.25.9(@babel/core@7.26.0) @@ -14130,7 +14140,7 @@ snapshots: '@vue/compiler-dom': 3.5.27 kolorist: 1.8.0 magic-string: 0.30.21 - vite: 7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3) + vite: 7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) transitivePeerDependencies: - supports-color @@ -14142,7 +14152,7 @@ snapshots: transitivePeerDependencies: - supports-color - vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3): + vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3): dependencies: esbuild: 0.27.5 fdir: 6.5.0(picomatch@4.0.4) @@ -14155,20 +14165,20 @@ snapshots: fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.32.0 - sass: 1.99.0 - sass-embedded: 1.99.0 + sass: 1.100.0 + sass-embedded: 1.100.0 terser: 5.31.6 yaml: 2.8.3 - vitest@4.1.6(@types/node@24.12.4)(happy-dom@20.9.0)(jsdom@27.4.0)(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3)): + vitest@4.1.7(@types/node@24.12.4)(happy-dom@20.9.0)(jsdom@27.4.0)(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)): dependencies: - '@vitest/expect': 4.1.6 - '@vitest/mocker': 4.1.6(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3)) - '@vitest/pretty-format': 4.1.6 - '@vitest/runner': 4.1.6 - '@vitest/snapshot': 4.1.6 - '@vitest/spy': 4.1.6 - '@vitest/utils': 4.1.6 + '@vitest/expect': 4.1.7 + '@vitest/mocker': 4.1.7(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) + '@vitest/pretty-format': 4.1.7 + '@vitest/runner': 4.1.7 + '@vitest/snapshot': 4.1.7 + '@vitest/spy': 4.1.7 + '@vitest/utils': 4.1.7 es-module-lexer: 2.0.0 expect-type: 1.3.0 magic-string: 0.30.21 @@ -14180,7 +14190,7 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.1.0 - vite: 7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3) + vite: 7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.12.4 @@ -14237,10 +14247,10 @@ snapshots: '@vue/devtools-api': 6.6.4 vue: 3.5.27(typescript@5.9.3) - vue-tsc@3.3.0(typescript@5.9.3): + vue-tsc@3.3.2(typescript@5.9.3): dependencies: '@volar/typescript': 2.4.28 - '@vue/language-core': 3.3.0 + '@vue/language-core': 3.3.2 typescript: 5.9.3 vue@3.5.27(typescript@5.9.3): @@ -14530,7 +14540,7 @@ snapshots: dependencies: signal-exit: 4.1.0 - ws@8.20.1: {} + ws@8.21.0: {} wsl-utils@0.1.0: dependencies: diff --git a/frontend/src/i18n/lang/ja-JP.json b/frontend/src/i18n/lang/ja-JP.json index d20972955..5e0d38cbd 100644 --- a/frontend/src/i18n/lang/ja-JP.json +++ b/frontend/src/i18n/lang/ja-JP.json @@ -5,9 +5,36 @@ }, "home": { "welcomeNight": "おやすみなさい、{username}さん", + "welcomeNightOwl": "夜更かしですか、{username}さん", + "welcomeNightBurning": "夜更かししていますね、{username}さん?", + "welcomeNightQuiet": "静かな時間ですね、{username}さん", + "welcomeNightLate": "もう遅い時間ですよ、{username}さん", + "welcomeNightMoonlit": "月明かりの下で計画ですか、{username}さん?", "welcomeMorning": "おはようございます、{username}さん", + "welcomeMorningHey": "やあ {username}さん、準備はいいですか?", + "welcomeMorningFresh": "さわやかな朝ですね、{username}さん", + "welcomeMorningCoffee": "コーヒーとタスクはいかがですか、{username}さん?", + "welcomeMorningRise": "さあ、計画を立てましょう、{username}さん", + "welcomeMorningBack": "おかえりなさい、{username}さん", + "welcomeMondayFresh": "新しい一週間の始まりです、{username}さん", + "welcomeTuesday": "よい火曜日を、{username}さん", + "welcomeWednesdayMid": "もう週の半ばですね、{username}さん", + "welcomeThursday": "あと少しです、{username}さん", + "welcomeFridayPush": "金曜日、もうひと踏ん張りですね、{username}さん?", + "welcomeSaturday": "週末モードですね、{username}さん", + "welcomeSundaySession": "日曜日の作業ですか、{username}さん?", "welcomeDay": "こんにちは、{username}さん", + "welcomeDayBack": "作業再開ですね、{username}さん", + "welcomeDayFocus": "集中していきましょう、{username}さん", + "welcomeDayKeepGoing": "その調子、{username}さん", + "welcomeDayWhatsNext": "次は何ですか、{username}さん?", + "welcomeDayGood": "こんにちは、{username}さん", "welcomeEvening": "こんばんは、{username}さん", + "welcomeEveningWind": "そろそろ一段落ですか、{username}さん?", + "welcomeEveningReturns": "{username}さんのお戻りですね", + "welcomeEveningWrap": "そろそろ終わりにしませんか、{username}さん?", + "welcomeEveningOneMore": "あと一つだけいかがですか、{username}さん?", + "welcomeEveningStill": "まだ頑張っていますね、{username}さん?", "lastViewed": "最近の表示", "addToHomeScreen": "ホーム画面に追加すると、すぐにアクセスできて使いやすくなります。", "goToOverview": "概要に移動", @@ -53,6 +80,15 @@ "authenticating": "認証中…", "openIdStateError": "stateパラメータが一致しないため処理を中断しました。", "openIdGeneralError": "認証中にエラーが発生しました。", + "openIdTotpRequired": "このアカウントには2要素認証が必要です。TOTPコードを入力して再度サインインしてください。", + "openIdTotpSubmit": "続ける", + "oauthMissingParams": "必要なOAuthパラメータがありません: {params}", + "oauthRedirectedToApp": "アプリにリダイレクトされました。このタブは閉じて構いません。", + "desktopTryDemo": "デモを試す", + "desktopCustomServer": "カスタムサーバーURL", + "desktopCustomServerDescription": "使用するVikunjaサーバーのURLを入力して始めましょう。", + "desktopWaitingForAuth": "認証を待機中…", + "desktopOAuthError": "認証に失敗しました: {error}", "logout": "ログアウト", "emailInvalid": "有効なメールアドレスを入力してください。", "usernameRequired": "ユーザー名を入力してください。", @@ -71,6 +107,19 @@ "registrationFailed": "登録中にエラーが発生しました。入力内容を確認して、もう一度お試しください。" }, "settings": { + "bots": { + "title": "ボットユーザー", + "description": "ボットユーザーは、あなたが所有する API 専用のユーザーです。プロジェクトに追加したり、タスクを割り当てたり、API トークンで認証したりできます。対話的にログインすることはできません。", + "namePlaceholder": "マイアシスタント", + "create": "ボットを作成", + "enable": "有効にする", + "badge": "ボット", + "delete": { + "header": "このボットユーザーを削除", + "text1": "ボットユーザー「{username}」を削除してもよろしいですか?", + "text2": "この操作は取り消せません。このボットに紐付く API トークンはすべて失効します。" + } + }, "title": "設定", "newPasswordTitle": "パスワードの更新", "newPassword": "新しいパスワード", @@ -96,12 +145,21 @@ "weekStart": "週の始まり", "weekStartSunday": "日曜日", "weekStartMonday": "月曜日", + "weekStartTuesday": "火曜日", + "weekStartWednesday": "水曜日", + "weekStartThursday": "木曜日", + "weekStartFriday": "金曜日", + "weekStartSaturday": "土曜日", "language": "言語", "defaultProject": "デフォルトのプロジェクト", "defaultView": "デフォルトのビュー", "timezone": "タイムゾーン", "overdueTasksRemindersTime": "期限切れタスクのリマインダー送信時間", + "quickAddDefaultReminders": "クイック追加のデフォルトリマインダー", + "quickAddDefaultRemindersDescription": "期限日を持つクイック追加マジックで作成されたすべてのタスクに、これらのリマインダーが自動的に追加されます。", + "quickAddDefaultRemindersHint": "タスクの期限日を基準としたリマインダーを1つ以上追加してください。空にすると無効化されます。", "filterUsedOnOverview": "概要ページの絞り込み条件", + "showLastViewed": "概要ページに最近表示したプロジェクトを表示", "minimumPriority": "表示タスク優先度の最小値", "dateDisplay": "日付表示形式", "dateDisplayOptions": { @@ -125,7 +183,13 @@ "taskAndNotifications": "プロジェクトとタスク", "privacy": "プライバシー設定", "localization": "ローカライズ", - "appearance": "外観と動作" + "appearance": "外観と動作", + "desktop": "デスクトップアプリ" + }, + "desktop": { + "quickEntryShortcut": "クイック入力ショートカット", + "shortcutRecorderPlaceholder": "クリックしてショートカットを設定", + "shortcutRecorderRecording": "キーの組み合わせを押してください…" }, "totp": { "title": "2要素認証", @@ -135,15 +199,32 @@ "scanQR": "あるいは、TOTPアプリでこのQRコードをスキャンして認証コードを入力してください:", "passcode": "認証コード", "passcodePlaceholder": "TOTPアプリで生成された認証コード", + "confirmNotice": "2要素認証を有効化すると、すべてのセッションからログアウトされ、再度ログインが必要になります。", "setupSuccess": "2要素認証は正常に設定されました。", "enterPassword": "パスワードを入力してください", "disable": "2要素認証の無効化", + "confirmSuccess": "2要素認証を有効化しました!", "disableSuccess": "2要素認証は無効化されました。" }, "caldav": { "title": "CalDAV", + "howTo": "VikunjaをCalDAVクライアントに接続すると、さまざまなクライアントからすべてのタスクを表示・管理できます。以下のURLをクライアントに入力してください:", "more": "VikunjaのCalDAVに関する詳細情報", - "tokens": "CalDAVトークン" + "tokens": "CalDAVトークン", + "tokensHowTo": "CalDAV認証には、通常のアカウントパスワードかCalDAV専用トークンのいずれかを利用できます。", + "createToken": "CalDAVトークンを作成", + "tokenCreated": "新しいトークンはこちらです: {token}", + "wontSeeItAgain": "書き留めるか安全に保管してください — 再度表示することはできません。", + "mustUseToken": "サードパーティ製クライアントでCalDAVを利用するには、CalDAVトークンを作成する必要があります。作成したトークンをクライアントのパスワード欄に入力してください。", + "usernameIs": "CalDAV用のユーザー名: {0}", + "apiTokenHint": "CalDAV権限を持つAPIトークンも利用できます。{link} で作成してください。" + }, + "feeds": { + "title": "Atom フィード", + "howTo": "Atom 対応のフィードリーダーから Vikunja の通知を購読できます。以下の URL を使用してください:", + "usernameIs": "フィード用のユーザー名: {0}", + "apiTokenHint": "{scope} 権限を持つ API トークンで認証してください。{link} で作成できます。", + "tokenTitle": "Atom フィード" }, "avatar": { "title": "プロフィール画像", @@ -174,6 +255,10 @@ "backgroundBrightness": { "title": "背景の明るさ" }, + "webhooks": { + "title": "Webhook通知", + "description": "リマインダーや期限切れイベント発生時にPOSTリクエストを受信するWebhook URLを設定します。これらのWebhookはすべてのプロジェクトからイベントを受信します。" + }, "apiTokens": { "title": "APIトークン", "general": "APIトークンを使うとログインせずにVikunjaのAPIを利用できます。", @@ -189,6 +274,13 @@ "expired": "このトークンは {ago} に期限切れしています。", "tokenCreatedSuccess": "新しい API トークンはこちらです: {token}", "tokenCreatedNotSeeAgain": "このトークンは二度と表示されません。安全な場所に保管してください。", + "presets": { + "title": "クイックプリセット", + "readOnly": "読み取り専用", + "tasks": "タスク管理", + "projects": "プロジェクト管理", + "fullAccess": "フルアクセス" + }, "delete": { "header": "トークンの削除", "text1": "トークン \"{token}\" を削除してよろしいですか?", @@ -368,7 +460,8 @@ "addPlaceholder": "タスクを追加…", "empty": "このプロジェクトにはタスクが存在しません。", "newTaskCta": "タスクを作成してください。", - "editTask": "タスクの編集" + "editTask": "タスクの編集", + "sort": "並べ替え" }, "gantt": { "title": "ガント", @@ -427,7 +520,8 @@ "bucketTitleSavedSuccess": "バケットのタイトルは正常に保存されました。", "bucketLimitSavedSuccess": "バケットの上限は正常に保存されました。", "collapse": "このバケットを折りたたむ", - "bucketLimitReached": "バケットの上限に達しました。新しいタスクを追加するには、既存のタスクを削除するか、上限を緩和してください。" + "bucketLimitReached": "バケットの上限に達しました。新しいタスクを追加するには、既存のタスクを削除するか、上限を緩和してください。", + "bucketOptions": "バケットオプション" }, "pseudo": { "favorites": { @@ -550,6 +644,29 @@ } } }, + "sorting": { + "manually": "手動", + "apply": "並べ替えを適用", + "description": "このリスト内のタスクの並び順を選択します。手動並び替えを選ぶと、ドラッグ&ドロップでタスクの順序を変更できます。", + "options": { + "titleAsc": "タイトル (A–Z)", + "titleDesc": "タイトル (Z–A)", + "priorityDesc": "優先度 (高い順)", + "priorityAsc": "優先度 (低い順)", + "dueDateAsc": "期限日 (早い順)", + "dueDateDesc": "期限日 (遅い順)", + "startDateAsc": "開始日 (早い順)", + "startDateDesc": "開始日 (遅い順)", + "endDateAsc": "終了日 (早い順)", + "endDateDesc": "終了日 (遅い順)", + "percentDoneDesc": "完了率 (高い順)", + "percentDoneAsc": "完了率 (低い順)", + "createdDesc": "作成日時 (新しい順)", + "createdAsc": "作成日時 (古い順)", + "updatedDesc": "更新日時 (新しい順)", + "updatedAsc": "更新日時 (古い順)" + } + }, "migrate": { "title": "他のサービスからのインポート", "titleService": "{name}からVikunjaへのデータのインポート", @@ -564,7 +681,30 @@ "importUpload": "{name}からVikunjaにデータをインポートするには、以下のボタンをクリックしてファイルを選択してください。", "upload": "ファイルのアップロード", "migrationStartedWillReciveEmail": "{service}のリスト、タスク、メモ、リマインダー、ファイルをすべてVikunjaにインポートします。完了までしばらくお待ちください。メールでお知らせします。このウィンドウは閉じても構いません。", - "migrationInProgress": "現在移行中です。完了するまでしばらくお待ちください。" + "migrationInProgress": "現在移行中です。完了するまでしばらくお待ちください。", + "csv": { + "description": "カスタム列マッピングでCSVファイルからタスクをインポートします。", + "uploadDescription": "インポートするCSVファイルを選択してください。ファイルの1行目はヘッダーで、タスクデータを含んでいる必要があります。", + "selectFile": "CSVファイルを選択", + "columnMappingDescription": "CSVファイルの各列をタスク属性にマッピングします。Vikunjaが最も可能性の高いマッピングを自動検出しています。設定を変更すると、下部のプレビューが自動更新されます。", + "parsingOptions": "解析オプション", + "delimiter": "区切り文字", + "dateFormat": "日付形式", + "skipRows": "スキップする行数", + "mapColumns": "列のマッピング", + "example": "例:", + "preview": "プレビュー", + "previewDescription": "インポートされる {count} 件のタスクのうち先頭5件を表示しています。", + "import": "タスクをインポート", + "untitled": "無題のタスク", + "ignore": "無視", + "delimiters": { + "comma": "カンマ (,)", + "semicolon": "セミコロン (;)", + "tab": "タブ", + "pipe": "パイプ (|)" + } + } }, "label": { "title": "ラベル", @@ -604,7 +744,9 @@ "upcoming": "今後の予定", "settings": "設定", "imprint": "運営情報", - "privacy": "プライバシーポリシー" + "privacy": "プライバシーポリシー", + "closeSidebar": "サイドバーを閉じる", + "home": "Vikunja ホーム" }, "misc": { "loading": "読み込み中…", @@ -636,9 +778,15 @@ "createdBy": "{0} によって作成", "actions": "アクション", "cannotBeUndone": "この操作は元に戻せません!", - "avatarOfUser": "{user} のプロフィール画像" + "avatarOfUser": "{user} のプロフィール画像", + "closeBanner": "バナーを閉じる", + "closeDialog": "ダイアログを閉じる", + "closeQuickActions": "クイックアクションを閉じる", + "skipToContent": "メインコンテンツへスキップ", + "sortBy": "並び替え" }, "input": { + "projectColor": "プロジェクトの色", "resetColor": "色のリセット", "datepicker": { "today": "今日", @@ -698,6 +846,9 @@ "toggleHeaderCell": "選択中のセルのタイトル セル指定の有無の切り替え", "mergeOrSplit": "結合または分割", "fixTables": "テーブルの修正" + }, + "emoji": { + "empty": "絵文字が見つかりません" } }, "multiselect": { @@ -711,6 +862,7 @@ "date": "日付", "ranges": { "today": "今日", + "tomorrow": "明日", "thisWeek": "今週", "restOfThisWeek": "今から週末まで", "nextWeek": "来週", @@ -784,6 +936,7 @@ "addReminder": "リマイダーを作成…", "doneSuccess": "タスクを完了にしました。", "undoneSuccess": "タスクを未完了に戻しました。", + "readOnlyCheckbox": "このタスクには読み取り権限しかないため、完了にすることはできません。", "movedToProject": "タスクは {project} に移動しました。", "undo": "元に戻す", "checklistTotal": "{total}件中{checked}件のタスク", @@ -796,7 +949,8 @@ "select": "期間の選択", "noTasks": "タスクはありません — よい一日を!", "filterByLabel": "ラベル {label} での絞り込み", - "clearLabelFilter": "ラベルでの絞り込みの解除" + "clearLabelFilter": "ラベルでの絞り込みの解除", + "savedFilterIgnored": "ラベルごとのタスク表示中は、保存されたホーム画面のフィルターは適用されません。" }, "detail": { "chooseDueDate": "期日を設定…", @@ -811,9 +965,14 @@ "updateSuccess": "タスクは正常に保存されました。", "deleteSuccess": "タスクは正常に削除されました。", "duplicateSuccess": "タスクは正常に複製されました。", + "noBucket": "バケットなし", + "bucketChangedSuccess": "タスクのバケットを変更しました。", "belongsToProject": "このタスクはプロジェクト「{project}」に含まれています。", "back": "プロジェクトに戻る", "due": "期限: {at}", + "closeTaskDetail": "タスク詳細を閉じる", + "title": "タスクの詳細", + "markAsDone": "「{task}」を完了にする", "scrollToBottom": "一番下まで移動", "organization": "組織", "management": "管理", @@ -907,7 +1066,10 @@ "addedSuccess": "コメントは正常に追加されました。", "permalink": "コメントへのリンクをコピー", "sortNewestFirst": "新しい順", - "sortOldestFirst": "古い順" + "sortOldestFirst": "古い順", + "reply": "返信", + "jumpToOriginal": "元のコメントへ移動", + "deletedComment": "削除されたコメント" }, "mention": { "noUsersFound": "ユーザーが見つかりません" @@ -990,6 +1152,7 @@ "mode": "繰り返しモード", "monthly": "毎月", "fromCurrentDate": "完了からの間隔", + "each": "毎", "specifyAmount": "数字を入力…", "hours": "時間ごと", "days": "日ごと", @@ -998,6 +1161,7 @@ }, "quickAddMagic": { "hint": "期日、担当者、その他の項目を追加するキーワードが使用できます。", + "quickEntryHint": "日付やラベルなどに使えるマジックプレフィックスを利用できます。詳細はVikunja本体のアプリを開き、タスク入力欄のツールチップをご確認ください。", "title": "クイック追加", "intro": "タスクを作成する際に特定のキーワードを使うことで項目を直接追加できます。よく使う項目とともにタスクをすぐ追加できます。", "multiple": "複数使用できます。", @@ -1170,9 +1334,11 @@ "none": "通知はありません。よい一日を!", "explainer": "購読中のアクション、プロジェクト、タスクへの変更が発生すると、通知がここに表示されます。", "markAllRead": "通知をすべて既読にする", - "markAllReadSuccess": "通知をすべて既読にしました。" + "markAllReadSuccess": "通知をすべて既読にしました。", + "subscribeFeed": "Atom フィードで通知を購読" }, "quickActions": { + "notLoggedIn": "まずVikunja本体のウィンドウにログインしてください。", "commands": "コマンド", "placeholder": "コマンドまたはキーワードを入力…", "hint": "{project} を使うとプロジェクトを検索対象にできます。{project} または {label} (ラベル) を検索条件と組み合わせて使うとプロジェクト内のタスクやラベルの付いたタスクを検索できます。{assignee} を使うとチームを検索対象にできます。", @@ -1305,5 +1471,66 @@ "weeks": "週間", "years": "年" } + }, + "admin": { + "title": "管理", + "labels": { + "users": "ユーザー", + "tasks": "タスク" + }, + "overview": { + "shares": "共有", + "linkSharesShort": "リンク", + "teamSharesShort": "チーム", + "userSharesShort": "ユーザー", + "version": "バージョン", + "license": "ライセンス", + "licenseValidUntil": "有効期限", + "licenseExpiresIn": "あと {days} 日", + "licenseLastVerified": "最終検証日", + "licenseNever": "なし", + "licenseLastCheckFailed": "直近のチェックに失敗しました", + "licenseFeatures": "機能", + "licenseInstance": "インスタンスID", + "licenseManage": "管理" + }, + "searchUsersPlaceholder": "ユーザー名またはメールアドレスで検索…", + "users": { + "status": "ステータス", + "details": "詳細", + "detailsTitle": "ユーザー: {username}", + "issuer": "発行者", + "issuerLocal": "ローカル", + "issuerUrl": "発行者URL", + "subject": "サブジェクト", + "statusActive": "有効", + "statusEmailConfirmation": "メール確認待ち", + "statusDisabled": "無効化済み", + "statusLocked": "アカウントロック中", + "isAdminLabel": "管理者", + "addUser": "ユーザーを追加", + "createTitle": "ユーザーを作成", + "nameLabel": "名前", + "skipEmailConfirm": "メール確認をスキップ", + "createSubmit": "ユーザーを作成", + "saveButton": "変更を保存", + "createdSuccess": "ユーザー {username} を作成しました。", + "updatedSuccess": "ユーザー {username} を更新しました。", + "deletedSuccess": "ユーザー {username} を削除しました。", + "deleteScheduledSuccess": "ユーザー {username} に削除スケジュール確認メールが送信されます。", + "confirmDeleteTitle": "ユーザーを削除しますか?", + "confirmDeleteIntro": "ユーザー {username} をどのように削除しますか?", + "deleteModeScheduled": "削除をスケジュール", + "deleteModeScheduledHelp": "削除のスケジュールでは、ユーザー自身によるアカウント削除と同様に、確認メールをユーザーに送信します。", + "deleteModeNow": "今すぐ削除", + "deleteModeNowHelp": "今すぐ削除は、ユーザーとそのすべてのデータを即時に削除します。この操作は取り消せません。" + }, + "projects": { + "ownerLabel": "所有者", + "reassignOwner": "所有者を再割り当て", + "reassignTitle": "{title} を再割り当て", + "reassignedSuccess": "プロジェクトの所有者を再割り当てしました。", + "newOwnerLabel": "新しい所有者" + } } } \ No newline at end of file diff --git a/frontend/src/i18n/lang/nl-NL.json b/frontend/src/i18n/lang/nl-NL.json index c9706b8f1..8b2d349db 100644 --- a/frontend/src/i18n/lang/nl-NL.json +++ b/frontend/src/i18n/lang/nl-NL.json @@ -5,10 +5,36 @@ }, "home": { "welcomeNight": "Goedenacht {username}!", + "welcomeNightOwl": "Hey {username}, nachtuil", + "welcomeNightBurning": "Tot diep in de nacht doorwerken {username}?", + "welcomeNightQuiet": "Stille uurtjes, {username}", + "welcomeNightLate": "Het is laat, {username}", + "welcomeNightMoonlit": "Maanverlichte planning, {username}?", "welcomeMorning": "Goedemorgen {username}!", + "welcomeMorningHey": "Hey {username}, klaar om te gaan?", + "welcomeMorningFresh": "Verse start, {username}", + "welcomeMorningCoffee": "Koffie en taken, {username}?", + "welcomeMorningRise": "Opstaan en plannen, {username}", + "welcomeMorningBack": "Welkom terug, {username}", + "welcomeMondayFresh": "Verse week, {username}", + "welcomeTuesday": "Fijne dinsdag, {username}", + "welcomeWednesdayMid": "Midden van de week alweer, {username}", + "welcomeThursday": "Bijna klaar, {username}", + "welcomeFridayPush": "Vrijdag nog even door, {username}?", + "welcomeSaturday": "Weekendstand, {username}", + "welcomeSundaySession": "Zondagse sessie, {username}?", "welcomeDay": "Hallo {username}!", + "welcomeDayBack": "Weer aan de slag, {username}", + "welcomeDayFocus": "Nu met focus, {username}", + "welcomeDayKeepGoing": "Blijf doorgaan, {username}", + "welcomeDayWhatsNext": "Wat komt er nu, {username}?", + "welcomeDayGood": "Goedemiddag {username}", "welcomeEvening": "Goedenavond {username}!", + "welcomeEveningWind": "Nu tot rust komen, {username}?", "welcomeEveningReturns": "{username} keert terug", + "welcomeEveningWrap": "Tijd om af te ronden, {username}?", + "welcomeEveningOneMore": "Nog één ding, {username}?", + "welcomeEveningStill": "Nog steeds bezig, {username}?", "lastViewed": "Laatst bekeken", "addToHomeScreen": "Voeg deze app toe aan je startscherm voor snellere toegang en verbeterde ervaring.", "goToOverview": "Ga naar overzicht", @@ -54,6 +80,15 @@ "authenticating": "Authenticeren…", "openIdStateError": "Status komt niet overeen, weigert door te gaan!", "openIdGeneralError": "Er was een fout tijdens het authenticeren bij de externe applicatie.", + "openIdTotpRequired": "Je account vereist tweestapsverificatie. Voer je TOTP-code in en log opnieuw in.", + "openIdTotpSubmit": "Doorgaan", + "oauthMissingParams": "Ontbrekende OAuth parameters: {params}", + "oauthRedirectedToApp": "Je bent doorgestuurd naar de app. Je kunt dit tabblad nu sluiten.", + "desktopTryDemo": "Probeer de demo", + "desktopCustomServer": "Aangepaste server-URL", + "desktopCustomServerDescription": "Voer de URL in van je Vikunja server om te beginnen.", + "desktopWaitingForAuth": "Wachten op authenticatie…", + "desktopOAuthError": "Authenticatie mislukt: {error}", "logout": "Uitloggen", "emailInvalid": "Vul een geldig e-mailadres in.", "usernameRequired": "Geef een gebruikersnaam op.", @@ -68,9 +103,23 @@ "alreadyHaveAnAccount": "Heb je al een account?", "remember": "Ingelogd blijven", "registrationDisabled": "Registratie is uitgeschakeld.", - "passwordResetTokenMissing": "Wachtwoord reset token ontbreekt." + "passwordResetTokenMissing": "Wachtwoord reset token ontbreekt.", + "registrationFailed": "Er is een fout opgetreden tijdens de registratie. Controleer je invoer en probeer opnieuw." }, "settings": { + "bots": { + "title": "Bot-gebruikers", + "description": "Bot-gebruikers zijn API-only gebruikers waar jij eigenaar van bent. Ze kunnen worden toegevoegd aan projecten, taken krijgen en authenticeren met API-tokens. Ze kunnen niet interactief inloggen.", + "namePlaceholder": "Mijn Assistent", + "create": "Bot aanmaken", + "enable": "Inschakelen", + "badge": "Bot", + "delete": { + "header": "Verwijder deze bot-gebruiker", + "text1": "Weet je zeker dat je bot-gebruiker \"{username}\" wilt verwijderen?", + "text2": "Dit is onomkeerbaar. Alle API-tokens die bij deze bot horen, worden ingetrokken." + } + }, "title": "Instellingen", "newPasswordTitle": "Je wachtwoord bijwerken", "newPassword": "Nieuw wachtwoord", @@ -96,12 +145,21 @@ "weekStart": "Week begint op", "weekStartSunday": "Zondag", "weekStartMonday": "Maandag", + "weekStartTuesday": "Dinsdag", + "weekStartWednesday": "Woensdag", + "weekStartThursday": "Donderdag", + "weekStartFriday": "Vrijdag", + "weekStartSaturday": "Zaterdag", "language": "Taal", "defaultProject": "Standaardproject", "defaultView": "Standaardweergave", "timezone": "Tijdzone", "overdueTasksRemindersTime": "Tijdstip herinneringsmail voor achterstallige taken", + "quickAddDefaultReminders": "Standaardherinneringen voor Snel Toevoegen", + "quickAddDefaultRemindersDescription": "Deze herinneringen worden automatisch toegevoegd aan elke taak die is gemaakt via Magisch Snel Toevoegen met een vervaldatum.", + "quickAddDefaultRemindersHint": "Voeg herinnering(en) toe ten opzichte van de vervaldatum van de taak. Laat leeg om uit te schakelen.", "filterUsedOnOverview": "Opgeslagen filter toegepast op de overzichtspagina", + "showLastViewed": "Toon laatst bekeken projecten op de overzichtspagina", "minimumPriority": "Minimale zichtbare taakprioriteit", "dateDisplay": "Datumweergave", "dateDisplayOptions": { @@ -125,7 +183,13 @@ "taskAndNotifications": "Projecten & taken", "privacy": "Privacy", "localization": "Lokalisatie", - "appearance": "Uiterlijk & gedrag" + "appearance": "Uiterlijk & gedrag", + "desktop": "Desktop app" + }, + "desktop": { + "quickEntryShortcut": "Sneltoets voor snelle invoer", + "shortcutRecorderPlaceholder": "Klik om sneltoets in te stellen", + "shortcutRecorderRecording": "Druk op een toetscombinatie…" }, "totp": { "title": "Tweestapsverificatie", @@ -135,15 +199,32 @@ "scanQR": "Als alternatief kan je ook deze QR code scannen:", "passcode": "Je toegangscode", "passcodePlaceholder": "Een code gegenereerd door je TOTP-app", + "confirmNotice": "Na het inschakelen van tweestapsverificatie, wordt je uitgelogd uit alle sessies en moet je opnieuw inloggen.", "setupSuccess": "Je hebt tweestapsverificatie succesvol ingesteld!", "enterPassword": "Voer alsjeblieft je wachtwoord in", "disable": "Tweestapsverificatie uitschakelen", + "confirmSuccess": "Je hebt tweestapsverificatie succesvol ingeschakeld!", "disableSuccess": "Uitschakelen tweestapsverificatie is geslaagd." }, "caldav": { "title": "CalDAV", + "howTo": "Je kunt Vikunja verbinden met CalDAV-clients om taken te bekijken en beheren vanuit verschillende clients. Voer deze url in bij je client:", "more": "Meer informatie over CalDAV in Vikunja", - "tokens": "CalDAV tokens" + "tokens": "CalDAV tokens", + "tokensHowTo": "Voor CalDAV-authenticatie gebruik je jouw normale accountwachtwoord of een speciaal CalDAV-token.", + "createToken": "Maak een CalDAV-token", + "tokenCreated": "Hier is je nieuwe token: {token}", + "wontSeeItAgain": "Schrijf het op of bewaar het veilig - je kunt het hierna niet meer inzien.", + "mustUseToken": "Je moet een CalDAV-token aanmaken om CalDAV te gebruiken met een externe client. Gebruik het token in het wachtwoordveld van uw client.", + "usernameIs": "Je gebruikersnaam voor CalDAV is: {0}", + "apiTokenHint": "Je kunt ook een API-token gebruiken met CalDAV-permissie. Maak er een aan in {link}." + }, + "feeds": { + "title": "Atom Feed", + "howTo": "Je kunt je abonneren op je Vikunja meldingen met elke Atom-compatibele feedlezer. Gebruik deze URL:", + "usernameIs": "Je gebruikersnaam voor de feed is: {0}", + "apiTokenHint": "Authenticeer met een API-token die {scope} permissies heeft. Maak er een aan in {link}.", + "tokenTitle": "Atom feed" }, "avatar": { "title": "Avatar", @@ -174,6 +255,10 @@ "backgroundBrightness": { "title": "Achtergrond helderheid" }, + "webhooks": { + "title": "Webhook notificaties", + "description": "Configureer webhook-URL's om POST aanvragen te ontvangen wanneer herinneringen of achterstallige gebeurtenissen worden geactiveerd. Deze webhooks ontvangen gebeurtenissen van al je projecten." + }, "apiTokens": { "title": "API tokens", "general": "Met API tokens kun je Vikunja's API gebruiken zonder gebruikersnaam en wachtwoord.", @@ -189,6 +274,13 @@ "expired": "Dit token is verlopen {ago}.", "tokenCreatedSuccess": "Hier is je API-token: {token}", "tokenCreatedNotSeeAgain": "Bewaar het op een veilige locatie, het wordt slechts één keer getoond!", + "presets": { + "title": "Snelle voorkeursinstellingen", + "readOnly": "Alleen-lezen", + "tasks": "Taakbeheer", + "projects": "Projectmanagement", + "fullAccess": "Volledige toegang" + }, "delete": { "header": "Dit token verwijderen", "text1": "Weet je zeker dat je token \"{token}\" wilt verwijderen?", @@ -200,6 +292,20 @@ "expiresAt": "Verloopt op", "permissions": "Machtigingen" } + }, + "sessions": { + "title": "Sessies", + "description": "Dit zijn alle apparaten die momenteel zijn ingelogd op je account. Je kunt elke sessie intrekken om dat apparaat uit te loggen. Het kan tot 10 minuten duren voordat de intrekking volledig van kracht is.", + "deviceInfo": "Apparaat", + "ipAddress": "IP-adres", + "lastActive": "Laatst actief", + "current": "Huidige sessie", + "delete": { + "header": "Sessie intrekken", + "text": "Weet je zeker dat je deze sessie wilt intrekken? Het apparaat wordt uitgelogd. Het kan tot 10 minuten duren voordat de sessie volledig is verlopen." + }, + "deleteSuccess": "De sessie is ingetrokken. Het kan tot 10 minuten duren voordat de sessie volledig is verlopen.", + "noOtherSessions": "Geen andere actieve sessies." } }, "deletion": { @@ -354,7 +460,8 @@ "addPlaceholder": "Taak toevoegen…", "empty": "Dit project is momenteel leeg.", "newTaskCta": "Taak aanmaken.", - "editTask": "Taak bewerken" + "editTask": "Taak bewerken", + "sort": "Sorteren" }, "gantt": { "title": "Gantt", @@ -380,7 +487,10 @@ "taskAriaLabel": "Taak: {task}", "taskAriaLabelById": "Taak {id}", "partialDatesStart": "Alleen startdatum (open-einde)", - "partialDatesEnd": "Alleen einddatum (open-einde)" + "partialDatesEnd": "Alleen einddatum (open-einde)", + "expandGroup": "Groep uitklappen: {task}", + "collapseGroup": "Groep inklappen: {task}", + "toggleRelationArrows": "Relatiepijlen wisselen" }, "table": { "title": "Tabel", @@ -410,7 +520,8 @@ "bucketTitleSavedSuccess": "De categorietitel is succesvol opgeslagen.", "bucketLimitSavedSuccess": "De categorielimiet is succesvol opgeslagen.", "collapse": "Deze categorie inklappen", - "bucketLimitReached": "U heeft de categorielimiet bereikt. Verwijder taken of verhoog de limiet om nieuwe taken toe te voegen." + "bucketLimitReached": "U heeft de categorielimiet bereikt. Verwijder taken of verhoog de limiet om nieuwe taken toe te voegen.", + "bucketOptions": "Categorie-opties" }, "pseudo": { "favorites": { @@ -533,6 +644,29 @@ } } }, + "sorting": { + "manually": "Handmatig", + "apply": "Sortering toepassen", + "description": "Kies hoe taken in deze lijst worden gesorteerd. Bij handmatig sorteren kun je taken verslepen om ze anders te ordenen.", + "options": { + "titleAsc": "Titel (A–Z)", + "titleDesc": "Titel (Z–A)", + "priorityDesc": "Prioriteit (hoogste eerst)", + "priorityAsc": "Prioriteit (laagste eerst)", + "dueDateAsc": "Vervaldatum (vroegste eerst)", + "dueDateDesc": "Vervaldatum (laatste eerst)", + "startDateAsc": "Startdatum (vroegste eerst)", + "startDateDesc": "Startdatum (laatste eerst)", + "endDateAsc": "Einddatum (vroegste eerst)", + "endDateDesc": "Einddatum (laatste eerst)", + "percentDoneDesc": "% gereed (meest gereed eerst)", + "percentDoneAsc": "% gereed (minst gereed eerst)", + "createdDesc": "Aangemaakt (nieuwste eerst)", + "createdAsc": "Aangemaakt (oudste eerst)", + "updatedDesc": "Bijgewerkt (nieuwste eerst)", + "updatedAsc": "Bijgewerkt (oudste eerst)" + } + }, "migrate": { "title": "Importeer vanuit een andere dienst", "titleService": "Importeer je gegevens van {name} naar Vikunja", @@ -547,7 +681,30 @@ "importUpload": "Om gegevens van {name} te importeren in Vikunja, klik je op de knop hieronder om een bestand te kiezen.", "upload": "Bestand uploaden", "migrationStartedWillReciveEmail": "Vikunja gaat nu je lijsten/projecten, taken, notities, herinneringen en bestanden van {service} importeren. Omdat dit een tijdje zal duren, sturen we je een e-mail zodra het klaar is. Je kunt dit venster nu sluiten.", - "migrationInProgress": "Er is momenteel een migratie aan de gang. Wacht tot dit voltooid is." + "migrationInProgress": "Er is momenteel een migratie aan de gang. Wacht tot dit voltooid is.", + "csv": { + "description": "Importeer taken uit een CSV-bestand met aangepaste kolomtoewijzing.", + "uploadDescription": "Kies een CSV-bestand om te importeren. Het bestand moet taakgegevens bevatten met kolomkoppen in de eerste rij.", + "selectFile": "Kies CSV-bestand", + "columnMappingDescription": "Wijs elke kolom in je CSV-bestand toe aan een taakattribuut. Vikunja heeft de meest waarschijnlijke toewijzingen automatisch gedetecteerd. Het voorbeeld hieronder wordt automatisch bijgewerkt wanneer je de instellingen aanpast.", + "parsingOptions": "Opties voor verwerking", + "delimiter": "Scheidingsteken", + "dateFormat": "Datumnotatie", + "skipRows": "Rijen overslaan", + "mapColumns": "Kolommen toewijzen", + "example": "bijv.", + "preview": "Voorbeeld", + "previewDescription": "Toont de eerste 5 van {count} taken die zullen worden geïmporteerd.", + "import": "Taken importeren", + "untitled": "Naamloze taak", + "ignore": "Negeren", + "delimiters": { + "comma": "Komma (,)", + "semicolon": "Puntkomma (;)", + "tab": "Tab", + "pipe": "Pijp (|)" + } + } }, "label": { "title": "Labels", @@ -587,7 +744,9 @@ "upcoming": "Aankomend", "settings": "Instellingen", "imprint": "Imprint", - "privacy": "Privacybeleid" + "privacy": "Privacybeleid", + "closeSidebar": "Zijbalk sluiten", + "home": "Vikunja home" }, "misc": { "loading": "Bezig met laden…", @@ -619,9 +778,15 @@ "createdBy": "Aangemaakt door {0}", "actions": "Acties", "cannotBeUndone": "Dit kan niet ongedaan gemaakt worden!", - "avatarOfUser": "{user}'s profielfoto" + "avatarOfUser": "{user}'s profielfoto", + "closeBanner": "Banner sluiten", + "closeDialog": "Dialoogvenster sluiten", + "closeQuickActions": "Snelle acties sluiten", + "skipToContent": "Direct naar hoofdinhoud", + "sortBy": "Sorteren op" }, "input": { + "projectColor": "Projectkleur", "resetColor": "Kleur resetten", "datepicker": { "today": "Vandaag", @@ -681,6 +846,9 @@ "toggleHeaderCell": "Celkopteksten in-/uitschakelen", "mergeOrSplit": "Samenvoegen of splitsen", "fixTables": "Tabellen repareren" + }, + "emoji": { + "empty": "Geen emoji gevonden" } }, "multiselect": { @@ -694,6 +862,7 @@ "date": "Datum", "ranges": { "today": "Vandaag", + "tomorrow": "Morgen", "thisWeek": "Deze week", "restOfThisWeek": "De rest van deze week", "nextWeek": "Volgende week", @@ -767,6 +936,7 @@ "addReminder": "Herinnering toevoegen…", "doneSuccess": "De taak is succesvol aangemerkt als voltooid.", "undoneSuccess": "Het voltooien van de taak is succesvol teruggedraaid.", + "readOnlyCheckbox": "Je hebt alleen leestoegang tot deze taak en kunt deze niet als voltooid markeren.", "movedToProject": "De taak werd verplaatst naar {project}.", "undo": "Ongedaan maken", "checklistTotal": "{checked} van {total} taken", @@ -779,7 +949,8 @@ "select": "Selecteer datumbereik", "noTasks": "Niets te doen - fijne dag!", "filterByLabel": "Gefilterd op label {label}", - "clearLabelFilter": "Wis labelfilter" + "clearLabelFilter": "Wis labelfilter", + "savedFilterIgnored": "Je opgeslagen startpagina-filter wordt niet toegepast als je taken bekijkt per label." }, "detail": { "chooseDueDate": "Klik hier om een vervaldatum in te stellen", @@ -793,9 +964,15 @@ "doneAt": "{0} voltooid", "updateSuccess": "De taak is succesvol opgeslagen.", "deleteSuccess": "De taak is succesvol verwijderd.", + "duplicateSuccess": "De taak is succesvol gedupliceerd.", + "noBucket": "Geen categorie", + "bucketChangedSuccess": "De taakcategorie is succesvol gewijzigd.", "belongsToProject": "Deze taak hoort bij project '{project}'", "back": "Terug naar project", "due": "Vervalt {at}", + "closeTaskDetail": "Sluit taakdetails", + "title": "Taakdetails", + "markAsDone": "Markeer '{task}' als gereed", "scrollToBottom": "Scroll naar beneden", "organization": "Organisatie", "management": "Beheer", @@ -818,6 +995,7 @@ "attachments": "Bijlagen toevoegen", "relatedTasks": "Relatie toevoegen", "moveProject": "Verplaatsen", + "duplicate": "Dupliceer", "color": "Kleur instellen", "delete": "Verwijder", "favorite": "Toevoegen aan favorieten", @@ -840,8 +1018,8 @@ "relatedTasks": "Verwante Taken", "reminders": "Herinneringen", "repeat": "Herhalen", - "comment": "{count} opmerking | {count} opmerkingen", - "commentCount": "Aantal opmerkingen", + "comment": "{count} reactie | {count} reacties", + "commentCount": "Aantal reacties", "startDate": "Begindatum", "title": "Titel", "updated": "Bijgewerkt", @@ -877,7 +1055,7 @@ }, "comment": { "title": "Reacties", - "loading": "Bezig met laden van reacties…", + "loading": "Reacties laden…", "edited": "bewerkt op {date}", "creating": "Opmerking maken…", "placeholder": "Voeg je reactie toe, druk op '/' voor meer opties…", @@ -888,7 +1066,10 @@ "addedSuccess": "De reactie is succesvol toegevoegd.", "permalink": "Kopieer permalink naar deze reactie", "sortNewestFirst": "Nieuwste eerst", - "sortOldestFirst": "Oudste eerst" + "sortOldestFirst": "Oudste eerst", + "reply": "Beantwoorden", + "jumpToOriginal": "Ga naar originele reactie", + "deletedComment": "verwijderde reactie" }, "mention": { "noUsersFound": "Geen gebruikers gevonden" @@ -980,6 +1161,7 @@ }, "quickAddMagic": { "hint": "Gebruik magische prefixes om vervaldata, toegewezen personen en andere taakeigenschappen te definiëren.", + "quickEntryHint": "Gebruik magische voorvoegsels voor datums, labels en meer. Open de Vikunja hoofd-app en bekijk de tooltip op de taakinvoer voor meer details.", "title": "Snel-toevoegen magie", "intro": "Bij het aanmaken van een taak kun je speciale trefwoorden gebruiken om direct kenmerken toe te voegen aan de nieuwe taak. Hiermee kun je veelgebruikte kenmerken veel sneller toevoegen aan taken.", "multiple": "Je kan dit meerdere keren gebruiken.", @@ -1152,9 +1334,11 @@ "none": "Je hebt geen meldingen. Fijne dag!", "explainer": "Hier verschijnen meldingen wanneer acties, projecten of taken gebeuren waarop u bent geabonneerd.", "markAllRead": "Markeer alle meldingen als gelezen", - "markAllReadSuccess": "Alle meldingen zijn als gelezen gemarkeerd." + "markAllReadSuccess": "Alle meldingen zijn als gelezen gemarkeerd.", + "subscribeFeed": "Abonneren op meldingen via Atom feed" }, "quickActions": { + "notLoggedIn": "Log eerst in op het Vikunja hoofdscherm.", "commands": "Opdrachten", "placeholder": "Typ een opdracht of zoek…", "hint": "Je kunt {project} gebruiken om het zoeken te beperken tot een project. Combineer {project} of {label} (labels) met een zoekopdracht om te zoeken naar een taak met deze labels of op dat project. Gebruik {assignee} om alleen te zoeken naar teams.", @@ -1287,5 +1471,66 @@ "weeks": "week|weken", "years": "jaar|jaren" } + }, + "admin": { + "title": "Beheer", + "labels": { + "users": "Gebruikers", + "tasks": "Taken" + }, + "overview": { + "shares": "Deelbare koppelingen", + "linkSharesShort": "link", + "teamSharesShort": "team", + "userSharesShort": "gebruiker", + "version": "Versie", + "license": "Licentie", + "licenseValidUntil": "Geldig tot", + "licenseExpiresIn": "over {days} dagen", + "licenseLastVerified": "Laatst geverifieerd", + "licenseNever": "nooit", + "licenseLastCheckFailed": "laatste controle mislukt", + "licenseFeatures": "Functionaliteiten", + "licenseInstance": "Instance ID", + "licenseManage": "Beheren" + }, + "searchUsersPlaceholder": "Zoek op gebruikersnaam of e-mail…", + "users": { + "status": "Status", + "details": "Details", + "detailsTitle": "Gebruiker: {username}", + "issuer": "Uitgever", + "issuerLocal": "Lokaal", + "issuerUrl": "Uitgever URL", + "subject": "Onderwerp", + "statusActive": "Actief", + "statusEmailConfirmation": "E-mailbevestiging vereist", + "statusDisabled": "Uitgeschakeld", + "statusLocked": "Account vergrendeld", + "isAdminLabel": "Beheerder", + "addUser": "Gebruiker toevoegen", + "createTitle": "Gebruiker aanmaken", + "nameLabel": "Naam", + "skipEmailConfirm": "E-mailbevestiging overslaan", + "createSubmit": "Gebruiker aanmaken", + "saveButton": "Wijzigingen opslaan", + "createdSuccess": "Gebruiker {username} aangemaakt.", + "updatedSuccess": "Gebruiker {username} bijgewerkt.", + "deletedSuccess": "Gebruiker {username} verwijderd.", + "deleteScheduledSuccess": "Gebruiker {username} ontvangt een bevestigingsmail om de verwijdering te plannen.", + "confirmDeleteTitle": "Gebruiker verwijderen?", + "confirmDeleteIntro": "Hoe moet gebruiker {username} worden verwijderd?", + "deleteModeScheduled": "Verwijdering plannen", + "deleteModeScheduledHelp": "Bij 'verwijdering plannen' ontvangt de gebruiker een bevestigingsmail, lijkend op een zelfgestarte accountverwijdering.", + "deleteModeNow": "Nu verwijderen", + "deleteModeNowHelp": "'Nu verwijderen' verwijdert de gebruiker en hun data onmiddellijk. Dit kan niet ongedaan worden gemaakt." + }, + "projects": { + "ownerLabel": "Eigenaar", + "reassignOwner": "Nieuwe eigenaar toewijzen", + "reassignTitle": "Opnieuw toewijzen {title}", + "reassignedSuccess": "Projecteigenaar opnieuw toegewezen.", + "newOwnerLabel": "Nieuwe eigenaar" + } } } \ No newline at end of file diff --git a/magefile.go b/magefile.go index 5996ee8a7..2d546d378 100644 --- a/magefile.go +++ b/magefile.go @@ -43,7 +43,6 @@ import ( "github.com/iancoleman/strcase" "github.com/magefile/mage/mg" - "golang.org/x/sync/errgroup" ) const ( @@ -62,27 +61,21 @@ var ( // Aliases are mage aliases of targets Aliases = map[string]any{ - "build": Build.Build, - "check:got-swag": Check.GotSwag, - "release": Release.Release, - "release:os-package": Release.OsPackage, - "release:prepare-nfpm-config": Release.PrepareNFPMConfig, - "release:repo-apt": Release.RepoApt, - "release:repo-rpm": Release.RepoRpm, - "release:repo-pacman": Release.RepoPacman, - "dev:make-migration": Dev.MakeMigration, - "dev:make-event": Dev.MakeEvent, - "dev:make-listener": Dev.MakeListener, - "dev:make-notification": Dev.MakeNotification, - "dev:prepare-worktree": Dev.PrepareWorktree, - "dev:tag-release": Dev.TagRelease, - "test:e2e": Test.E2E, - "test:e2e-api": Test.E2EApi, - "plugins:build": Plugins.Build, - "lint": Check.Golangci, - "lint:fix": Check.GolangciFix, - "generate:config-yaml": Generate.ConfigYAML, - "generate:swagger-docs": Generate.SwaggerDocs, + "build": Build.Build, + "check:got-swag": Check.GotSwag, + "dev:make-migration": Dev.MakeMigration, + "dev:make-event": Dev.MakeEvent, + "dev:make-listener": Dev.MakeListener, + "dev:make-notification": Dev.MakeNotification, + "dev:prepare-worktree": Dev.PrepareWorktree, + "dev:tag-release": Dev.TagRelease, + "test:e2e": Test.E2E, + "test:e2e-api": Test.E2EApi, + "plugins:build": Plugins.Build, + "lint": Check.Golangci, + "lint:fix": Check.GolangciFix, + "generate:config-yaml": Generate.ConfigYAML, + "generate:swagger-docs": Generate.SwaggerDocs, } ) @@ -268,45 +261,6 @@ func copyFile(src, dst string) error { return out.Close() } -// os.Rename has issues with moving files between docker volumes. -// Because of this limitation, it fails in drone. -// Source: https://gist.github.com/var23rav/23ae5d0d4d830aff886c3c970b8f6c6b -func moveFile(src, dst string) error { - inputFile, err := os.Open(src) - if err != nil { - return fmt.Errorf("couldn't open source file: %w", err) - } - defer inputFile.Close() - - outputFile, err := os.Create(dst) - if err != nil { - return fmt.Errorf("couldn't open dest file: %w", err) - } - defer outputFile.Close() - - _, err = io.Copy(outputFile, inputFile) - if err != nil { - return fmt.Errorf("writing to output file failed: %w", err) - } - - // Make sure to copy copy the permissions of the original file as well - si, err := os.Stat(src) - if err != nil { - return err - } - - if err := os.Chmod(dst, si.Mode()); err != nil { - return err - } - - // The copy was successful, so now delete the original file - err = os.Remove(src) - if err != nil { - return fmt.Errorf("failed removing original file: %w", err) - } - return nil -} - func appendToFile(filename, content string) error { f, err := os.OpenFile(filename, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0o600) if err != nil { @@ -1180,624 +1134,6 @@ func (Build) SaveVersionToFile() error { return nil } -type Release mg.Namespace - -// Release runs all steps in the right order to create release packages for various platforms -func (Release) Release(ctx context.Context) error { - mg.Deps(initVars) - mg.Deps(Release.Dirs, prepareXgo) - - // Run compiling in parallel to speed it up - errs, _ := errgroup.WithContext(ctx) - errgroupGoWithContext(ctx, errs, (Release{}).Windows) - errgroupGoWithContext(ctx, errs, (Release{}).Linux) - errgroupGoWithContext(ctx, errs, (Release{}).Darwin) - if err := errs.Wait(); err != nil { - return err - } - - if err := (Release{}).Compress(ctx); err != nil { - return err - } - if err := (Release{}).Copy(); err != nil { - return err - } - if err := (Release{}).Check(); err != nil { - return err - } - if err := (Release{}).OsPackage(); err != nil { - return err - } - if err := (Release{}).Zip(ctx); err != nil { - return err - } - - return nil -} - -func errgroupGoWithContext(ctx context.Context, errs *errgroup.Group, do func(context.Context) error) { - errs.Go(func() error { - return do(ctx) - }) -} - -// Dirs creates all directories needed to release vikunja -func (Release) Dirs() error { - for _, d := range []string{"binaries", "release", "zip"} { - if err := os.MkdirAll("./"+DIST+"/"+d, 0o755); err != nil { - return err - } - } - return nil -} - -func prepareXgo(ctx context.Context) error { - mg.Deps(initVars) - if err := checkAndInstallGoTool(ctx, "xgo", "src.techknowlogick.com/xgo"); err != nil { - return err - } - - fmt.Println("Pulling latest xgo docker image...") - return runAndStreamOutput(ctx, "docker", "pull", "ghcr.io/techknowlogick/xgo:latest") -} - -func runXgo(ctx context.Context, targets string) error { - mg.Deps(initVars) - if err := checkAndInstallGoTool(ctx, "xgo", "src.techknowlogick.com/xgo"); err != nil { - return err - } - - extraLdflags := `-linkmode external -extldflags "-static" ` - - // See https://github.com/techknowlogick/xgo/issues/79 - if strings.HasPrefix(targets, "darwin") { - extraLdflags = "" - } - outName := os.Getenv("XGO_OUT_NAME") - if outName == "" { - outName = Executable + "-" + Version - } - - if err := runAndStreamOutput(ctx, "xgo", - "-dest", "./"+DIST+"/binaries", - "-tags", "netgo "+Tags, - "-ldflags", extraLdflags+Ldflags, - "-targets", targets, - "-out", outName, - "."); err != nil { - return err - } - if os.Getenv("DRONE_WORKSPACE") != "" { - return filepath.Walk("/build/", func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - // Skip directories - if info.IsDir() { - return nil - } - - return moveFile(path, "./"+DIST+"/binaries/"+info.Name()) - }) - } - return nil -} - -// Windows builds binaries for windows -func (Release) Windows(ctx context.Context) error { - return runXgo(ctx, "windows/*") -} - -// Linux builds binaries for linux -func (Release) Linux(ctx context.Context) error { - targets := []string{ - "linux/amd64", - "linux/arm-5", - "linux/arm-6", - "linux/arm-7", - "linux/arm64", - "linux/mips", - "linux/mipsle", - "linux/mips64", - "linux/mips64le", - "linux/riscv64", - } - return runXgo(ctx, strings.Join(targets, ",")) -} - -// Darwin builds binaries for darwin -func (Release) Darwin(ctx context.Context) error { - return runXgo(ctx, "darwin-10.15/*") -} - -func (Release) Xgo(ctx context.Context, target string) error { - parts := strings.Split(target, "/") - if len(parts) < 2 { - return fmt.Errorf("invalid target") - } - - variant := "" - if len(parts) > 2 && parts[2] != "" { - variant = "-" + strings.ReplaceAll(parts[2], "v", "") - } - - return runXgo(ctx, parts[0]+"/"+parts[1]+variant) -} - -// Compress compresses the built binaries in dist/binaries/ to reduce their filesize -func (Release) Compress(ctx context.Context) error { - // $(foreach file,$(filter-out $(wildcard $(wildcard $(DIST)/binaries/$(EXECUTABLE)-*mips*)),$(wildcard $(DIST)/binaries/$(EXECUTABLE)-*)), upx -9 $(file);) - - errs, _ := errgroup.WithContext(ctx) - - walkErr := filepath.Walk("./"+DIST+"/binaries/", func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - // Only executable files - if !strings.Contains(info.Name(), Executable) { - return nil - } - if strings.Contains(info.Name(), "mips") || - strings.Contains(info.Name(), "s390x") || - strings.Contains(info.Name(), "riscv64") || - strings.Contains(info.Name(), "darwin") || - (strings.Contains(info.Name(), "windows") && strings.Contains(info.Name(), "arm64")) { - // not supported by upx - return nil - } - - // Runs compressing in parallel since upx is single-threaded - errs.Go(func() error { - if err := runAndStreamOutput(ctx, "chmod", "+x", path); err != nil { // Make sure all binaries are executable. Sometimes the CI does weird things and they're not. - return err - } - return runAndStreamOutput(ctx, "upx", "-9", path) - }) - - return nil - }) - if walkErr != nil { - return walkErr - } - return errs.Wait() -} - -// Copy copies all built binaries to dist/release/ in preparation for creating the os packages -func (Release) Copy() error { - return filepath.Walk("./"+DIST+"/binaries/", func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - // Only executable files - if !strings.Contains(info.Name(), Executable) { - return nil - } - - return copyFile(path, "./"+DIST+"/release/"+info.Name()) - }) -} - -// Check creates sha256 checksum files for each binary in dist/release/ -func (Release) Check() error { - p := "./" + DIST + "/release/" - return filepath.Walk(p, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - if info.IsDir() { - return nil - } - - f, err := os.Create(p + info.Name() + ".sha256") - if err != nil { - return err - } - - hash, err := calculateSha256FileHash(path) - if err != nil { - return err - } - - _, err = f.WriteString(hash + " " + info.Name()) - if err != nil { - return err - } - - return f.Close() - }) -} - -// OsPackage creates a folder for each -func (Release) OsPackage() error { - p := "./" + DIST + "/release/" - - // We first put all files in a map to then iterate over it since the walk function would otherwise also iterate - // over the newly created files, creating some kind of endless loop. - bins := make(map[string]os.FileInfo) - if err := filepath.Walk(p, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - if strings.Contains(info.Name(), ".sha256") || info.IsDir() { - return nil - } - bins[path] = info - return nil - }); err != nil { - return err - } - - generateConfigYAMLFromJSON("./"+DefaultConfigYAMLSamplePath, true) - - for path, info := range bins { - folder := p + info.Name() + "-full/" - if err := os.Mkdir(folder, 0o755); err != nil { - return err - } - if err := moveFile(p+info.Name()+".sha256", folder+info.Name()+".sha256"); err != nil { - return err - } - if err := moveFile(path, folder+info.Name()); err != nil { - return err - } - if err := copyFile("./"+DefaultConfigYAMLSamplePath, folder+DefaultConfigYAMLSamplePath); err != nil { - return err - } - if err := copyFile("./LICENSE", folder+"LICENSE"); err != nil { - return err - } - } - return nil -} - -// Zip creates a zip file from all os-package folders in dist/release -func (Release) Zip(ctx context.Context) error { - rootDir, err := os.Getwd() - if err != nil { - return fmt.Errorf("could not get working directory: %w", err) - } - - p := "./" + DIST + "/release/" - if err := filepath.Walk(p, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - if !info.IsDir() || info.Name() == "release" { - return nil - } - - fmt.Printf("Zipping %s...\n", info.Name()) - - zipFile := filepath.Join(rootDir, DIST, "zip", info.Name()+".zip") - c := exec.CommandContext(ctx, "zip", "-r", zipFile, ".", "-i", "*") //nolint:gosec // This mage task creates zips of every directory recursively, it must use the directory name in the resulting file path to distinguish output files. - c.Dir = path - out, err := c.Output() - fmt.Print(string(out)) - return err - }); err != nil { - return err - } - - return nil -} - -// repoSuite returns a validated suite name from the REPO_SUITE env var. -// Only "stable" and "unstable" are allowed to prevent path traversal. -func repoSuite() string { - suite := os.Getenv("REPO_SUITE") - switch suite { - case "stable", "unstable": - return suite - default: - return "stable" - } -} - -// RepoApt generates APT repository metadata using reprepro. -// It expects .deb files in /repo-work/incoming/ and outputs to /repo-output/apt/. -// The reprepro config is read from build/reprepro-dist-conf. -// Signing is done manually after reprepro finishes to avoid gpgme pinentry issues in CI. -// Environment: REPO_SUITE controls the target suite (default: "stable"). -// Environment: RELEASE_GPG_KEY, RELEASE_GPG_PASSPHRASE must be set for signing. -func (Release) RepoApt(ctx context.Context) error { - mg.Deps(initVars) - - suite := repoSuite() - - incomingDir := filepath.Join(DIST, "repo-work", "incoming") - outputBase := filepath.Join(DIST, "repo-output", "apt") - - // Set up reprepro conf directory - confDir := filepath.Join(outputBase, "conf") - if err := os.MkdirAll(confDir, 0o755); err != nil { - return fmt.Errorf("creating reprepro conf dir: %w", err) - } - - // Copy distributions config - distConf, err := os.ReadFile("build/reprepro-dist-conf") - if err != nil { - return fmt.Errorf("reading reprepro-dist-conf: %w", err) - } - if err := os.WriteFile(filepath.Join(confDir, "distributions"), distConf, 0o600); err != nil { - return fmt.Errorf("writing distributions config: %w", err) - } - - // Include all .deb files into the target suite - debs, err := filepath.Glob(filepath.Join(incomingDir, "*.deb")) - if err != nil { - return err - } - for _, deb := range debs { - abs, _ := filepath.Abs(deb) - if err := runAndStreamOutput(ctx, "reprepro", - "-b", outputBase, - "includedeb", suite, - abs, - ); err != nil { - return fmt.Errorf("reprepro includedeb %s: %w", filepath.Base(deb), err) - } - } - - // Sign Release files manually (reprepro's gpgme signing doesn't work in CI) - gpgKey := os.Getenv("RELEASE_GPG_KEY") - gpgPassphrase := os.Getenv("RELEASE_GPG_PASSPHRASE") - - releaseFile := filepath.Join(outputBase, "dists", suite, "Release") - if _, err := os.Stat(releaseFile); err == nil { - // Generate Release.gpg (detached signature) - if err := runAndStreamOutput(ctx, "gpg", - "--default-key", gpgKey, - "--batch", "--yes", - "--passphrase", gpgPassphrase, - "--pinentry-mode", "loopback", - "--detach-sign", "--armor", - "-o", releaseFile+".gpg", - releaseFile, - ); err != nil { - return fmt.Errorf("signing Release (detached): %w", err) - } - - // Generate InRelease (clearsigned) - if err := runAndStreamOutput(ctx, "gpg", - "--default-key", gpgKey, - "--batch", "--yes", - "--passphrase", gpgPassphrase, - "--pinentry-mode", "loopback", - "--clearsign", - "-o", filepath.Join(filepath.Dir(releaseFile), "InRelease"), - releaseFile, - ); err != nil { - return fmt.Errorf("signing Release (clearsign): %w", err) - } - } - - fmt.Println("APT repo metadata generated in", outputBase) - return nil -} - -// RepoRpm generates RPM repository metadata for all .rpm files in the work directory. -// Expects .rpm files in /repo-work/incoming/ and outputs to /repo-output/rpm//. -// Environment: RELEASE_GPG_KEY, RELEASE_GPG_PASSPHRASE must be set for signing. -// Environment: REPO_SUITE controls the target suite (default: "stable"). -func (Release) RepoRpm(ctx context.Context) error { - mg.Deps(initVars) - - suite := repoSuite() - - incomingDir := filepath.Join(DIST, "repo-work", "incoming") - outputBase := filepath.Join(DIST, "repo-output", "rpm", suite) - - archMap := map[string]string{ - "x86_64": "x86_64", - "aarch64": "aarch64", - "armv7": "armv7", - } - - gpgKey := os.Getenv("RELEASE_GPG_KEY") - gpgPassphrase := os.Getenv("RELEASE_GPG_PASSPHRASE") - - for pkgArch, repoArch := range archMap { - repoDir := filepath.Join(outputBase, repoArch) - if err := os.MkdirAll(repoDir, 0o755); err != nil { - return err - } - - // Symlink matching RPMs - pattern := filepath.Join(incomingDir, "*-"+pkgArch+".rpm") - rpms, _ := filepath.Glob(pattern) - if len(rpms) == 0 { - continue - } - for _, rpm := range rpms { - abs, _ := filepath.Abs(rpm) - dst := filepath.Join(repoDir, filepath.Base(rpm)) - os.Remove(dst) - if err := os.Symlink(abs, dst); err != nil { - return err - } - } - - // createrepo_c (--update if repodata already exists) - args := []string{repoDir} - if _, err := os.Stat(filepath.Join(repoDir, "repodata")); err == nil { - args = []string{"--update", repoDir} - } - if err := runAndStreamOutput(ctx, "createrepo_c", args...); err != nil { - return fmt.Errorf("createrepo_c for %s: %w", repoArch, err) - } - - // Sign repomd.xml - if err := runAndStreamOutput(ctx, "gpg", - "--default-key", gpgKey, - "--batch", "--yes", - "--passphrase", gpgPassphrase, - "--pinentry-mode", "loopback", - "--detach-sign", "--armor", - "-o", filepath.Join(repoDir, "repodata", "repomd.xml.asc"), - filepath.Join(repoDir, "repodata", "repomd.xml"), - ); err != nil { - return fmt.Errorf("signing repomd.xml for %s: %w", repoArch, err) - } - } - - fmt.Println("RPM repo metadata generated in", outputBase) - return nil -} - -// RepoPacman generates Pacman repository database for all .archlinux files in the work directory. -// Expects .archlinux files in /repo-work/incoming/ and outputs to /repo-output/pacman//. -// Environment: RELEASE_GPG_KEY, RELEASE_GPG_PASSPHRASE must be set for signing. -// Environment: REPO_SUITE controls the target suite (default: "stable"). -func (Release) RepoPacman(ctx context.Context) error { - mg.Deps(initVars) - - suite := repoSuite() - - incomingDir := filepath.Join(DIST, "repo-work", "incoming") - outputBase := filepath.Join(DIST, "repo-output", "pacman", suite) - - archMap := map[string]string{ - "x86_64": "x86_64", - "aarch64": "aarch64", - "armv7": "armv7", - } - - gpgKey := os.Getenv("RELEASE_GPG_KEY") - gpgPassphrase := os.Getenv("RELEASE_GPG_PASSPHRASE") - - for pkgArch, repoArch := range archMap { - repoDir := filepath.Join(outputBase, repoArch) - if err := os.MkdirAll(repoDir, 0o755); err != nil { - return err - } - - pattern := filepath.Join(incomingDir, "*-"+pkgArch+".archlinux") - pkgs, _ := filepath.Glob(pattern) - if len(pkgs) == 0 { - continue - } - for _, pkg := range pkgs { - abs, _ := filepath.Abs(pkg) - dst := filepath.Join(repoDir, filepath.Base(pkg)) - os.Remove(dst) - if err := os.Symlink(abs, dst); err != nil { - return err - } - } - - // repo-add creates vikunja.db.tar.gz and vikunja.files.tar.gz - dbPath := filepath.Join(repoDir, "vikunja.db.tar.gz") - repoPkgs, _ := filepath.Glob(filepath.Join(repoDir, "*.archlinux")) - repoAddArgs := append([]string{dbPath}, repoPkgs...) - if err := runAndStreamOutput(ctx, "repo-add", repoAddArgs...); err != nil { - return fmt.Errorf("repo-add for %s: %w", repoArch, err) - } - - // Create conventional symlinks (vikunja.db -> vikunja.db.tar.gz) - for _, name := range []string{"vikunja.db", "vikunja.files"} { - link := filepath.Join(repoDir, name) - os.Remove(link) - if err := os.Symlink(name+".tar.gz", link); err != nil { - return fmt.Errorf("creating symlink %s: %w", name, err) - } - } - - // Sign the database - if err := runAndStreamOutput(ctx, "gpg", - "--default-key", gpgKey, - "--batch", "--yes", - "--passphrase", gpgPassphrase, - "--pinentry-mode", "loopback", - "--detach-sign", - "-o", filepath.Join(repoDir, "vikunja.db.sig"), - dbPath, - ); err != nil { - return fmt.Errorf("signing db for %s: %w", repoArch, err) - } - } - - fmt.Println("Pacman repo metadata generated in", outputBase) - return nil -} - -// PrepareNFPMConfig prepares the nfpm config -func (Release) PrepareNFPMConfig() error { - mg.Deps(initVars) - var err error - - // Because nfpm does not support templating, we replace the values in the config file and restore it after running - nfpmConfigPath := "./nfpm.yaml" - nfpmconfig, err := os.ReadFile(nfpmConfigPath) - if err != nil { - return err - } - - var nfpmArch string - switch os.Getenv("NFPM_ARCH") { - case "arm64": - nfpmArch = "arm64" - case "arm7": - nfpmArch = "arm7" - case "386": - nfpmArch = "386" - default: - nfpmArch = "amd64" - } - - fixedConfig := strings.ReplaceAll(string(nfpmconfig), "", VersionNumber) - fixedConfig = strings.ReplaceAll(fixedConfig, "", BinLocation) - fixedConfig = strings.ReplaceAll(fixedConfig, "", nfpmArch) - if err := os.WriteFile(nfpmConfigPath, []byte(fixedConfig), 0); err != nil { - return err - } - - generateConfigYAMLFromJSON(DefaultConfigYAMLSamplePath, true) - - return nil -} - -// Packages creates deb, rpm and apk packages -func (Release) Packages(ctx context.Context) error { - mg.Deps(initVars) - - var err error - binpath := os.Getenv("NFPM_BIN_PATH") - if binpath == "" { - binpath = "nfpm" - } - err = exec.CommandContext(ctx, binpath).Run() - if err != nil && strings.Contains(err.Error(), "executable file not found") { - binpath = "/usr/bin/nfpm" - err = exec.CommandContext(ctx, binpath).Run() - } - if err != nil && strings.Contains(err.Error(), "executable file not found") { - return fmt.Errorf("executable %s not found: please manually install nfpm by running the command: curl -sfL https://install.goreleaser.com/github.com/goreleaser/nfpm.sh | sh -s -- -b $(go env GOPATH)/bin", binpath) - } - - err = (Release{}).PrepareNFPMConfig() - if err != nil { - return err - } - - releasePath := "./" + DIST + "/os-packages/" - if err := os.MkdirAll(releasePath, 0o755); err != nil { - return err - } - - if err := runAndStreamOutput(ctx, binpath, "pkg", "--packager", "deb", "--target", releasePath); err != nil { - return err - } - if err := runAndStreamOutput(ctx, binpath, "pkg", "--packager", "rpm", "--target", releasePath); err != nil { - return err - } - if err := runAndStreamOutput(ctx, binpath, "pkg", "--packager", "apk", "--target", releasePath); err != nil { - return err - } - - return nil -} - type Dev mg.Namespace // MakeMigration creates a new bare db migration skeleton in pkg/migration. diff --git a/pkg/i18n/lang/ja-JP.json b/pkg/i18n/lang/ja-JP.json index c0745bade..d0fe3d7a4 100644 --- a/pkg/i18n/lang/ja-JP.json +++ b/pkg/i18n/lang/ja-JP.json @@ -173,5 +173,10 @@ "since_hours": "%[1]d 時間", "since_minutes": "%[1]d 分", "list_last_separator": ", " + }, + "feeds": { + "notifications": { + "title": "%[1]s の Vikunja 通知" + } } } \ No newline at end of file diff --git a/pkg/models/task_assignees.go b/pkg/models/task_assignees.go index c2cb20483..567341844 100644 --- a/pkg/models/task_assignees.go +++ b/pkg/models/task_assignees.go @@ -334,10 +334,12 @@ func (la *TaskAssginee) ReadAll(s *xorm.Session, a web.Auth, search string, page } numberOfTotalItems, err = s.Table("task_assignees"). - Select("users.*"). Join("INNER", "users", "task_assignees.user_id = users.id"). - Where("task_id = ? AND users.username LIKE ?", la.TaskID, "%"+search+"%"). - Count(&user.User{}) + Where(builder.And( + builder.Eq{"task_id": la.TaskID}, + db.ILIKE("users.username", search), + )). + Count(&TaskAssginee{}) return taskAssignees, len(taskAssignees), numberOfTotalItems, err } diff --git a/pkg/routes/caldav/handler.go b/pkg/routes/caldav/handler.go index 395313a5e..7338b3961 100644 --- a/pkg/routes/caldav/handler.go +++ b/pkg/routes/caldav/handler.go @@ -185,7 +185,8 @@ func getProjectFromParam(c *echo.Context) (project *models.ProjectWithTasksAndBu intParam, err := strconv.ParseInt(param, 10, 64) if err != nil { - return nil, err + // The project ID given is not an integer, it cannot exist + return nil, models.ErrProjectDoesNotExist{} } if intParam == models.FavoritesPseudoProjectID { diff --git a/pkg/webtests/caldav_test.go b/pkg/webtests/caldav_test.go index 900772d0c..2cae0128e 100644 --- a/pkg/webtests/caldav_test.go +++ b/pkg/webtests/caldav_test.go @@ -44,6 +44,12 @@ func TestCaldav(t *testing.T) { assert.Contains(t, rec.Body.String(), "END:VTODO") assert.Contains(t, rec.Body.String(), "END:VCALENDAR") }) + t.Run("Returns 404 for non-integer project param", func(t *testing.T) { + e, _ := setupTestEnv() + rec, err := newCaldavTestRequestWithUser(t, e, http.MethodGet, caldav.ProjectHandler, &testuser15, ``, nil, map[string]string{"project": "E6948AA3-86CC-40A1-874D-3B0A02FAA781"}) + require.NoError(t, err) + assert.Equal(t, http.StatusNotFound, rec.Result().StatusCode) + }) t.Run("Import VTODO", func(t *testing.T) { const vtodo = `BEGIN:VCALENDAR VERSION:2.0 diff --git a/pkg/webtests/task_test.go b/pkg/webtests/task_test.go index d067703b7..5afe7f717 100644 --- a/pkg/webtests/task_test.go +++ b/pkg/webtests/task_test.go @@ -563,3 +563,36 @@ func TestTaskDuplicate(t *testing.T) { assert.Contains(t, getHTTPErrorMessage(err), "Forbidden") }) } + +func TestTaskAssignees(t *testing.T) { + testHandler := webHandlerTest{ + user: &testuser1, + strFunc: func() handler.CObject { + return &models.TaskAssginee{} + }, + t: t, + } + + t.Run("ReadAll", func(t *testing.T) { + t.Run("With assignees", func(t *testing.T) { + rec, err := testHandler.testReadAllWithUser(nil, map[string]string{"projecttask": "30"}) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), `"id":1`) + assert.Contains(t, rec.Body.String(), `"id":2`) + }) + + t.Run("Without assignees", func(t *testing.T) { + rec, err := testHandler.testReadAllWithUser(nil, map[string]string{"projecttask": "1"}) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), `[]`) + }) + + t.Run("No permission", func(t *testing.T) { + _, err := testHandler.testReadAllWithUser(nil, map[string]string{"projecttask": "14"}) + require.Error(t, err) + assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err)) + }) + }) +} diff --git a/veans/.gitignore b/veans/.gitignore new file mode 100644 index 000000000..106e7b3fe --- /dev/null +++ b/veans/.gitignore @@ -0,0 +1,3 @@ +/veans +/veans.exe +/dist/ diff --git a/veans/.golangci.yml b/veans/.golangci.yml new file mode 100644 index 000000000..c14dab5ab --- /dev/null +++ b/veans/.golangci.yml @@ -0,0 +1,106 @@ +version: "2" +run: + tests: true + build-tags: + - mage +linters: + enable: + - asasalint + - asciicheck + - bidichk + - bodyclose + - contextcheck + - err113 + - errchkjson + - errorlint + - exhaustive + - gocheckcompilerdirectives + - gochecksumtype + - gocritic + - gocyclo + - goheader + - gosec + - gosmopolitan + - loggercheck + - makezero + - misspell + - nilerr + - nilnesserr + - noctx + - protogetter + - reassign + - recvcheck + - revive + - rowserrcheck + - testifylint + - unparam + disable: + - durationcheck + - goconst + - musttag + settings: + goheader: + template-path: code-header-template.txt + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + rules: + # Tests compose dynamic error messages and exercise edge cases — let + # them. Mirrors the parent repo's _test.go carve-outs. + - linters: + - err113 + - errorlint + - gocyclo + path: _test\.go + - linters: + - gocritic + text: 'commentFormatting: put a space between `//` and comment text' + # The veans CLI uses fmt.Errorf and output.New/Wrap intentionally — + # err113's "no dynamic errors" rule isn't a fit for user-facing CLI + # errors that are routinely templated with parameters. + - linters: + - err113 + path: ".*" + text: 'do not define dynamic errors, use wrapped static errors instead:' + # mage build tooling is internal — gosec subprocess flags don't apply. + - linters: + - err113 + - gosec + path: magefile.go + # term.ReadPassword takes int(*os.File.Fd()) — canonical Go idiom. + - linters: + - gosec + text: 'G115: integer overflow conversion uintptr -> int' + # Password / AccessToken / RefreshToken are intentional API model + # fields, mirroring the parent repo's exclusion. + - linters: + - gosec + text: 'G117:' + # veans is an HTTP CLI: G704 (SSRF) and G705 (XSS via Fprintf to a + # terminal) are categorically false positives for this codebase. + - linters: + - gosec + text: 'G70[45]:' + # E2E helpers run subprocesses with controlled inputs (git, the + # built veans binary). G204 (subprocess) and G703 (path traversal) + # don't apply to test infrastructure. + - linters: + - gosec + path: e2e/ + text: 'G(204|306|703):' + # .veans.yml + agent hook config files are committed to the repo + # and intentionally world-readable; 0o644 is correct. + - linters: + - gosec + path: internal/(config|bootstrap)/.*\.go + text: 'G306:' +formatters: + enable: + - gofmt + - goimports + exclusions: + generated: lax diff --git a/veans/AGENTS.md b/veans/AGENTS.md new file mode 100644 index 000000000..adc4d420c --- /dev/null +++ b/veans/AGENTS.md @@ -0,0 +1,208 @@ +# AGENT Instructions for veans + +Things to know before touching this submodule that aren't obvious from +reading the code. The parent repo's `CLAUDE.md` covers the rest of Vikunja; +this file is veans-specific. + +## Module layout + +- `veans/` is its own Go module (`code.vikunja.io/veans`), separate from + the parent. Don't try to import `code.vikunja.io/api/...` — that pulls + XORM into the CLI binary. Wire types live in `internal/client/types.go` + as plain JSON-tagged structs that mirror the parent models. +- License headers are enforced by `goheader` in `veans/.golangci.yml`. + Every new `.go` file needs the AGPLv3 banner from + `veans/code-header-template.txt` (a copy of the parent's, kept local + so the linter resolves the path relative to this module). + +## Building and testing + +- `mage build` → `./veans` binary. The `Aliases` map in `magefile.go` + routes bare names like `mage test` to `Test.All` — without aliases, + mage rejects namespace invocations ("Unknown target specified"). +- Unit tests: `mage test` (passes `-short`) or `go test -short ./...`. + The e2e package's `TestMain` gates the suite on `-short`, mirroring + the parent monorepo's `pkg/webtests` convention. Without `-short` + and without `VEANS_E2E_API_URL` set, the e2e tests fail loudly with + a "configure or pass -short" hint. +- E2e tests: `mage test:e2e` (no `-short`). Assumes an externally- + running Vikunja at `VEANS_E2E_API_URL`. The harness seeds its own + admin user via `PATCH /api/v1/test/users` — same mechanism the + playwright suite uses — so the API must be booted with + `VIKUNJA_SERVICE_TESTINGTOKEN=` and the same value passed in + via `VEANS_E2E_TESTING_TOKEN`. Alternative path: + `VEANS_E2E_ADMIN_TOKEN=` skips the seed and uses the given + token as-is, for driving a long-lived Vikunja the suite shouldn't + mutate user rows on. +- Local e2e loop: from the parent repo root, build the API + (`mage build:build`), run it with sqlite-memory + a known JWT + secret + `VIKUNJA_SERVICE_TESTINGTOKEN`, then `mage test:e2e` from + `veans/` with `VEANS_E2E_API_URL` + `VEANS_E2E_TESTING_TOKEN`. No + manual seeding step — the test harness handles it. +- CI: the `test-veans-e2e` job in `.github/workflows/test.yml` consumes + the existing `vikunja_bin` artifact from `api-build`; don't recompile + the API in a parallel workflow. The `veans-test` job runs unit tests + with `-short` for fast feedback, independent of `api-build`. + +## Vikunja wire-format gotchas + +Most failures surface when crossing the JSON boundary. The list below is +what's bitten me; if a new endpoint behaves oddly, suspect one of these: + +- **`ProjectView.view_kind` and `bucket_configuration_mode` are + strings**, not ints. The parent enums (`ProjectViewKind`, + `BucketConfigurationModeKind`) have custom `MarshalJSON` that emits + `"kanban"` / `"manual"` etc. Use the string constants in + `internal/client/types.go`. +- **`Task.BucketID` is always 0** in `GET /tasks/:id`. The model has + `xorm:"-"` on it — the actual bucket lives in a separate + `task_buckets` table. Fetch with `?expand=buckets` and use + `task.CurrentBucketID(viewID)` to read it. +- **`POST /tasks/{id}` does NOT move tasks between buckets.** The + task↔bucket relation is row-shaped; use `client.MoveTaskToBucket()` + which hits `POST /projects/{p}/views/{v}/buckets/{b}/tasks`. The + Update path on the server only auto-moves on `done` flips. +- **Bot user creation is `PUT /user/bots`**, not `/bots` — the routes + are registered under the `/user` subgroup. Same prefix for + `GET /user/bots`. +- **`APIToken.expires_at` is required.** The struct field has + `valid:"required"` upstream; sending it omitted or zero fails + validation. Use `client.FarFuture` (year 9999) when you mean "no + expiry" — the frontend does the same. +- **Task descriptions and comments are HTML, not markdown.** The + Vikunja web UI uses TipTap, which calls `getHTML()` on save. The + stored field is therefore HTML. The agent prompt template + (`internal/commands/prompt.tmpl`) teaches agents the canonical + TipTap shapes — most importantly `
    ` + + `
  • ` for + interactive checkboxes. We deliberately do **not** convert + markdown↔HTML in the CLI; the agent writes HTML directly, which + avoids lossy roundtrips on `--description-replace-old/new`. `veans + show` displays the raw HTML; humans skim it fine. + +## API token permissions + +- Vikunja validates token `permissions` against `apiTokenRoutes`, a map + built dynamically from registered routes. Group names are derived + from the URL path (params stripped, joined by `_`). Examples: + - `/projects/:project/views/:view/buckets/:bucket/tasks` → + group `projects`, action `views_buckets_tasks` + - `/tasks/:task/comments` → group `tasks_comments`, action `create` +- `client.PermissionsForBot()` calls `GET /routes` at runtime and + grants only the intersection of what we want and what the server + exposes. **Don't hard-code permission group names** — they drift + across Vikunja versions, and discovery keeps the bot's grant valid + across upgrades. + +## Bot ownership and token minting + +- Creating a bot via `PUT /user/bots` automatically sets the bot's + `bot_owner_id` to the calling user. Only the owner can mint tokens + for the bot via `PUT /tokens` with `owner_id=`. The init + flow does these as a single human-JWT-authenticated batch. +- Bots have no password and **cannot** authenticate via `POST /login`. + After init, `veans login` re-authenticates as the human (not the + bot) and mints a fresh bot token. + +## OAuth flow + +- Vikunja's authorization server requires PKCE/S256 and accepts either + `vikunja-…://` custom schemes or RFC 8252 loopback URIs + (`http://127.0.0.1:NNN/`, `http://localhost:NNN/`, `http://[::1]:NNN/`). + No client registration needed — `client_id` can be any consistent + string (we use `veans-cli`). +- `internal/auth/oauth.go` binds a free port on 127.0.0.1, opens the + browser, and captures the callback. The `Shutdown` defer uses + `context.WithoutCancel(ctx)` so cancellation at the outer scope + still drains the loopback server cleanly. +- Token exchange is **JSON only**. Form-encoded POSTs to `/oauth/token` + fail; the standard `golang.org/x/oauth2` client speaks form encoding, + which is why we have a hand-rolled `client.ExchangeOAuthCode`. + +## Credential store + +- Lookup chain: keychain → env (`VEANS_TOKEN`) → file + (`~/.config/veans/credentials.yml`, mode 0600, atomic-write + flock + serialization). `XDG_CONFIG_HOME` is deliberately not honored — + agent-only audience runs in a known environment, and the env var + was a path-traversal seam for no real benefit. +- `Chain.Set` falls through to the next backend on error so a missing + dbus on a CI runner doesn't block writes — the file backend is the + reliable last-resort. +- File writes go through a tmp file + `Rename`, with `Chmod 0o600` + re-asserted on the destination inode so a pre-existing wider mode + is narrowed. Concurrent writers (e.g. two `veans login` runs) are + serialized via `flock` on `.lock` (Unix only; Windows is a + no-op stub since the audience is Linux/macOS). +- E2e tests override `HOME` per test and `filterEnv(..., "VEANS_")` + strips any inherited `VEANS_TOKEN` so the developer's keyring + stays untouched. Don't bypass the credentials package in tests — + leaks between tests will surface as the wrong bot token. + +## Project identifiers and bot usernames + +- Project `Identifier` is `runelength(0|10)`, can be empty. When empty, + `Config.FormatTaskID` renders `#NN`; otherwise `PROJ-NN`. Both are + accepted by `runtime.resolveTaskID` along with bare integers. +- Bot username must start with `bot-`; the server enforces it. Hyphens, + digits, lowercase letters allowed; no spaces, no commas, no + `link-share-N` pattern. `config.SuggestedBotUsername` does the + folding for repo names. +- E2e tests deriving identifiers from a unique suffix should use the + trailing chars of `strconv.FormatInt(time.Now().UnixNano(), 36)`. + The leading chars barely change between consecutive runs and will + collide if you take `[:N]`. + +## Audience split + +The CLI is agent-only at runtime; humans never use it for day-to-day +work (they use Vikunja's web UI). Two commands serve a human running +one-off setup: + +- **`init`** — bootstrap a repo: pick project + view, create bot, + share, mint token, write `.veans.yml`, install hooks. +- **`login`** — rotate the bot's token. + +Everything else (`list`, `show`, `create`, `update`, `claim`, `api`, +`prime`, `version`) is **agent-only**: + +- **Emits JSON on stdout unconditionally.** No `--json` flag, no + human-formatted variant. `list` is a raw array; `show` / `create` / + `update` / `claim` return a single task object. +- **Errors are JSON on stderr** with non-zero exit — same envelope + everywhere (`{"code": "...", "error": "..."}`), regardless of which + command ran. Stable codes in `internal/output/errors.go`: + `NOT_FOUND`, `CONFLICT`, `VALIDATION_ERROR`, `AUTH_ERROR`, + `RATE_LIMITED`, `BOT_USERS_UNAVAILABLE`, `NOT_CONFIGURED`, + `UNKNOWN`. Don't add ad-hoc strings — wrap with `output.New` / + `output.Wrap`. +- **No `globals.JSON`, no dual rendering paths.** If you find yourself + reaching for "if interactive, do X" on an agent-facing command, + stop — it's not interactive, an agent is on the other end. + +## Cobra surface conventions + +- `RunE` handlers that don't use `args []string` should rename it to + `_` to satisfy revive's `unused-parameter` rule. +- The bucket-move dance (`MoveTaskToBucket`) runs **after** the field + update on `update`, so a status transition can't clobber freshly + attached labels. Comments for `--status scrapped` post **before** + the bucket move so the audit trail reads in chronological order. +- Agent-facing commands return the task via `json.NewEncoder(...).Encode(task)`. + Adding new top-level keys to `client.Task` is an implicit API + change — bump `prime`'s "useful fields" note alongside. + +## Things to *not* do + +- **Don't add an `os/exec.Command`** without ctx — `noctx` is enabled. + Use `exec.CommandContext(ctx, …)` and thread the context through. +- **Don't commit the built binary.** `veans/.gitignore` covers + `./veans` and `./veans.exe`. +- **Don't write to stdout from `prime` when no `.veans.yml` is found.** + The hook contract is silent + exit 0 so the snippet is safe to install + globally in `~/.claude/settings.json`. +- **Don't rename canonical bucket titles** without updating + `BucketTitleAliases[s][0]` in `internal/status/status.go`, the + prompt template (`internal/commands/prompt.tmpl`), and the e2e + assertions in lockstep — agents and humans both treat them as + fixed strings. diff --git a/veans/CLAUDE.md b/veans/CLAUDE.md new file mode 120000 index 000000000..47dc3e3d8 --- /dev/null +++ b/veans/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/veans/README.md b/veans/README.md new file mode 100644 index 000000000..7c8d1e3fa --- /dev/null +++ b/veans/README.md @@ -0,0 +1,180 @@ +# veans + +A beans-shaped CLI for Vikunja. Drop it into a repo, run `veans init`, paste a +hook snippet into your coding agent's settings, and the agent immediately +knows to track its work in Vikunja instead of in `TodoWrite` or `.beans/`. + +veans is a thin Go binary that wraps Vikunja's REST API with an opinionated +agent-friendly surface and emits a system prompt teaching agents the workflow +(claim → work → in-review → human closes). The agent prompt is re-emitted on +every `SessionStart` and `PreCompact`, so context never goes stale. + +## Quick start + +```sh +# 1. Build (or download) the binary +cd veans && mage build && sudo install ./veans /usr/local/bin/ + +# 2. In a repo with a Vikunja instance reachable +veans init --server https://vikunja.example.com + +# 3. Wire it into Claude Code (.claude/settings.json): +{ + "hooks": { + "SessionStart": [{ "hooks": [{ "type": "command", "command": "veans prime" }] }], + "PreCompact": [{ "hooks": [{ "type": "command", "command": "veans prime" }] }] + } +} + +# OpenCode (.opencode/plugin/veans-prime.ts): +export const VeansPrime = { + event: ["session.start", "compact.before"], + handler: async ({ exec }) => exec("veans prime"), +} +``` + +`veans prime` exits silently with status 0 when no `.veans.yml` is reachable +upward from cwd, so the hook is safe to install in a global `~/.claude/` +without breaking sessions in unrelated repos. + +## What `veans init` does + +1. Authenticates as you. Default is OAuth 2.0 Authorization Code + PKCE + against Vikunja's built-in authorization server (Vikunja 2.3+ — no + client registration needed). veans prints an authorize URL; you open + it in your browser, sign in, and paste the resulting + `vikunja-veans-cli://callback?code=...` URL back into the CLI. The + browser will fail to open the custom scheme — that's expected; the + address bar still has what we need. + + Alternative auth modes: + - `--token ` — paste-in, useful for SSO/OIDC + - `--use-password` — fall back to `POST /login` (local accounts only) + - `--username` + `--password` (non-interactive; implies `--use-password`) +2. Asks you to pick a project and a Kanban view. +3. Bootstraps the canonical buckets if missing: `Todo`, `In Progress`, + `In Review`, `Done`, `Scrapped`. +4. Creates a `bot-` user (Vikunja bot user — no password, no + email, can't log in interactively). +5. Shares the project with the bot at read+write. +6. Mints a long-lived API token for the bot via `PUT /tokens` with + `owner_id`, scoped to the discovered route groups (tasks, comments, + labels, relations, assignees, etc.) the server actually exposes. +7. Stores the token in your OS keychain (or + `~/.config/veans/credentials.yml` if no keychain is available). +8. Writes `.veans.yml` to the repo root. + +The token stored is the bot's, not yours. The human's transient session is +discarded as soon as init finishes — rotate or revoke the bot independently +without affecting your own session. + +## Commands + +``` +veans init OAuth/login → create bot → mint token → write .veans.yml +veans prime emit system prompt for agents (silent if no .veans.yml) +veans list filtered list (--ready, --mine, --branch, --filter, --status); emits JSON +veans show view a task (JSON) +veans create "title" --description, --label, --status, --priority, --parent, --blocked-by +veans update --status, --title, --priority, --label-add/remove, + --description, --description-replace-old/new, --description-append, + --comment, --reason, --if-unchanged-since +veans claim assign the bot, move to In Progress, tag with current branch label +veans api METHOD PATH raw REST passthrough — escape hatch for endpoints not wrapped here +veans login re-mint the bot's token (rotation) +veans version +``` + +Task IDs accept `PROJ-NN` (when the project has an identifier), `#NN` +(when it doesn't), or a bare integer. + +## `.veans.yml` + +Committed to the repo root. The numeric IDs are the source of truth; cached +identifiers and bot username are for human-readable output. + +```yaml +server: https://vikunja.example.com +project_id: 42 +project_identifier: PROJ # may be "" — task IDs render as #NN then +view_id: 7 +buckets: + todo: 11 + in_progress: 12 + in_review: 13 + done: 14 + scrapped: 15 +bot: + username: bot-myrepo + user_id: 99 +``` + +## Credentials + +Resolved in order on every command: + +1. **OS keychain** (macOS Keychain, Windows Credential Manager, + libsecret/gnome-keyring on Linux), via `github.com/zalando/go-keyring`. +2. **`VEANS_TOKEN`** env var (read-only). Optionally pin to a server with + `VEANS_SERVER`. Intended for CI / containers. +3. **`~/.config/veans/credentials.yml`** (mode 0600) — automatic fallback + when the keychain is unavailable. Honors `XDG_CONFIG_HOME`. + +## Mage targets + +``` +mage build # go build -o ./veans ./cmd/veans +mage test # unit tests across the module +mage test:filter EXPR # go test -run EXPR ./... +mage test:e2e # e2e suite (needs VEANS_E2E_API_URL) +mage lint / lint:fix # golangci-lint +mage fmt # go fmt ./... +mage clean # remove built binary +``` + +## End-to-end tests + +The suite in `e2e/` assumes a running Vikunja API. Locally, point it at any +dev instance: + +```sh +export VEANS_E2E_API_URL=http://localhost:3456 +export VEANS_E2E_ADMIN_USER=user1 +export VEANS_E2E_ADMIN_PASS=12345678 # canonical fixture password +mage test:e2e +``` + +CI spins Vikunja up the same way the frontend Playwright suite does — see +`.github/workflows/veans-e2e.yml`. The workflow builds the parent API +binary, starts it with `VIKUNJA_DATABASE_TYPE=sqlite`, +`VIKUNJA_DATABASE_PATH=memory`, fixtures from `pkg/db/fixtures/`, and runs +`mage test:e2e` from this directory. + +E2E tests never touch the developer's keychain — they override `HOME` and +`XDG_CONFIG_HOME` per test, which forces the credential store to fall +through to its file backend. + +## Status model + +| Status | Bucket name | Done flag | Who moves there? | +| ------------- | -------------- | --------- | ---------------------------------------- | +| `todo` | Todo | false | created here by default | +| `in-progress` | In Progress | false | `veans claim` / `update -s in-progress` | +| `in-review` | In Review | false | the agent, when work is finished | +| `completed` | Done | true | humans / merge hook only | +| `scrapped` | Scrapped | true | the agent, with `--reason` | + +The agent never moves tasks to `completed` itself — it parks them in +`In Review` and a human (or the future merge hook) closes them once the +PR lands. + +## Out of scope (for now) + +- OAuth 2.0 device flow (RFC 8628) — would let SSH'd / headless setups + authenticate without a browser-on-the-same-machine; not implemented + upstream yet. +- Project-scoped API tokens — Vikunja doesn't ship them yet. The + credential schema's `scope` field is forward-compatible for when it does. +- Auto-installing hook snippets. We print them; you paste them. +- Merge-hook GitHub Action that auto-closes tasks on PR merge — separate + repo, future work. diff --git a/veans/cmd/veans/main.go b/veans/cmd/veans/main.go new file mode 100644 index 000000000..dfff29e0f --- /dev/null +++ b/veans/cmd/veans/main.go @@ -0,0 +1,35 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// veans — a beans-shaped CLI for Vikunja. +package main + +import ( + "fmt" + "os" + "runtime" + + "code.vikunja.io/veans/internal/client" + "code.vikunja.io/veans/internal/commands" +) + +// version is overwritten via -ldflags at release time. +var version = "dev" + +func main() { + client.UserAgent = fmt.Sprintf("veans/%s (%s/%s)", version, runtime.GOOS, runtime.GOARCH) + os.Exit(commands.Execute(version)) +} diff --git a/veans/code-header-template.txt b/veans/code-header-template.txt new file mode 100644 index 000000000..556542639 --- /dev/null +++ b/veans/code-header-template.txt @@ -0,0 +1,15 @@ +Vikunja is a to-do list application to facilitate your life. +Copyright 2018-present Vikunja and contributors. All rights reserved. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . diff --git a/veans/e2e/claim_test.go b/veans/e2e/claim_test.go new file mode 100644 index 000000000..5056056f9 --- /dev/null +++ b/veans/e2e/claim_test.go @@ -0,0 +1,77 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package e2e + +import ( + "encoding/json" + "fmt" + "testing" + + "code.vikunja.io/veans/internal/client" +) + +// TestClaim_AssignsBotMovesToInProgressTagsBranch exercises the full claim +// flow: assignment, bucket transition, and branch label application. +func TestClaim_AssignsBotMovesToInProgressTagsBranch(t *testing.T) { + ws, h := provisionWorkspace(t) + + out, _, code := h.Run(t, ws, "create", "claim me") + if code != 0 { + t.Fatalf("create exit %d\n%s", code, out) + } + var created client.Task + if err := json.Unmarshal([]byte(out), &created); err != nil { + t.Fatal(err) + } + id := fmt.Sprintf("%d", created.Index) + + // Switch the workspace's git branch so claim has something to label with. + gitInWorkspace(t, ws, "checkout", "-q", "-b", "feat-claim-test") + + _, errOut, code := h.Run(t, ws, "claim", id) + if code != 0 { + t.Fatalf("claim exit %d\n%s", code, errOut) + } + + server := h.GetTask(t, created.ID) + + // Verify bucket transition by reading the workspace's .veans.yml — the + // bot's expected In Progress bucket is stored there. + cfg := loadConfig(t, ws) + bucket := server.CurrentBucketID(cfg.ViewID) + if bucket != cfg.Buckets.InProgress { + t.Fatalf("task not in In Progress bucket: got %d, want %d", bucket, cfg.Buckets.InProgress) + } + + // Bot assigned. + assigned := false + for _, a := range server.Assignees { + if a != nil && a.ID == cfg.Bot.UserID { + assigned = true + break + } + } + if !assigned { + t.Fatalf("bot %d not in assignees: %+v", cfg.Bot.UserID, server.Assignees) + } + + // Branch label attached. + branchLabel := "veans:branch:feat-claim-test" + if !taskHasLabelTitle(server, branchLabel) { + t.Fatalf("expected label %q on task; got %+v", branchLabel, server.Labels) + } +} diff --git a/veans/e2e/helpers.go b/veans/e2e/helpers.go new file mode 100644 index 000000000..2baa88952 --- /dev/null +++ b/veans/e2e/helpers.go @@ -0,0 +1,326 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Package e2e is the integration suite for veans. It assumes a running +// Vikunja API at VEANS_E2E_API_URL with VIKUNJA_SERVICE_TESTINGTOKEN set +// (passed in via VEANS_E2E_TESTING_TOKEN) so the suite can seed its own +// admin via PATCH /api/v1/test/users — the same `/test/{table}` endpoint +// the frontend playwright suite uses. +// +// The alternative path — VEANS_E2E_ADMIN_TOKEN — is a JWT against a +// long-lived Vikunja the user wants to drive without touching its data; +// in that mode the suite skips the seed. +// +// The suite never provisions Vikunja itself. +package e2e + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "code.vikunja.io/veans/internal/client" +) + +// Hard-coded seed credentials. The hash is the bcrypt of "1234" and +// matches frontend/tests/support/constants.ts so the whole e2e infra +// shares one well-known password — tests themselves never need to read +// these from env. +const ( + seedAdminUsername = "e2eadmin" + seedAdminPassword = "1234" + seedAdminBcrypt = "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To." //nolint:gosec // G101: deterministic test fixture, not a credential +) + +// Harness bundles a built veans binary and an authenticated admin client +// for verifying side effects on the server. +type Harness struct { + Binary string + APIURL string + AdminToken string + AdminClient *client.Client +} + +// New builds (or reuses) the veans binary, seeds the admin user via +// PATCH /api/v1/test/users (using VEANS_E2E_TESTING_TOKEN), logs in as +// that admin, and returns a Harness ready to drive tests. +// +// If VEANS_E2E_ADMIN_TOKEN is set, the seed is skipped and that token +// is used directly — useful for running against a long-lived Vikunja +// the caller doesn't want this suite to mutate user rows on. +// +// Tests rely on the `-short` skip in TestMain to opt out when a Vikunja +// instance isn't available; if `-short` is *not* set and env is missing, +// we fail loudly with a "configure or pass -short" hint. +func New(t *testing.T) *Harness { + t.Helper() + + apiURL := strings.TrimRight(os.Getenv("VEANS_E2E_API_URL"), "/") + if apiURL == "" { + t.Fatal("VEANS_E2E_API_URL is not set — point it at a Vikunja instance, or pass -short to skip the e2e suite") + } + binary, err := buildOrLocate() + if err != nil { + t.Fatalf("locate veans binary: %v", err) + } + + tok := os.Getenv("VEANS_E2E_ADMIN_TOKEN") + if tok == "" { + testingToken := os.Getenv("VEANS_E2E_TESTING_TOKEN") + if testingToken == "" { + t.Fatal("set VEANS_E2E_ADMIN_TOKEN, or VEANS_E2E_TESTING_TOKEN (matching the API's VIKUNJA_SERVICE_TESTINGTOKEN) so the suite can seed its own admin") + } + seedAdmin(t, apiURL, testingToken) + c := client.New(apiURL, "") + resp, err := c.Login(t.Context(), &client.LoginRequest{ + Username: seedAdminUsername, + Password: seedAdminPassword, + LongToken: true, + }) + if err != nil { + t.Fatalf("admin login: %v", err) + } + tok = resp.Token + } + + return &Harness{ + Binary: binary, + APIURL: apiURL, + AdminToken: tok, + AdminClient: client.New(apiURL, tok), + } +} + +// seedAdmin PATCHes a single admin user row into the users table via +// the testing endpoint. truncate=true wipes any prior users from +// previous tests so each New(t) starts from a known state. +func seedAdmin(t *testing.T, apiURL, testingToken string) { + t.Helper() + now := time.Now().UTC().Format(time.RFC3339) + body, err := json.Marshal([]map[string]any{{ + "id": 1, + "username": seedAdminUsername, + "password": seedAdminBcrypt, + "email": "e2e@example.com", + "status": 0, + "issuer": "local", + "language": "en", + "created": now, + "updated": now, + }}) + if err != nil { + t.Fatalf("marshal seed payload: %v", err) + } + + req, err := http.NewRequestWithContext(t.Context(), http.MethodPatch, + apiURL+"/api/v1/test/users?truncate=true", bytes.NewReader(body)) + if err != nil { + t.Fatalf("build seed request: %v", err) + } + req.Header.Set("Authorization", testingToken) + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("seed admin: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + buf, _ := io.ReadAll(resp.Body) + t.Fatalf("seed admin: HTTP %d: %s", resp.StatusCode, string(buf)) + } +} + +// Workspace creates a per-test git repo in a TempDir with HOME pointed at +// a TempDir so the credential store writes under the test's own directory +// rather than touching the developer's keychain. +type Workspace struct { + Dir string + Home string + ConfigPath string + BotUsername string + envOverrides map[string]string +} + +// NewWorkspace initializes a fresh repo with `git init` + a single commit so +// `git rev-parse --abbrev-ref HEAD` returns a real branch name. +func (h *Harness) NewWorkspace(t *testing.T) *Workspace { + t.Helper() + dir := t.TempDir() + home := t.TempDir() + + for _, c := range [][]string{ + {"git", "init", "-q", "-b", "main"}, + {"git", "config", "user.email", "veans-e2e@example.com"}, + {"git", "config", "user.name", "veans-e2e"}, + // Disable any inherited commit signing; the test commit doesn't + // need provenance and signing brokers can fail in dev containers. + {"git", "config", "commit.gpgsign", "false"}, + {"git", "config", "tag.gpgsign", "false"}, + } { + cmd := exec.CommandContext(t.Context(), c[0], c[1:]...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("%s: %v\n%s", strings.Join(c, " "), err, out) + } + } + if err := os.WriteFile(filepath.Join(dir, "README"), []byte("test"), 0o644); err != nil { + t.Fatal(err) + } + for _, c := range [][]string{ + {"git", "add", "."}, + {"git", "commit", "-q", "-m", "initial"}, + } { + cmd := exec.CommandContext(t.Context(), c[0], c[1:]...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("%s: %v\n%s", strings.Join(c, " "), err, out) + } + } + + return &Workspace{ + Dir: dir, + Home: home, + ConfigPath: filepath.Join(dir, ".veans.yml"), + envOverrides: map[string]string{ + "HOME": home, + }, + } +} + +// Run executes the veans binary against this workspace, returning stdout, +// stderr, and exit code. +func (h *Harness) Run(t *testing.T, ws *Workspace, args ...string) (stdout, stderr string, exitCode int) { + t.Helper() + cmd := exec.CommandContext(t.Context(), h.Binary, args...) + cmd.Dir = ws.Dir + // Filter VEANS_* out of the inherited env before applying our + // overrides — a developer's VEANS_TOKEN would otherwise mask the + // per-test bot token via the env backend. + cmd.Env = append(filterEnv(os.Environ(), "VEANS_"), envSlice(ws.envOverrides)...) + var so, se bytes.Buffer + cmd.Stdout = &so + cmd.Stderr = &se + err := cmd.Run() + if err != nil { + var ee *exec.ExitError + if errors.As(err, &ee) { + return so.String(), se.String(), ee.ExitCode() + } + t.Fatalf("run veans %v: %v", args, err) + } + return so.String(), se.String(), 0 +} + +// CreateProject creates a fresh project owned by the admin user and returns +// it. Tests use a unique title to keep results isolated across parallel runs. +func (h *Harness) CreateProject(t *testing.T, title, identifier string) *client.Project { + t.Helper() + out, err := h.AdminClient.CreateProject(t.Context(), + &client.Project{Title: title, Identifier: identifier}) + if err != nil { + t.Fatalf("create project %q: %v", title, err) + } + return out +} + +// FindKanbanView returns the first Kanban view of the project (Vikunja +// auto-creates one). +func (h *Harness) FindKanbanView(t *testing.T, projectID int64) *client.ProjectView { + t.Helper() + views, err := h.AdminClient.ListProjectViews(t.Context(), projectID) + if err != nil { + t.Fatalf("list views: %v", err) + } + for _, v := range views { + if v.ViewKind == client.ViewKindKanban { + return v + } + } + t.Fatalf("no Kanban view on project %d", projectID) + return nil +} + +// GetTask fetches a task by ID for verification. +func (h *Harness) GetTask(t *testing.T, id int64) *client.Task { + t.Helper() + task, err := h.AdminClient.GetTask(t.Context(), id) + if err != nil { + t.Fatalf("get task %d: %v", id, err) + } + return task +} + +func buildOrLocate() (string, error) { + if env := os.Getenv("VEANS_BINARY"); env != "" { + if abs, err := filepath.Abs(env); err == nil { + if _, err := os.Stat(abs); err == nil { + return abs, nil + } + } + } + tmp, err := os.MkdirTemp("", "veans-bin-*") + if err != nil { + return "", err + } + bin := filepath.Join(tmp, "veans") + if runtime.GOOS == "windows" { + bin += ".exe" + } + cmd := exec.CommandContext(context.Background(), "go", "build", "-o", bin, "./cmd/veans") + cmd.Dir = repoRoot() + out, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("build veans: %w (output: %s)", err, out) + } + return bin, nil +} + +func repoRoot() string { + // e2e/helpers.go lives at /veans/e2e/helpers.go, so go up two. + _, file, _, _ := runtime.Caller(0) + return filepath.Clean(filepath.Join(filepath.Dir(file), "..")) +} + +func envSlice(overrides map[string]string) []string { + out := make([]string, 0, len(overrides)) + for k, v := range overrides { + out = append(out, k+"="+v) + } + return out +} + +// filterEnv returns env entries whose keys do NOT start with prefix. +func filterEnv(env []string, prefix string) []string { + out := make([]string, 0, len(env)) + for _, kv := range env { + if !strings.HasPrefix(kv, prefix) { + out = append(out, kv) + } + } + return out +} diff --git a/veans/e2e/init_test.go b/veans/e2e/init_test.go new file mode 100644 index 000000000..cc432d99a --- /dev/null +++ b/veans/e2e/init_test.go @@ -0,0 +1,174 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package e2e + +import ( + "fmt" + "strconv" + "strings" + "testing" + "time" + + "code.vikunja.io/veans/internal/config" + "code.vikunja.io/veans/internal/credentials" +) + +// TestInit_HappyPath exercises the full bootstrap: pick project + view, +// create canonical buckets, create the bot user, share the project, mint +// the bot's token, and write .veans.yml. Verifies side effects via the +// admin client. +func TestInit_HappyPath(t *testing.T) { + h := New(t) + + // Use a unique title and identifier per run so parallel jobs don't + // collide on the bot username. + suffix := uniqueSuffix() + project := h.CreateProject(t, "veans-e2e-"+suffix, identifier(suffix)) + view := h.FindKanbanView(t, project.ID) + + ws := h.NewWorkspace(t) + // Rename the workspace dir so the auto-generated bot username is unique. + ws.BotUsername = "bot-veans-e2e-" + suffix + + stdout, stderr, code := h.Run(t, ws, + "init", + "--server", h.APIURL, + "--token", h.AdminToken, + "--project", fmt.Sprintf("%d", project.ID), + "--view", fmt.Sprintf("%d", view.ID), + "--bot-username", ws.BotUsername, + "--yes-buckets", + ) + if code != 0 { + t.Fatalf("init exit %d\nstdout:\n%s\nstderr:\n%s", code, stdout, stderr) + } + + // Config written? + cfg, err := config.Load(ws.ConfigPath) + if err != nil { + t.Fatalf("load .veans.yml: %v", err) + } + if cfg.ProjectID != project.ID || cfg.ViewID != view.ID { + t.Fatalf("unexpected ids in config: %+v", cfg) + } + if cfg.Bot.Username != ws.BotUsername { + t.Fatalf("bot username = %q, want %q", cfg.Bot.Username, ws.BotUsername) + } + if cfg.Buckets.Todo == 0 || cfg.Buckets.InProgress == 0 || cfg.Buckets.InReview == 0 || cfg.Buckets.Done == 0 || cfg.Buckets.Scrapped == 0 { + t.Fatalf("buckets not fully populated: %+v", cfg.Buckets) + } + + // Bot token persisted in the file backend (since HOME points at a + // fresh tmpdir, the file backend takes over from the missing keyring). + store := credentials.NewFileBackend(ws.Home + "/.config/veans/credentials.yml") + tok, err := store.Get(h.APIURL, ws.BotUsername) + if err != nil { + t.Fatalf("token not persisted: %v", err) + } + if !strings.HasPrefix(tok, "tk_") { + t.Fatalf("bot token doesn't look like a Vikunja API token: %q", tok) + } + + // Bot exists on the server with the right username. + bots, err := h.AdminClient.ListBotUsers(t.Context()) + if err != nil { + t.Fatalf("list bots: %v", err) + } + found := false + for _, b := range bots { + if b.Username == ws.BotUsername { + found = true + if b.ID != cfg.Bot.UserID { + t.Fatalf("bot user_id mismatch: server=%d cfg=%d", b.ID, cfg.Bot.UserID) + } + break + } + } + if !found { + t.Fatalf("bot %q not found on server", ws.BotUsername) + } + + // Project shared with the bot at write permission. + var shares []map[string]any + _ = h.AdminClient.Do(t.Context(), "GET", fmt.Sprintf("/projects/%d/users", project.ID), nil, nil, &shares) + shareFound := false + for _, s := range shares { + if u, _ := s["username"].(string); u == ws.BotUsername { + if p, _ := s["permission"].(float64); int(p) >= 1 { + shareFound = true + break + } + } + } + if !shareFound { + t.Fatalf("project not shared with bot at write permission: %v", shares) + } +} + +func TestInit_NoIdentifierFallsBackToHashNN(t *testing.T) { + h := New(t) + + suffix := uniqueSuffix() + project := h.CreateProject(t, "veans-e2e-noid-"+suffix, "") + view := h.FindKanbanView(t, project.ID) + + ws := h.NewWorkspace(t) + ws.BotUsername = "bot-veans-noid-" + suffix + + _, stderr, code := h.Run(t, ws, + "init", + "--server", h.APIURL, + "--token", h.AdminToken, + "--project", fmt.Sprintf("%d", project.ID), + "--view", fmt.Sprintf("%d", view.ID), + "--bot-username", ws.BotUsername, + "--yes-buckets", + ) + if code != 0 { + t.Fatalf("init exit %d\n%s", code, stderr) + } + + cfg, err := config.Load(ws.ConfigPath) + if err != nil { + t.Fatal(err) + } + if cfg.ProjectIdentifier != "" { + t.Fatalf("expected empty identifier, got %q", cfg.ProjectIdentifier) + } + if got := cfg.FormatTaskID(7); got != "#7" { + t.Fatalf("expected #7, got %q", got) + } +} + +// uniqueSuffix returns a short slug derived from the current nanosecond +// timestamp, base-36-encoded so every character is alphanumeric. Tests +// also use this slug as a project identifier, which Vikunja caps at 10 +// chars, so the encoding has to be compact and free of separators. +func uniqueSuffix() string { + return strconv.FormatInt(time.Now().UnixNano(), 36) +} + +// identifier returns a stable 10-char-or-fewer slug for use as a Vikunja +// project identifier. The base-36 timestamp's most-significant chars +// barely change across consecutive runs, so we use the trailing chars +// (which carry the nanosecond entropy) and uppercase them. +func identifier(suffix string) string { + if len(suffix) > 8 { + suffix = suffix[len(suffix)-8:] + } + return strings.ToUpper(suffix) +} diff --git a/veans/e2e/main_test.go b/veans/e2e/main_test.go new file mode 100644 index 000000000..072665376 --- /dev/null +++ b/veans/e2e/main_test.go @@ -0,0 +1,36 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package e2e + +import ( + "flag" + "os" + "testing" +) + +// TestMain follows the parent monorepo's `pkg/webtests` convention: +// `-short` skips the whole package so a plain `go test ./...` from the +// repo root (or `mage test`) doesn't try to drive a live Vikunja. Run +// `mage test:e2e` to execute it. +func TestMain(m *testing.M) { + flag.Parse() + if testing.Short() { + println("-short requested, skipping veans e2e tests") + return + } + os.Exit(m.Run()) +} diff --git a/veans/e2e/prime_test.go b/veans/e2e/prime_test.go new file mode 100644 index 000000000..8b51679ad --- /dev/null +++ b/veans/e2e/prime_test.go @@ -0,0 +1,68 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package e2e + +import ( + "strings" + "testing" +) + +// TestPrime_RendersWithProjectAndBot pins the literal anchors hooks depend +// on. Mirrors plan e2e test 12. +func TestPrime_RendersWithProjectAndBot(t *testing.T) { + ws, h := provisionWorkspace(t) + cfg := loadConfig(t, ws) + + out, _, code := h.Run(t, ws, "prime") + if code != 0 { + t.Fatalf("prime exit %d", code) + } + + mustContain := []string{ + "", + cfg.Bot.Username, + "Refs:", + "veans claim", + "Todo", + "In Progress", + "In Review", + "Done", + "Scrapped", + } + for _, s := range mustContain { + if !strings.Contains(out, s) { + t.Errorf("prime output missing %q", s) + } + } +} + +// TestPrime_SilentOutsideWorkspace verifies the safe-globally-installed +// hook contract: no .veans.yml ⇒ silent + exit 0. +func TestPrime_SilentOutsideWorkspace(t *testing.T) { + h := New(t) + + // A workspace with no .veans.yml — just a temp dir. + ws := h.NewWorkspace(t) + + stdout, stderr, code := h.Run(t, ws, "prime") + if code != 0 { + t.Fatalf("prime exit %d (expected 0)\n%s\n%s", code, stdout, stderr) + } + if stdout != "" { + t.Fatalf("expected silent stdout outside workspace, got: %s", stdout) + } +} diff --git a/veans/e2e/shared_test.go b/veans/e2e/shared_test.go new file mode 100644 index 000000000..dba95a3a0 --- /dev/null +++ b/veans/e2e/shared_test.go @@ -0,0 +1,74 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package e2e + +import ( + "os/exec" + "strconv" + "strings" + "testing" + + "code.vikunja.io/veans/internal/config" +) + +// provisionWorkspace runs `veans init` against a fresh project and returns +// the workspace + harness primed for command-level e2e tests. Each test that +// needs a working .veans.yml calls this at the top. +func provisionWorkspace(t *testing.T) (*Workspace, *Harness) { + t.Helper() + h := New(t) + suffix := uniqueSuffix() + project := h.CreateProject(t, "veans-e2e-"+suffix, identifier(suffix)) + view := h.FindKanbanView(t, project.ID) + + ws := h.NewWorkspace(t) + ws.BotUsername = "bot-veans-e2e-" + suffix + + _, stderr, code := h.Run(t, ws, + "init", + "--server", h.APIURL, + "--token", h.AdminToken, + "--project", strconv.FormatInt(project.ID, 10), + "--view", strconv.FormatInt(view.ID, 10), + "--bot-username", ws.BotUsername, + "--yes-buckets", + ) + if code != 0 { + t.Fatalf("provision init failed: %s", stderr) + } + return ws, h +} + +// loadConfig reads .veans.yml out of a workspace. +func loadConfig(t *testing.T, ws *Workspace) *config.Config { + t.Helper() + c, err := config.Load(ws.ConfigPath) + if err != nil { + t.Fatal(err) + } + return c +} + +// gitInWorkspace runs git inside the workspace and fails the test on error. +func gitInWorkspace(t *testing.T, ws *Workspace, args ...string) { + t.Helper() + cmd := exec.CommandContext(t.Context(), "git", args...) + cmd.Dir = ws.Dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out) + } +} diff --git a/veans/e2e/tasks_test.go b/veans/e2e/tasks_test.go new file mode 100644 index 000000000..4e2dee52b --- /dev/null +++ b/veans/e2e/tasks_test.go @@ -0,0 +1,161 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package e2e + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + + "code.vikunja.io/veans/internal/client" +) + +// TestCreateShowList_RoundTrip verifies the read+write path against a real +// Vikunja: provision a workspace via init, create a task, show it, list it +// (with --filter), and confirm the JSON shapes are unwrapped raw object/array. +func TestCreateShowList_RoundTrip(t *testing.T) { + ws, h := provisionWorkspace(t) + + // Create a task with a description and a label. + out, errOut, code := h.Run(t, ws, + "create", "Test bug fix", + "-d", "## Repro\n- [ ] step 1\n- [ ] step 2", + "--label", "bug", + "--priority", "3", + ) + if code != 0 { + t.Fatalf("create exit %d\n%s\n%s", code, out, errOut) + } + var created client.Task + if err := json.Unmarshal([]byte(out), &created); err != nil { + t.Fatalf("decode create: %v\n%s", err, out) + } + if created.Title != "Test bug fix" { + t.Fatalf("created title = %q", created.Title) + } + if created.Priority != 3 { + t.Fatalf("priority = %d", created.Priority) + } + if !taskHasLabelTitle(&created, "veans:bug") { + t.Fatalf("expected veans:bug label on created task; got %+v", created.Labels) + } + + // Show with --json — should be a raw object, not enveloped. + id := fmt.Sprintf("%d", created.Index) + showOut, _, code := h.Run(t, ws, "show", id) + if code != 0 { + t.Fatalf("show exit %d", code) + } + var shown client.Task + if err := json.Unmarshal([]byte(showOut), &shown); err != nil { + t.Fatalf("decode show: %v\n%s", err, showOut) + } + if shown.ID != created.ID { + t.Fatalf("show returned wrong task: %d vs %d", shown.ID, created.ID) + } + + // List with --json — should be a raw array. + listOut, _, code := h.Run(t, ws, "list") + if code != 0 { + t.Fatalf("list exit %d", code) + } + var listed []*client.Task + if err := json.Unmarshal([]byte(listOut), &listed); err != nil { + t.Fatalf("decode list: %v\n%s", err, listOut) + } + if len(listed) == 0 { + t.Fatalf("list returned empty array; expected at least our created task") + } + + // --filter passthrough: only items with priority > 2. + filterOut, _, code := h.Run(t, ws, "list", "--filter", "priority > 2") + if code != 0 { + t.Fatalf("list --filter exit %d\n%s", code, filterOut) + } + var filtered []*client.Task + if err := json.Unmarshal([]byte(filterOut), &filtered); err != nil { + t.Fatalf("decode filter list: %v", err) + } + for _, ft := range filtered { + if ft.Priority <= 2 { + t.Fatalf("filter leaked priority=%d task into result", ft.Priority) + } + } + + // Empty result set must encode as `[]`, not `null` — JSON-parsing agents + // can't reliably branch on the latter. Use a filter that matches nothing. + emptyOut, _, code := h.Run(t, ws, "list", "--filter", "priority > 10") + if code != 0 { + t.Fatalf("list (empty) exit %d\n%s", code, emptyOut) + } + if got := strings.TrimSpace(emptyOut); got != "[]" { + t.Fatalf("empty list should print []; got %q", got) + } +} + +// TestUpdate_DescriptionReplaceUniqueness pins the agent-friendly Edit-tool +// behavior: the "old" string must match exactly once, otherwise the update +// errors and nothing changes. +func TestUpdate_DescriptionReplaceUniqueness(t *testing.T) { + ws, h := provisionWorkspace(t) + + out, errOut, code := h.Run(t, ws, "create", "checkbox task", + "-d", "- [ ] step 1\n- [ ] step 1 (again)", + ) + if code != 0 { + t.Fatalf("create exit %d\n%s\n%s", code, out, errOut) + } + var created client.Task + if err := json.Unmarshal([]byte(out), &created); err != nil { + t.Fatal(err) + } + id := fmt.Sprintf("%d", created.Index) + + // Non-unique replace should fail with a validation error and a + // non-zero exit code. + _, stderr, code := h.Run(t, ws, + "update", id, + "--description-replace-old", "step 1", + "--description-replace-new", "step 1 [x]", + ) + if code == 0 { + t.Fatalf("expected non-zero exit on non-unique replace") + } + if !strings.Contains(stderr, "VALIDATION_ERROR") { + t.Fatalf("expected VALIDATION_ERROR in stderr, got: %s", stderr) + } + + // Disambiguate and try again — should succeed. + _, _, code = h.Run(t, ws, + "update", id, + "--description-replace-old", "- [ ] step 1\n", + "--description-replace-new", "- [x] step 1\n", + ) + if code != 0 { + t.Fatalf("disambiguated update should succeed; exit %d", code) + } +} + +func taskHasLabelTitle(t *client.Task, title string) bool { + for _, l := range t.Labels { + if l != nil && l.Title == title { + return true + } + } + return false +} diff --git a/veans/go.mod b/veans/go.mod new file mode 100644 index 000000000..c025994dc --- /dev/null +++ b/veans/go.mod @@ -0,0 +1,21 @@ +module code.vikunja.io/veans + +go 1.25.0 + +require ( + github.com/dustinkirkland/golang-petname v0.0.0-20260215035315-f0c533e9ce9b + github.com/magefile/mage v1.17.2 + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c + github.com/spf13/cobra v1.10.2 + github.com/zalando/go-keyring v0.2.8 + golang.org/x/sys v0.43.0 + golang.org/x/term v0.42.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/danieljoos/wincred v1.2.3 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect +) diff --git a/veans/go.sum b/veans/go.sum new file mode 100644 index 000000000..3e0c9d612 --- /dev/null +++ b/veans/go.sum @@ -0,0 +1,38 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= +github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustinkirkland/golang-petname v0.0.0-20260215035315-f0c533e9ce9b h1:qZ21OofI7zneC9dOEqul4FmIWz/YjJJMrf6fL7jrFYQ= +github.com/dustinkirkland/golang-petname v0.0.0-20260215035315-f0c533e9ce9b/go.mod h1:8AuBTZBRSFqEYBPYULd+NN474/zZBLP+6WeT5S9xlAc= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/magefile/mage v1.17.2 h1:fyXVu1eadI8Ap1HCCNgEhJ5McIWiYhLR8uol64ZZc40= +github.com/magefile/mage v1.17.2/go.mod h1:Yj51kqllmsgFpvvSzgrZPK9WtluG3kUhFaBUVLo4feA= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= +github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/veans/internal/auth/auth.go b/veans/internal/auth/auth.go new file mode 100644 index 000000000..60706aaf7 --- /dev/null +++ b/veans/internal/auth/auth.go @@ -0,0 +1,165 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Package auth handles the human's transient authentication during init and +// login. The default interactive flow is OAuth 2.0 Authorization Code + PKCE +// against Vikunja's built-in authorization server. The OAuth dance opens a +// browser at the authorize URL; the user signs in and lands on a localhost +// callback this CLI ran. --token / --use-password / --username + --password +// are escape hatches for non-interactive contexts. +package auth + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "os" + "strings" + + "golang.org/x/term" + + "code.vikunja.io/veans/internal/client" + "code.vikunja.io/veans/internal/output" +) + +// Prompter abstracts stdin / TTY reads so tests can inject scripted answers. +type Prompter interface { + ReadLine(prompt string) (string, error) + ReadPassword(prompt string) (string, error) +} + +// StdPrompter reads from os.Stdin and writes prompts to os.Stderr; uses +// term.ReadPassword for masked input when on a TTY. The bufio.Reader is +// reused across ReadLine calls — a new reader on each call would read- +// ahead a buffer, discard the rest on return, and starve later prompts. +type StdPrompter struct { + stdin *bufio.Reader +} + +func NewStdPrompter() *StdPrompter { + return &StdPrompter{stdin: bufio.NewReader(os.Stdin)} +} + +func (p *StdPrompter) ReadLine(prompt string) (string, error) { + if _, err := fmt.Fprint(os.Stderr, prompt); err != nil { + return "", err + } + line, err := p.stdin.ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return "", err + } + return strings.TrimRight(line, "\r\n"), nil +} + +func (p *StdPrompter) ReadPassword(prompt string) (string, error) { + if _, err := fmt.Fprint(os.Stderr, prompt); err != nil { + return "", err + } + if term.IsTerminal(int(os.Stdin.Fd())) { + buf, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Fprintln(os.Stderr) + if err != nil { + return "", err + } + return string(buf), nil + } + // Non-TTY (CI, scripted test) — read a plain line. + return p.ReadLine("") +} + +// LoginOptions controls how AcquireHumanToken obtains a JWT. +type LoginOptions struct { + // Token short-circuits all flows. May be a JWT or a personal API token. + Token string + // UsePassword forces the legacy POST /login flow even when no password + // is set yet (the prompter will ask for it). Useful on instances where + // OAuth is disabled or the user prefers entering a password. + UsePassword bool + // Username / Password / TOTP feed POST /login. If both Username and + // Password are non-empty, AcquireHumanToken uses /login non-interactively + // regardless of UsePassword. + Username string + Password string + TOTP string + // Out is where progress / OAuth instructions are written. Defaults to + // os.Stderr in production via NewStdPrompter; tests can pass any writer. + Out io.Writer +} + +// AcquireHumanToken returns a bearer token to act as the human during init. +// Resolution order: +// 1. opts.Token (paste-in or --token flag) +// 2. POST /login with Username + Password (used non-interactively when both +// are set, or when --use-password is passed) +// 3. OAuth Authorization Code + PKCE flow with manual callback paste-back +// (the default for interactive use) +func AcquireHumanToken(ctx context.Context, c *client.Client, opts LoginOptions, p Prompter) (string, error) { + if opts.Token != "" { + return opts.Token, nil + } + if p == nil { + p = NewStdPrompter() + } + w := opts.Out + if w == nil { + w = os.Stderr + } + + usePassword := opts.UsePassword || (opts.Username != "" && opts.Password != "") + if usePassword { + return loginWithPassword(ctx, c, opts, p) + } + + return runOAuthFlow(ctx, c, p, w) +} + +// loginWithPassword runs the legacy POST /login path. Kept for instances +// that have OAuth disabled or for non-interactive `--username` + `--password` +// invocations in CI. +func loginWithPassword(ctx context.Context, c *client.Client, opts LoginOptions, p Prompter) (string, error) { + if opts.Username == "" { + u, err := p.ReadLine("Vikunja username: ") + if err != nil { + return "", output.Wrap(output.CodeAuth, err, "read username: %v", err) + } + opts.Username = strings.TrimSpace(u) + } + if opts.Password == "" { + pw, err := p.ReadPassword("Vikunja password: ") + if err != nil { + return "", output.Wrap(output.CodeAuth, err, "read password: %v", err) + } + opts.Password = pw + } + if opts.Username == "" || opts.Password == "" { + return "", output.New(output.CodeAuth, "username and password are required for password login") + } + resp, err := c.Login(ctx, &client.LoginRequest{ + Username: opts.Username, + Password: opts.Password, + TOTPPasscode: opts.TOTP, + LongToken: true, + }) + if err != nil { + return "", err + } + if resp.Token == "" { + return "", output.New(output.CodeAuth, "login returned empty token") + } + return resp.Token, nil +} diff --git a/veans/internal/auth/auth_test.go b/veans/internal/auth/auth_test.go new file mode 100644 index 000000000..0315cc256 --- /dev/null +++ b/veans/internal/auth/auth_test.go @@ -0,0 +1,50 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package auth + +import ( + "context" + "testing" + + "code.vikunja.io/veans/internal/client" +) + +func TestAcquireHumanToken_TokenShortCircuit(t *testing.T) { + // When opts.Token is set, no prompts and no HTTP calls happen — the + // nil client confirms that nothing tries to dial out. + tok, err := AcquireHumanToken(context.Background(), (*client.Client)(nil), LoginOptions{Token: "abc"}, &recordingPrompter{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tok != "abc" { + t.Fatalf("got %q, want abc", tok) + } +} + +type recordingPrompter struct { + calls []string +} + +func (r *recordingPrompter) ReadLine(p string) (string, error) { + r.calls = append(r.calls, "line:"+p) + return "", nil +} + +func (r *recordingPrompter) ReadPassword(p string) (string, error) { + r.calls = append(r.calls, "pw:"+p) + return "", nil +} diff --git a/veans/internal/auth/oauth.go b/veans/internal/auth/oauth.go new file mode 100644 index 000000000..281bbabce --- /dev/null +++ b/veans/internal/auth/oauth.go @@ -0,0 +1,279 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package auth + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "fmt" + "html" + "io" + "net" + "net/http" + "net/url" + "strings" + "time" + + "github.com/pkg/browser" + + "code.vikunja.io/veans/internal/client" + "code.vikunja.io/veans/internal/output" +) + +// oauthClientID is what veans presents to Vikunja's authorization server. +// Vikunja's OAuth provider doesn't require client registration — the value +// just needs to be consistent across the authorize and token-exchange steps. +const oauthClientID = "veans-cli" + +// loopbackTimeout caps how long we wait for the user to complete the +// browser-side handshake before giving up. +const loopbackTimeout = 5 * time.Minute + +// PKCEPair holds the challenge sent to /oauth/authorize and the verifier +// kept locally until token exchange. +type PKCEPair struct { + Verifier string + Challenge string +} + +// generatePKCE produces a fresh (verifier, challenge) pair per RFC 7636. +// The verifier is 64 random bytes, base64url-encoded without padding (~86 +// characters — comfortably inside the 43–128 range Vikunja accepts). The +// challenge is the SHA-256 of the verifier, also base64url-no-pad. +func generatePKCE() (PKCEPair, error) { + buf := make([]byte, 64) + if _, err := rand.Read(buf); err != nil { + return PKCEPair{}, err + } + verifier := base64.RawURLEncoding.EncodeToString(buf) + sum := sha256.Sum256([]byte(verifier)) + challenge := base64.RawURLEncoding.EncodeToString(sum[:]) + return PKCEPair{Verifier: verifier, Challenge: challenge}, nil +} + +// generateState returns a random opaque string for CSRF protection. +func generateState() (string, error) { + buf := make([]byte, 24) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(buf), nil +} + +// buildAuthorizeURL renders the browser-side redirect target. +func buildAuthorizeURL(server, redirectURI string, pkce PKCEPair, state string) string { + q := url.Values{} + q.Set("response_type", "code") + q.Set("client_id", oauthClientID) + q.Set("redirect_uri", redirectURI) + q.Set("code_challenge", pkce.Challenge) + q.Set("code_challenge_method", "S256") + q.Set("state", state) + return strings.TrimRight(server, "/") + "/oauth/authorize?" + q.Encode() +} + +// callbackResult carries the parsed query parameters from the loopback +// callback request, or any error that prevented a clean handshake. +type callbackResult struct { + code string + state string + err error +} + +// runOAuthFlow drives an OAuth Authorization Code + PKCE handshake against +// Vikunja's server using a localhost loopback listener (RFC 8252): +// bind 127.0.0.1:0, open the authorize URL in the browser, capture the +// callback, exchange the code for a token. +// +// The prompter is retained on the signature for symmetry with the +// password flow but isn't called — the loopback handshake completes +// without further user input beyond the in-browser sign-in. +func runOAuthFlow(ctx context.Context, c *client.Client, _ Prompter, w io.Writer) (string, error) { + pkce, err := generatePKCE() + if err != nil { + return "", output.Wrap(output.CodeUnknown, err, "generate PKCE: %v", err) + } + state, err := generateState() + if err != nil { + return "", output.Wrap(output.CodeUnknown, err, "generate state: %v", err) + } + + listener, redirectURI, err := bindLoopbackListener(ctx) + if err != nil { + return "", err + } + + server, resultCh := newCallbackServer(listener) + go func() { _ = server.Serve(listener) }() + // Shutdown uses a detached context derived from ctx so cancellation + // at the outer scope still allows the graceful-stop to drain. + shutdownParent := context.WithoutCancel(ctx) + defer func() { + shutdownCtx, cancel := context.WithTimeout(shutdownParent, 2*time.Second) + defer cancel() + _ = server.Shutdown(shutdownCtx) + }() + + authURL := buildAuthorizeURL(c.BaseURL, redirectURI, pkce, state) + announceBrowserStep(w, authURL) + // Best-effort browser launch — the URL is also printed so the user + // can paste it manually if their environment can't auto-open one + // (SSH session, container without DISPLAY, etc.). + _ = browser.OpenURL(authURL) + + result, err := waitForCallback(ctx, resultCh) + if err != nil { + return "", err + } + if result.state != state { + return "", output.New(output.CodeAuth, + "state mismatch on OAuth callback (possible CSRF)") + } + + resp, err := c.ExchangeOAuthCode(ctx, &client.OAuthTokenRequest{ + GrantType: "authorization_code", + Code: result.code, + ClientID: oauthClientID, + RedirectURI: redirectURI, + CodeVerifier: pkce.Verifier, + }) + if err != nil { + return "", err + } + if resp.AccessToken == "" { + return "", output.New(output.CodeAuth, "OAuth token exchange returned empty access_token") + } + return resp.AccessToken, nil +} + +// bindLoopbackListener picks a free port on 127.0.0.1 and returns a +// listener + the corresponding `http://127.0.0.1:NNN/callback` URI for +// the OAuth `redirect_uri` parameter. +func bindLoopbackListener(ctx context.Context) (net.Listener, string, error) { + var lc net.ListenConfig + listener, err := lc.Listen(ctx, "tcp", "127.0.0.1:0") + if err != nil { + return nil, "", output.Wrap(output.CodeUnknown, err, + "bind loopback port for OAuth callback: %v", err) + } + port := listener.Addr().(*net.TCPAddr).Port + return listener, fmt.Sprintf("http://127.0.0.1:%d/callback", port), nil +} + +// newCallbackServer returns an http.Server bound to `listener` whose +// /callback handler parses the authorization-server redirect query and +// pushes the result onto the returned channel. +func newCallbackServer(listener net.Listener) (*http.Server, <-chan callbackResult) { + resultCh := make(chan callbackResult, 1) + server := &http.Server{ + Addr: listener.Addr().String(), + ReadHeaderTimeout: 5 * time.Second, + WriteTimeout: 10 * time.Second, + IdleTimeout: 10 * time.Second, + Handler: http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/callback" { + http.NotFound(rw, r) + return + } + // Pin to GET so a third-party page can't POST a forged + // (code, state) into the loopback handler. State binding + // already defends, but cheap belt-and-braces. + if r.Method != http.MethodGet { + rw.Header().Set("Allow", "GET") + http.Error(rw, "method not allowed", http.StatusMethodNotAllowed) + return + } + q := r.URL.Query() + res := callbackResult{code: q.Get("code"), state: q.Get("state")} + if errCode := q.Get("error"); errCode != "" { + desc := q.Get("error_description") + if desc == "" { + desc = errCode + } + res.err = fmt.Errorf("authorization failed: %s", desc) + } + renderCallbackPage(rw, res.err) + select { + case resultCh <- res: + default: + } + }), + } + return server, resultCh +} + +// waitForCallback blocks until the loopback handler fires, ctx cancels, +// or loopbackTimeout elapses. +func waitForCallback(ctx context.Context, resultCh <-chan callbackResult) (callbackResult, error) { + timer := time.NewTimer(loopbackTimeout) + defer timer.Stop() + select { + case result := <-resultCh: + if result.err != nil { + return result, output.Wrap(output.CodeAuth, result.err, "%v", result.err) + } + if result.code == "" { + return result, output.New(output.CodeAuth, "no `code` returned from OAuth callback") + } + return result, nil + case <-timer.C: + return callbackResult{}, output.New(output.CodeAuth, + "OAuth flow timed out after %s — re-run init with --use-password or --token", loopbackTimeout) + case <-ctx.Done(): + return callbackResult{}, ctx.Err() + } +} + +func announceBrowserStep(w io.Writer, authURL string) { + if w == nil { + return + } + fmt.Fprintln(w) + fmt.Fprintln(w, "Opening your browser to authorize veans:") + fmt.Fprintln(w, " "+authURL) + fmt.Fprintln(w) + fmt.Fprintln(w, "If the browser doesn't open, paste the URL above manually.") + fmt.Fprintln(w) +} + +// renderCallbackPage writes a minimal HTML response to the user's browser +// after the loopback callback fires. We don't ship any framework — a few +// lines of inlined HTML are enough to confirm completion. +func renderCallbackPage(w http.ResponseWriter, err error) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + if err != nil { + w.WriteHeader(http.StatusBadRequest) + // HTML-escape the authorization-server's error_description — it + // arrives unsanitized from a remote source and we render it + // straight into the loopback page. + _, _ = fmt.Fprintf(w, ` +

    veans: authorization failed

    +

    %s

    +

    You can close this tab and re-run veans init.

    +`, html.EscapeString(err.Error())) + return + } + _, _ = w.Write([]byte(` +

    veans is authorized

    +

    You can close this tab and return to the terminal.

    +`)) +} + +// silence the unused-import linter when errors isn't referenced elsewhere. diff --git a/veans/internal/auth/oauth_test.go b/veans/internal/auth/oauth_test.go new file mode 100644 index 000000000..9af726bc2 --- /dev/null +++ b/veans/internal/auth/oauth_test.go @@ -0,0 +1,286 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package auth + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestGeneratePKCE_VerifierShape(t *testing.T) { + pair, err := generatePKCE() + if err != nil { + t.Fatal(err) + } + // RFC 7636 §4.1: verifier is 43–128 chars, [A-Za-z0-9-._~]. + if len(pair.Verifier) < 43 || len(pair.Verifier) > 128 { + t.Fatalf("verifier length %d out of [43,128]", len(pair.Verifier)) + } + for _, r := range pair.Verifier { + switch { + case r >= 'A' && r <= 'Z', + r >= 'a' && r <= 'z', + r >= '0' && r <= '9', + r == '-', r == '.', r == '_', r == '~': + default: + t.Fatalf("verifier contains illegal rune %q", r) + } + } + // Challenge must be SHA256(verifier) base64url-no-pad. + want := sha256.Sum256([]byte(pair.Verifier)) + got, err := base64.RawURLEncoding.DecodeString(pair.Challenge) + if err != nil { + t.Fatalf("challenge isn't base64url-no-pad: %v", err) + } + if string(got) != string(want[:]) { + t.Fatal("challenge != SHA256(verifier)") + } +} + +func TestGeneratePKCE_Unique(t *testing.T) { + a, _ := generatePKCE() + b, _ := generatePKCE() + if a.Verifier == b.Verifier { + t.Fatal("two consecutive verifiers are identical — entropy is broken") + } +} + +func TestBuildAuthorizeURL(t *testing.T) { + u := buildAuthorizeURL( + "https://vikunja.example.com", + "http://127.0.0.1:54321/callback", + PKCEPair{Challenge: "CHL"}, + "S", + ) + if !strings.HasPrefix(u, "https://vikunja.example.com/oauth/authorize?") { + t.Fatalf("unexpected prefix: %s", u) + } + for _, want := range []string{ + "response_type=code", + "client_id=" + oauthClientID, + "code_challenge=CHL", + "code_challenge_method=S256", + "state=S", + // redirect_uri carried through (URL-encoded) + "redirect_uri=http%3A%2F%2F127.0.0.1%3A54321%2Fcallback", + } { + if !strings.Contains(u, want) { + t.Errorf("authorize URL missing %q: %s", want, u) + } + } + // Server URL with trailing slash should still produce a single slash + // before the path. + u2 := buildAuthorizeURL("https://vikunja.example.com/", "", PKCEPair{}, "") + if strings.Contains(u2, "//oauth") { + t.Errorf("trailing slash leaked into URL: %s", u2) + } +} + +// newCallbackHandler returns just the http.Handler portion of +// newCallbackServer so tests can drive it directly with httptest.NewRecorder +// without binding a real loopback socket. +func newCallbackHandler(t *testing.T) (http.Handler, <-chan callbackResult) { + t.Helper() + var lc net.ListenConfig + listener, err := lc.Listen(context.Background(), "tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + t.Cleanup(func() { _ = listener.Close() }) + server, ch := newCallbackServer(listener) + return server.Handler, ch +} + +func TestNewCallbackServer_HappyPath(t *testing.T) { + handler, ch := newCallbackHandler(t) + req := httptest.NewRequest(http.MethodGet, "/callback?code=abc&state=xyz", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + select { + case res := <-ch: + if res.code != "abc" { + t.Errorf("code = %q, want abc", res.code) + } + if res.state != "xyz" { + t.Errorf("state = %q, want xyz", res.state) + } + if res.err != nil { + t.Errorf("err = %v, want nil", res.err) + } + default: + t.Fatal("no result pushed to channel") + } + + if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { + t.Errorf("Content-Type = %q, want text/html…", ct) + } + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want 200", rec.Code) + } +} + +func TestNewCallbackServer_AuthzServerError(t *testing.T) { + handler, ch := newCallbackHandler(t) + req := httptest.NewRequest(http.MethodGet, + "/callback?error=access_denied&error_description=user+declined", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + select { + case res := <-ch: + if res.err == nil { + t.Fatal("err = nil, want non-nil") + } + // renderCallbackPage uses error_description when present; the + // handler also stuffs it into res.err. "user declined" comes + // straight from error_description. + if !strings.Contains(res.err.Error(), "user declined") { + t.Errorf("err = %q, want it to mention error_description", res.err.Error()) + } + default: + t.Fatal("no result pushed to channel") + } + + if rec.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", rec.Code) + } +} + +func TestNewCallbackServer_AuthzServerErrorOnlyCode(t *testing.T) { + // When error_description is empty, the handler falls back to the + // `error` code in the user-visible message. + handler, ch := newCallbackHandler(t) + req := httptest.NewRequest(http.MethodGet, "/callback?error=access_denied", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + select { + case res := <-ch: + if res.err == nil { + t.Fatal("err = nil, want non-nil") + } + if !strings.Contains(res.err.Error(), "access_denied") { + t.Errorf("err = %q, want it to mention error code", res.err.Error()) + } + default: + t.Fatal("no result pushed to channel") + } +} + +func TestNewCallbackServer_EmptyCode(t *testing.T) { + // Empty `code` without an `error` parameter is the handler's job + // only to forward verbatim — waitForCallback is the one that + // upgrades that to an error. + handler, ch := newCallbackHandler(t) + req := httptest.NewRequest(http.MethodGet, "/callback?state=xyz", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + select { + case res := <-ch: + if res.code != "" { + t.Errorf("code = %q, want empty", res.code) + } + if res.state != "xyz" { + t.Errorf("state = %q, want xyz", res.state) + } + if res.err != nil { + t.Errorf("err = %v, want nil", res.err) + } + default: + t.Fatal("no result pushed to channel") + } +} + +func TestNewCallbackServer_MethodNotAllowed(t *testing.T) { + handler, ch := newCallbackHandler(t) + req := httptest.NewRequest(http.MethodPost, "/callback?code=abc&state=xyz", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Errorf("status = %d, want 405", rec.Code) + } + if a := rec.Header().Get("Allow"); a != "GET" { + t.Errorf("Allow header = %q, want GET", a) + } + select { + case res := <-ch: + t.Fatalf("nothing should be pushed for a rejected method, got %+v", res) + default: + } +} + +func TestNewCallbackServer_WrongPath(t *testing.T) { + handler, ch := newCallbackHandler(t) + req := httptest.NewRequest(http.MethodGet, "/something-else", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404", rec.Code) + } + select { + case res := <-ch: + t.Fatalf("nothing should be pushed for a 404, got %+v", res) + default: + } +} + +func TestRenderCallbackPage_HTMLEscapesError(t *testing.T) { + rec := httptest.NewRecorder() + renderCallbackPage(rec, &fakeError{msg: ``}) + + body := rec.Body.String() + if strings.Contains(body, "