diff --git a/.all-contributorsrc b/.all-contributorsrc index b2242bc408c06e1940ebc5e031c9272b0eb96b5d..223de1467afc17a4b0e903e1e702b791173e28d8 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -364,11 +364,38 @@ "contributions": [ "code" ] + }, + { + "login": "cchalop1", + "name": "CHALOPIN ClΓ©ment", + "avatar_url": "https://avatars.githubusercontent.com/u/28163855?v=4", + "profile": "http://cchalop1.com", + "contributions": [ + "code" + ] + }, + { + "login": "geetansh", + "name": "Geetansh Jindal", + "avatar_url": "https://avatars.githubusercontent.com/u/9976198?v=4", + "profile": "https://github.com/geetansh", + "contributions": [ + "code" + ] + }, + { + "login": "0livier", + "name": "Olivier Garcia", + "avatar_url": "https://avatars.githubusercontent.com/u/10607?v=4", + "profile": "https://github.com/0livier", + "contributions": [ + "code" + ] } ], "contributorsPerLine": 7, "projectName": "runtipi", - "projectOwner": "meienberger", + "projectOwner": "runtipi", "repoType": "github", "repoHost": "https://github.com", "skipCi": true, diff --git a/.github/workflows/alpha-release.yml b/.github/workflows/alpha-release.yml index 6e5e6a24f80d6a3284a56a2f45cd0ef8c2fec16d..61abfbc771ec30c9ebe6abdedf7772edd8481d53 100644 --- a/.github/workflows/alpha-release.yml +++ b/.github/workflows/alpha-release.yml @@ -11,25 +11,55 @@ jobs: create-tag: runs-on: ubuntu-latest outputs: - tagname: ${{ steps.create_tag.outputs.tagname }} + tagname: ${{ steps.get_tag.outputs.tagname }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Get tag from package.json + id: get_tag + run: | + VERSION=$(npm run version --silent) + echo "tagname=v${VERSION}-alpha.${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT + - uses: rickstaa/action-create-tag@v1 + with: + tag: ${{ steps.get_tag.outputs.tagname }} + + build-worker: + runs-on: ubuntu-latest + needs: create-tag steps: - name: Checkout code uses: actions/checkout@v4 - - name: Create Tag - id: create_tag - uses: butlerlogic/action-autotag@stable - env: - GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 with: - tag_prefix: 'v' - tag_suffix: '-alpha.${{ github.event.inputs.tag }}' + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push images + uses: docker/build-push-action@v5 + with: + context: . + file: ./packages/worker/Dockerfile + platforms: linux/amd64 + push: true + tags: ghcr.io/${{ github.repository_owner }}/worker:${{ needs.create-tag.outputs.tagname }} + cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/worker:buildcache + cache-to: type=registry,ref=ghcr.io/${{ github.repository_owner }}/worker:buildcache,mode=max build-images: runs-on: ubuntu-latest needs: create-tag - steps: - name: Checkout code uses: actions/checkout@v4 @@ -51,7 +81,7 @@ jobs: uses: docker/build-push-action@v5 with: context: . - platforms: linux/amd64,linux/arm64 + platforms: linux/amd64 push: true tags: ghcr.io/${{ github.repository_owner }}/runtipi:${{ needs.create-tag.outputs.tagname }} cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/runtipi:buildcache @@ -60,7 +90,6 @@ jobs: build-cli: runs-on: ubuntu-latest needs: create-tag - steps: - name: Checkout code uses: actions/checkout@v4 @@ -107,7 +136,7 @@ jobs: publish-release: runs-on: ubuntu-latest - needs: [create-tag, build-images, build-cli] + needs: [create-tag, build-images, build-cli, build-worker] steps: - name: Download CLI @@ -116,35 +145,21 @@ jobs: name: cli path: cli + - name: Rename CLI + run: | + mv cli/bin/cli-x64 ./runtipi-cli-linux-x64 + - name: Create alpha release id: create_release - uses: actions/create-release@v1 + uses: softprops/action-gh-release@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: body: | **${{ needs.create-tag.outputs.tagname }}** tag_name: ${{ needs.create-tag.outputs.tagname }} - release_name: ${{ needs.create-tag.outputs.tagname }} + name: ${{ needs.create-tag.outputs.tagname }} draft: false prerelease: true - - - name: Upload X64 Linux CLI binary to release - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: cli/bin/cli-x64 - asset_name: runtipi-cli-linux-x64 - asset_content_type: application/octet-stream - - - name: Upload ARM64 Linux CLI binary to release - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: cli/bin/cli-arm64 - asset_name: runtipi-cli-linux-arm64 - asset_content_type: application/octet-stream + files: | + runtipi-cli-linux-x64 diff --git a/.github/workflows/beta-release.yml b/.github/workflows/beta-release.yml index 745fd61873883c08fd788a56924728a41c8be9de..4b8d8cbe2de8e0f14ad1fa6aec8b5833d91009d9 100644 --- a/.github/workflows/beta-release.yml +++ b/.github/workflows/beta-release.yml @@ -8,27 +8,57 @@ on: required: true jobs: - get-tag: + create-tag: runs-on: ubuntu-latest outputs: - tag: ${{ steps.get_tag.outputs.tag }} + tagname: ${{ steps.get_tag.outputs.tagname }} steps: - name: Checkout code uses: actions/checkout@v4 - - name: Install Node.js - uses: actions/setup-node@v4 - with: - node-version: 18 - - - name: Get tag from VERSION file + - name: Get tag from package.json id: get_tag run: | VERSION=$(npm run version --silent) - echo "tag=v${VERSION}-beta.${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT + echo "tagname=v${VERSION}-beta.${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT + + - uses: rickstaa/action-create-tag@v1 + with: + tag: ${{ steps.get_tag.outputs.tagname }} + + build-worker: + runs-on: ubuntu-latest + needs: create-tag + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push images + uses: docker/build-push-action@v5 + with: + context: . + file: ./packages/worker/Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: ghcr.io/${{ github.repository_owner }}/worker:${{ needs.create-tag.outputs.tagname }} + cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/worker:buildcache + cache-to: type=registry,ref=ghcr.io/${{ github.repository_owner }}/worker:buildcache,mode=max build-images: - needs: get-tag + needs: create-tag runs-on: ubuntu-latest steps: - name: Checkout code @@ -53,13 +83,13 @@ jobs: context: . platforms: linux/amd64,linux/arm64 push: true - tags: ghcr.io/${{ github.repository_owner }}/runtipi:${{ needs.get-tag.outputs.tag }} + tags: ghcr.io/${{ github.repository_owner }}/runtipi:${{ needs.create-tag.outputs.tagname }} cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/runtipi:buildcache cache-to: type=registry,ref=ghcr.io/${{ github.repository_owner }}/runtipi:buildcache,mode=max build-cli: runs-on: ubuntu-latest - needs: get-tag + needs: create-tag steps: - name: Checkout code uses: actions/checkout@v4 @@ -93,7 +123,7 @@ jobs: run: pnpm install - name: Set version - run: pnpm -r --filter cli set-version ${{ needs.get-tag.outputs.tag }} + run: pnpm -r --filter cli set-version ${{ needs.create-tag.outputs.tagname }} - name: Build CLI run: pnpm -r --filter cli package @@ -104,28 +134,9 @@ jobs: name: cli path: packages/cli/dist - create-tag: - needs: [build-images, build-cli] - runs-on: ubuntu-latest - outputs: - tagname: ${{ steps.create_tag.outputs.tagname }} - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Create Tag - id: create_tag - uses: butlerlogic/action-autotag@stable - env: - GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' - with: - tag_prefix: 'v' - tag_suffix: '-beta.${{ github.event.inputs.tag }}' - publish-release: runs-on: ubuntu-latest - needs: [create-tag, build-images, build-cli] + needs: [create-tag, build-images, build-cli, build-worker] outputs: id: ${{ steps.create_release.outputs.id }} steps: @@ -135,38 +146,26 @@ jobs: name: cli path: cli + - name: Rename CLI + run: | + mv cli/bin/cli-x64 ./runtipi-cli-linux-x64 + mv cli/bin/cli-arm64 ./runtipi-cli-linux-arm64 + - name: Create beta release id: create_release - uses: actions/create-release@v1 + uses: softprops/action-gh-release@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: body: | **${{ needs.create-tag.outputs.tagname }}** tag_name: ${{ needs.create-tag.outputs.tagname }} - release_name: ${{ needs.create-tag.outputs.tagname }} + name: ${{ needs.create-tag.outputs.tagname }} draft: false prerelease: true - - - name: Upload X64 Linux CLI binary to release - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: cli/bin/cli-x64 - asset_name: runtipi-cli-linux-x64 - asset_content_type: application/octet-stream - - - name: Upload ARM64 Linux CLI binary to release - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: cli/bin/cli-arm64 - asset_name: runtipi-cli-linux-arm64 - asset_content_type: application/octet-stream + files: | + runtipi-cli-linux-x64 + runtipi-cli-linux-arm64 e2e-tests: needs: [create-tag, publish-release] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b32df657b0b2adf2b377e54f716b5b6cac578156..fc81012fc2de6536bdd71c122f5abb4236143d40 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,6 @@ name: Tipi CI on: - push: + pull_request: env: ROOT_FOLDER: /runtipi diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index dc9d23d51a87e4f8c3cb3096602ded8b5a7cabde..fea580bb1f13fb832a125b6d3e4101279a3fc462 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -72,9 +72,6 @@ jobs: run: | while ! ssh -o StrictHostKeyChecking=no -i ~/.ssh/id_rsa root@${{ steps.get-droplet-ip.outputs.droplet_ip }} "echo 'SSH is ready'"; do sleep 5; done - - name: Wait 1 minute for Droplet to be ready - run: sleep 60 - - name: Create docker group on Droplet uses: fifsky/ssh-action@master with: @@ -85,6 +82,9 @@ jobs: user: root key: ${{ secrets.SSH_KEY }} + - name: Wait 90 seconds for Docker to be ready on Droplet + run: sleep 90 + - name: Deploy app to Droplet uses: fifsky/ssh-action@master with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0678947cb2e456d8385d4b1cd84e0be016c0367c..1a94fdefbee1fa1b47faa9217876ba31589d4bae 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,28 +3,28 @@ on: workflow_dispatch: jobs: - get-tag: + create-tag: runs-on: ubuntu-latest + needs: [build-images, build-cli] outputs: - tag: ${{ steps.get_tag.outputs.tag }} + tagname: ${{ steps.get_tag.outputs.tagname }} steps: - name: Checkout code uses: actions/checkout@v4 - - name: Install Node.js - uses: actions/setup-node@v4 - with: - node-version: 18 - - - name: Get tag from VERSION file + - name: Get tag from package.json id: get_tag run: | VERSION=$(npm run version --silent) - echo "tag=v${VERSION}" >> $GITHUB_OUTPUT + echo "tagname=v${VERSION}" >> $GITHUB_OUTPUT + + - uses: rickstaa/action-create-tag@v1 + with: + tag: ${{ steps.get_tag.outputs.tagname }} build-images: if: github.repository == 'runtipi/runtipi' - needs: get-tag + needs: create-tag runs-on: ubuntu-latest steps: - name: Checkout @@ -49,14 +49,45 @@ jobs: context: . platforms: linux/amd64,linux/arm64 push: true - tags: ghcr.io/${{ github.repository_owner }}/runtipi:${{ needs.get-tag.outputs.tag }},ghcr.io/${{ github.repository_owner }}/runtipi:latest + tags: ghcr.io/${{ github.repository_owner }}/runtipi:${{ needs.create-tag.outputs.tagname }},ghcr.io/${{ github.repository_owner }}/runtipi:latest cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/runtipi:buildcache cache-to: type=registry,ref=ghcr.io/${{ github.repository_owner }}/runtipi:buildcache,mode=max + build-worker: + runs-on: ubuntu-latest + needs: create-tag + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push images + uses: docker/build-push-action@v5 + with: + context: . + file: ./packages/worker/Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: ghcr.io/${{ github.repository_owner }}/worker:${{ needs.create-tag.outputs.tagname }},ghcr.io/${{ github.repository_owner }}/worker:latest + cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/worker:buildcache + cache-to: type=registry,ref=ghcr.io/${{ github.repository_owner }}/worker:buildcache,mode=max + build-cli: runs-on: ubuntu-latest timeout-minutes: 10 - needs: get-tag + needs: create-tag steps: - name: Checkout code uses: actions/checkout@v4 @@ -90,7 +121,7 @@ jobs: run: pnpm install - name: Set version - run: pnpm -r --filter cli set-version ${{ needs.get-tag.outputs.tag }} + run: pnpm -r --filter cli set-version ${{ needs.create-tag.outputs.tagname }} - name: Build CLI run: pnpm -r --filter cli package @@ -101,23 +132,6 @@ jobs: name: cli path: packages/cli/dist - create-tag: - runs-on: ubuntu-latest - needs: [build-images, build-cli] - outputs: - tagname: ${{ steps.create_tag.outputs.tagname }} - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Create Tag - id: create_tag - uses: butlerlogic/action-autotag@stable - env: - GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' - with: - tag_prefix: 'v' - publish-release: runs-on: ubuntu-latest needs: [create-tag] @@ -130,38 +144,26 @@ jobs: name: cli path: cli + - name: Rename CLI + run: | + mv cli/bin/cli-x64 ./runtipi-cli-linux-x64 + mv cli/bin/cli-arm64 ./runtipi-cli-linux-arm64 + - name: Create release id: create_release - uses: actions/create-release@v1 + uses: softprops/action-gh-release@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: body: | **${{ needs.create-tag.outputs.tagname }}** tag_name: ${{ needs.create-tag.outputs.tagname }} - release_name: ${{ needs.create-tag.outputs.tagname }} + name: ${{ needs.create-tag.outputs.tagname }} draft: false prerelease: true - - - name: Upload X64 Linux CLI binary to release - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: cli/bin/cli-x64 - asset_name: runtipi-cli-linux-x64 - asset_content_type: application/octet-stream - - - name: Upload ARM64 Linux CLI binary to release - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: cli/bin/cli-arm64 - asset_name: runtipi-cli-linux-arm64 - asset_content_type: application/octet-stream + files: | + runtipi-cli-linux-x64 + runtipi-cli-linux-arm64 e2e-tests: needs: [create-tag, publish-release] @@ -176,7 +178,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Promote release - uses: actions/github-script@v6 + uses: actions/github-script@v7 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | diff --git a/.gitignore b/.gitignore index a6501c9b65c7db7c658345782712c6d4488e1c91..6765c17b44176b61b87fb5de570f5b104645f0ff 100644 --- a/.gitignore +++ b/.gitignore @@ -54,8 +54,7 @@ node_modules/ /data/ /repos/ /apps/ -traefik/shared -traefik/tls +/traefik/ # media folder media @@ -67,3 +66,4 @@ media temp ./traefik/ +/user-config/ diff --git a/Dockerfile b/Dockerfile index 6b7b9cc9b915a436807b0b0dfd46e72644a99124..fcdbbd5dc2785b960175bb1f04ec3d71d8b5707a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,7 +33,8 @@ RUN npm run build FROM node_base AS app ENV NODE_ENV production -# USER node + +USER node WORKDIR /app diff --git a/README.md b/README.md index 2bc257b2c768285f2d8b0bb241b1959caac412b1..388d3c8477acacae7e5e3ed2aa8045981ed451c1 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,7 @@ # Tipi β€” A personal homeserver for everyone - -[![All Contributors](https://img.shields.io/badge/all_contributors-38-orange.svg?style=flat-square)](#contributors-) - +[![All Contributors](https://img.shields.io/badge/all_contributors-41-orange.svg?style=flat-square)](#contributors-) [![License](https://img.shields.io/github/license/runtipi/runtipi)](https://github.com/runtipi/runtipi/blob/master/LICENSE) @@ -75,36 +73,36 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d - - - - - - - + + + + + + + - - - - - - - + + + + + + + - + - - + + - - - + + + @@ -113,16 +111,19 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d - + - + - + - + - + + + +
Nicolas Meienberger
Nicolas Meienberger

πŸ’» πŸš‡ ⚠️ πŸ“–
ArneNaessens
ArneNaessens

πŸ’» πŸ€” ⚠️
DrMxrcy
DrMxrcy

πŸ’» πŸ€” ⚠️ πŸ–‹ πŸ“£ πŸ’¬ πŸ‘€
Cooper
Cooper

πŸ’»
JTruj1ll0923
JTruj1ll0923

πŸ’»
Stetsed
Stetsed

πŸ’»
Jones_Town
Jones_Town

πŸ’»
Nicolas Meienberger
Nicolas Meienberger

πŸ’» πŸš‡ ⚠️ πŸ“–
ArneNaessens
ArneNaessens

πŸ’» πŸ€” ⚠️
DrMxrcy
DrMxrcy

πŸ’» πŸ€” ⚠️ πŸ–‹ πŸ“£ πŸ’¬ πŸ‘€
Cooper
Cooper

πŸ’»
JTruj1ll0923
JTruj1ll0923

πŸ’»
Stetsed
Stetsed

πŸ’»
Jones_Town
Jones_Town

πŸ’»
Rushi Chaudhari
Rushi Chaudhari

πŸ’»
Robert Blaine
Robert Blaine

πŸ’»
Seth For Privacy
Seth For Privacy

πŸ’»
Prajna
Prajna

πŸ’»
Justin Moy
Justin Moy

πŸ’»
dextreem
dextreem

πŸ’»
Brahim Hadriche
Brahim Hadriche

πŸ’»
Rushi Chaudhari
Rushi Chaudhari

πŸ’»
Robert Blaine
Robert Blaine

πŸ’»
Seth For Privacy
Seth For Privacy

πŸ’»
Prajna
Prajna

πŸ’»
Justin Moy
Justin Moy

πŸ’»
dextreem
dextreem

πŸ’»
Brahim Hadriche
Brahim Hadriche

πŸ’»
Andrew Brereton
Andrew Brereton

πŸ–‹
Freddie Sackur
Freddie Sackur

πŸ’» πŸ“–
Freddie Sackur
Freddie Sackur

πŸ’» πŸ“–
Innocentius
Innocentius

🌍
Alex
Alex

πŸ’»
Ryan Wang
Ryan Wang

πŸ’»
Alex
Alex

πŸ’»
Ryan Wang
Ryan Wang

πŸ’»
simonandr
simonandr

πŸ–‹
iepure
iepure

🌍
Sergey Kodolov
Sergey Kodolov

🌍 πŸ’»
sclaren
sclaren

πŸ’»
mcmeel
mcmeel

πŸ’¬ πŸ€” πŸ’» πŸ“–
Sergey Kodolov
Sergey Kodolov

🌍 πŸ’»
sclaren
sclaren

πŸ’»
mcmeel
mcmeel

πŸ’¬ πŸ€” πŸ’» πŸ“–
NoisyFridge
NoisyFridge

🌍
Bvoxl
Bvoxl

🌍
m-lab-0
m-lab-0

🌍
Schmanko
Schmanko

🌍
Nghia Lele
Nghia Lele

🌍
amusingimpala75
amusingimpala75

πŸ’»
amusingimpala75
amusingimpala75

πŸ’»
David
David

🌍
Stavros
Stavros

🌍 πŸ’» ⚠️
Stavros Iliopoulos
Stavros Iliopoulos

🌍 πŸ’» ⚠️
loxiry
loxiry

🌍
JigSaw
JigSaw

πŸ’»
JigSaw
JigSaw

πŸ’»
DireMunchkin
DireMunchkin

πŸ’»
DireMunchkin
DireMunchkin

πŸ’»
Fabio Cingottini
Fabio Cingottini

🌍
him
him

πŸ’»
him
him

πŸ’»
CHALOPIN ClΓ©ment
CHALOPIN ClΓ©ment

πŸ’»
Geetansh Jindal
Geetansh Jindal

πŸ’»
Olivier Garcia
Olivier Garcia

πŸ’»
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index f3fb0d29d8405dbb86e97a6df7801311bff62de4..5f85b16de57a68500173c55288457c882c431614 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -55,6 +55,43 @@ services: networks: - tipi_main_network + tipi-worker: + build: + context: . + dockerfile: ./packages/worker/Dockerfile.dev + container_name: tipi-worker + healthcheck: + test: ['CMD', 'curl', '-f', 'http://localhost:3000/healthcheck'] + interval: 5s + timeout: 10s + retries: 120 + start_period: 5s + depends_on: + tipi-db: + condition: service_healthy + tipi-redis: + condition: service_healthy + env_file: + - .env + environment: + NODE_ENV: development + volumes: + # Dev mode + - ${PWD}/packages/worker/src:/app/packages/worker/src + # Production mode + - /proc:/host/proc:ro + - /var/run/docker.sock:/var/run/docker.sock + - ${PWD}/.env:/app/.env + - ${PWD}/state:/app/state + - ${PWD}/repos:/app/repos + - ${PWD}/apps:/app/apps + - ${STORAGE_PATH:-$PWD}/app-data:/storage/app-data + - ${PWD}/logs:/app/logs + - ${PWD}/traefik:/app/traefik + - ${PWD}/user-config:/app/user-config + networks: + - tipi_main_network + tipi-dashboard: build: context: . @@ -65,6 +102,8 @@ services: condition: service_healthy tipi-redis: condition: service_healthy + tipi-worker: + condition: service_healthy env_file: - .env environment: @@ -84,7 +123,7 @@ services: - ${PWD}/apps:/runtipi/apps - ${PWD}/logs:/app/logs - ${PWD}/traefik:/runtipi/traefik - - ${STORAGE_PATH}:/app/storage + - ${STORAGE_PATH:-$PWD}:/app/storage labels: traefik.enable: true traefik.http.services.dashboard.loadbalancer.server.port: 3000 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index a1c30d96c3d0c96fa01988cab704f12fd80c379b..309eaf22a23009db22a83154238285fb1fba21cf 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -55,6 +55,40 @@ services: networks: - tipi_main_network + tipi-worker: + build: + context: . + dockerfile: ./packages/worker/Dockerfile + container_name: tipi-worker + healthcheck: + test: ['CMD', 'curl', '-f', 'http://localhost:3000/healthcheck'] + interval: 5s + timeout: 10s + retries: 120 + start_period: 5s + depends_on: + tipi-db: + condition: service_healthy + tipi-redis: + condition: service_healthy + env_file: + - .env + environment: + NODE_ENV: production + volumes: + - /proc:/host/proc + - /var/run/docker.sock:/var/run/docker.sock + - ${PWD}/.env:/app/.env + - ${PWD}/state:/app/state + - ${PWD}/repos:/app/repos + - ${PWD}/apps:/app/apps + - ${STORAGE_PATH:-$PWD}/app-data:/storage/app-data + - ${PWD}/logs:/app/logs + - ${PWD}/traefik:/app/traefik + - ${PWD}/user-config:/app/user-config + networks: + - tipi_main_network + tipi-dashboard: build: context: . @@ -65,6 +99,8 @@ services: condition: service_healthy tipi-redis: condition: service_healthy + tipi-worker: + condition: service_healthy env_file: - .env environment: diff --git a/package.json b/package.json index f8c87bf3f065b678bf463be119913bdde9bed293..e7306622e30b7940fa02101aabbd75f223e50b6a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "runtipi", - "version": "2.1.0", + "version": "2.2.0", "description": "A homeserver for everyone", "scripts": { "knip": "knip", @@ -11,7 +11,7 @@ "test:client": "jest --colors --selectProjects client --", "test:server": "jest --colors --selectProjects server --", "test:vite": "dotenv -e .env.test -- vitest run --coverage", - "dev": "npm run db:migrate && next dev", + "dev": "next dev", "dev:watcher": "pnpm -r --filter cli dev", "db:migrate": "NODE_ENV=development dotenv -e .env.local -- tsx ./src/server/run-migrations-dev.ts", "lint": "next lint", @@ -46,12 +46,13 @@ "@runtipi/shared": "workspace:^", "@tabler/core": "1.0.0-beta20", "@tabler/icons-react": "^2.40.0", - "argon2": "^0.31.1", + "argon2": "^0.31.2", "bullmq": "^4.13.0", "clsx": "^2.0.0", "connect-redis": "^7.1.0", "drizzle-orm": "^0.28.6", "fs-extra": "^11.1.1", + "let-it-go": "^1.0.0", "lodash.merge": "^4.6.2", "next": "14.0.1", "next-client-cookies": "^1.0.6", @@ -92,7 +93,7 @@ "@testing-library/user-event": "^14.5.1", "@total-typescript/shoehorn": "^0.1.1", "@total-typescript/ts-reset": "^0.5.1", - "@types/fs-extra": "^11.0.3", + "@types/fs-extra": "^11.0.4", "@types/jest": "^29.5.7", "@types/lodash.merge": "^4.6.8", "@types/node": "20.8.10", @@ -122,7 +123,7 @@ "eslint-plugin-testing-library": "^6.1.0", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", - "knip": "^2.39.0", + "knip": "^2.41.3", "memfs": "^4.6.0", "msw": "^1.3.2", "next-router-mock": "^0.9.10", diff --git a/packages/cli/assets/docker-compose.yml b/packages/cli/assets/docker-compose.yml index 9ec0e9c7a34036c2e727211a36364f7372c270ca..4aa6fe0649e8eace3bde32f0456253ba64dd0de6 100644 --- a/packages/cli/assets/docker-compose.yml +++ b/packages/cli/assets/docker-compose.yml @@ -4,10 +4,12 @@ services: tipi-reverse-proxy: container_name: tipi-reverse-proxy image: traefik:v2.8 - restart: on-failure + restart: unless-stopped + depends_on: + - tipi-dashboard ports: - - ${NGINX_PORT-80}:80 - - ${NGINX_PORT_SSL-443}:443 + - ${NGINX_PORT:-80}:80 + - ${NGINX_PORT_SSL:-443}:443 command: --providers.docker volumes: - /var/run/docker.sock:/var/run/docker.sock:ro @@ -22,7 +24,7 @@ services: restart: on-failure stop_grace_period: 1m ports: - - 5432:5432 + - ${POSTGRES_PORT:-5432}:5432 volumes: - ./data/postgres:/var/lib/postgresql/data environment: @@ -40,7 +42,7 @@ services: tipi-redis: container_name: tipi-redis image: redis:7.2.0 - restart: on-failure + restart: unless-stopped command: redis-server --requirepass ${REDIS_PASSWORD} ports: - 6379:6379 @@ -54,9 +56,45 @@ services: networks: - tipi_main_network + tipi-worker: + container_name: tipi-worker + image: ghcr.io/runtipi/worker:${TIPI_VERSION} + restart: unless-stopped + healthcheck: + test: ['CMD', 'curl', '-f', 'http://localhost:3000/healthcheck'] + interval: 5s + timeout: 10s + retries: 120 + start_period: 5s + depends_on: + tipi-db: + condition: service_healthy + tipi-redis: + condition: service_healthy + env_file: + - .env + environment: + NODE_ENV: production + volumes: + # Core + - /proc:/host/proc + - /var/run/docker.sock:/var/run/docker.sock + # App + - ./.env:/app/.env + - ./state:/app/state + - ./repos:/app/repos + - ./apps:/app/apps + - ./logs:/app/logs + - ./traefik:/app/traefik + - ./user-config:/app/user-config + - ./media:/app/media + - ${STORAGE_PATH:-.}:/storage + networks: + - tipi_main_network + tipi-dashboard: image: ghcr.io/runtipi/runtipi:${TIPI_VERSION} - restart: on-failure + restart: unless-stopped container_name: tipi-dashboard networks: - tipi_main_network @@ -65,18 +103,19 @@ services: condition: service_healthy tipi-redis: condition: service_healthy - env_file: - - .env - environment: - NODE_ENV: production + tipi-worker: + condition: service_healthy volumes: - - ./.env:/runtipi/.env + - ./.env:/runtipi/.env:ro - ./state:/runtipi/state - ./repos:/runtipi/repos:ro - ./apps:/runtipi/apps - ./logs:/app/logs - - ./traefik:/runtipi/traefik - - ${STORAGE_PATH}:/app/storage + - ${STORAGE_PATH:-.}:/app/storage + env_file: + - .env + environment: + NODE_ENV: production labels: # Main traefik.enable: true diff --git a/packages/cli/package.json b/packages/cli/package.json index d93a4ed9bda641bb18d3a47297cc9c0b53ce295a..b07f693222298058238746e0aa83bfc93b9449ba 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -5,7 +5,7 @@ "main": "index.js", "bin": "dist/index.js", "scripts": { - "test": "dotenv -e .env.test vitest -- --coverage --watch=false", + "test": "dotenv -e .env.test vitest -- --coverage --watch=false --passWithNoTests", "test:watch": "dotenv -e .env.test vitest", "package": "npm run build && pkg package.json && chmod +x dist/bin/cli-x64 && chmod +x dist/bin/cli-arm64", "package:m1": "npm run build && pkg package.json -t node18-darwin-arm64", @@ -14,7 +14,8 @@ "build:meta": "esbuild ./src/index.ts --bundle --platform=node --target=node18 --outfile=dist/index.js --metafile=meta.json --analyze", "dev": "dotenv -e ../../.env nodemon", "lint": "eslint . --ext .ts", - "tsc": "tsc --noEmit" + "tsc": "tsc --noEmit", + "knip": "knip" }, "pkg": { "assets": "assets/**/*", @@ -31,10 +32,10 @@ "@faker-js/faker": "^8.2.0", "@types/cli-progress": "^3.11.4", "@types/node": "20.8.10", - "@types/web-push": "^3.6.2", "dotenv-cli": "^7.3.0", "esbuild": "^0.19.4", "eslint-config-prettier": "^9.0.0", + "knip": "^2.41.3", "memfs": "^4.6.0", "nodemon": "^3.0.1", "pkg": "^5.8.1", @@ -43,7 +44,6 @@ "vitest": "^0.34.6" }, "dependencies": { - "@runtipi/postgres-migrations": "^5.3.0", "@runtipi/shared": "workspace:^", "axios": "^1.6.0", "boxen": "^7.1.1", @@ -53,12 +53,8 @@ "cli-spinners": "^2.9.1", "commander": "^11.1.0", "dotenv": "^16.3.1", - "ioredis": "^5.3.2", "log-update": "^5.0.1", - "pg": "^8.11.3", "semver": "^7.5.4", - "systeminformation": "^5.21.15", - "web-push": "^3.6.6", "zod": "^3.22.4" } } diff --git a/packages/cli/src/executors/app/app.executors.ts b/packages/cli/src/executors/app/app.executors.ts index 7cfe77d38c21d357dce037d3b6fa79a32e9e0df2..cf7112afd9b66abf4e8fe66f8d441c60811777b2 100644 --- a/packages/cli/src/executors/app/app.executors.ts +++ b/packages/cli/src/executors/app/app.executors.ts @@ -1,300 +1,73 @@ -/* eslint-disable no-await-in-loop */ -/* eslint-disable no-restricted-syntax */ -import fs from 'fs'; -import path from 'path'; -import pg from 'pg'; +import { Queue, QueueEvents } from 'bullmq'; +import { SystemEvent, eventSchema } from '@runtipi/shared'; import { getEnv } from '@/utils/environment/environment'; -import { pathExists } from '@/utils/fs-helpers'; -import { compose } from '@/utils/docker-helpers'; -import { copyDataDir, generateEnvFile } from './app.helpers'; -import { fileLogger } from '@/utils/logger/file-logger'; +import { logger } from '@/utils/logger/logger'; import { TerminalSpinner } from '@/utils/logger/terminal-spinner'; -import { execAsync } from '@/utils/exec-async/execAsync'; - -const getDbClient = async () => { - const { postgresDatabase, postgresUsername, postgresPassword, postgresPort } = getEnv(); - - const client = new pg.Client({ - host: '127.0.0.1', - database: postgresDatabase, - user: postgresUsername, - password: postgresPassword, - port: Number(postgresPort), - }); - - await client.connect(); - - return client; -}; export class AppExecutors { private readonly logger; constructor() { - this.logger = fileLogger; + this.logger = logger; } - private handleAppError = (err: unknown) => { - if (err instanceof Error) { - this.logger.error(`An error occurred: ${err.message}`); - return { success: false, message: err.message }; - } - - return { success: false, message: `An error occurred: ${err}` }; - }; - - private getAppPaths = (appId: string) => { - const { rootFolderHost, storagePath, appsRepoId } = getEnv(); - - const appDataDirPath = path.join(storagePath, 'app-data', appId); - const appDirPath = path.join(rootFolderHost, 'apps', appId); - const configJsonPath = path.join(appDirPath, 'config.json'); - const repoPath = path.join(rootFolderHost, 'repos', appsRepoId, 'apps', appId); + private getQueue = () => { + const { redisPassword } = getEnv(); + const queue = new Queue('events', { connection: { host: '127.0.0.1', port: 6379, password: redisPassword } }); + const queueEvents = new QueueEvents('events', { connection: { host: '127.0.0.1', port: 6379, password: redisPassword } }); - return { appDataDirPath, appDirPath, configJsonPath, repoPath }; - }; - - /** - * Given an app id, ensures that the app folder exists in the apps folder - * If not, copies the app folder from the repo - * @param {string} appId - App id - */ - private ensureAppDir = async (appId: string) => { - const { rootFolderHost } = getEnv(); - - const { appDirPath, repoPath } = this.getAppPaths(appId); - const dockerFilePath = path.join(rootFolderHost, 'apps', appId, 'docker-compose.yml'); - - if (!(await pathExists(dockerFilePath))) { - // delete eventual app folder if exists - this.logger.info(`Deleting app ${appId} folder if exists`); - await fs.promises.rm(appDirPath, { recursive: true, force: true }); - - // Copy app folder from repo - this.logger.info(`Copying app ${appId} from repo ${getEnv().appsRepoId}`); - await fs.promises.cp(repoPath, appDirPath, { recursive: true }); - } + return { queue, queueEvents }; }; - /** - * Install an app from the repo - * @param {string} appId - The id of the app to install - * @param {Record} config - The config of the app - */ - public installApp = async (appId: string, config: Record) => { - try { - if (process.getuid && process.getgid) { - this.logger.info(`Installing app ${appId} as User ID: ${process.getuid()}, Group ID: ${process.getgid()}`); - } else { - this.logger.info(`Installing app ${appId}. No User ID or Group ID found.`); - } - - const { rootFolderHost, appsRepoId } = getEnv(); - - const { appDirPath, repoPath, appDataDirPath } = this.getAppPaths(appId); - - // Check if app exists in repo - const apps = await fs.promises.readdir(path.join(rootFolderHost, 'repos', appsRepoId, 'apps')); - - if (!apps.includes(appId)) { - this.logger.error(`App ${appId} not found in repo ${appsRepoId}`); - return { success: false, message: `App ${appId} not found in repo ${appsRepoId}` }; - } - - // Delete app folder if exists - this.logger.info(`Deleting folder ${appDirPath} if exists`); - await fs.promises.rm(appDirPath, { recursive: true, force: true }); - - // Create app folder - this.logger.info(`Creating folder ${appDirPath}`); - await fs.promises.mkdir(appDirPath, { recursive: true }); - - // Copy app folder from repo - this.logger.info(`Copying folder ${repoPath} to ${appDirPath}`); - await fs.promises.cp(repoPath, appDirPath, { recursive: true }); - - // Create folder app-data folder - this.logger.info(`Creating folder ${appDataDirPath}`); - await fs.promises.mkdir(appDataDirPath, { recursive: true }); - - // Create app.env file - this.logger.info(`Creating app.env file for app ${appId}`); - await generateEnvFile(appId, config); - - // Copy data dir - this.logger.info(`Copying data dir for app ${appId}`); - if (!(await pathExists(`${appDataDirPath}/data`))) { - await copyDataDir(appId); - } - - await execAsync(`chmod -R a+rwx ${path.join(appDataDirPath)}`).catch(() => { - this.logger.error(`Error setting permissions for app ${appId}`); - }); - - // run docker-compose up - this.logger.info(`Running docker-compose up for app ${appId}`); - await compose(appId, 'up -d'); - - this.logger.info(`Docker-compose up for app ${appId} finished`); - - return { success: true, message: `App ${appId} installed successfully` }; - } catch (err) { - return this.handleAppError(err); - } + private generateJobId = (event: Record) => { + const { appId, action } = event; + return `${appId}-${action}`; }; /** * Stops an app * @param {string} appId - The id of the app to stop - * @param {Record} config - The config of the app */ - public stopApp = async (appId: string, config: Record, skipEnvGeneration = false) => { + public stopApp = async (appId: string) => { const spinner = new TerminalSpinner(`Stopping app ${appId}`); + spinner.start(); - try { - spinner.start(); - this.logger.info(`Stopping app ${appId}`); + const jobid = this.generateJobId({ appId, action: 'stop' }); - await this.ensureAppDir(appId); + const { queue, queueEvents } = this.getQueue(); + const event = { type: 'app', command: 'stop', appid: appId, form: {}, skipEnv: true } satisfies SystemEvent; + const job = await queue.add(jobid, eventSchema.parse(event)); + const result = await job.waitUntilFinished(queueEvents, 1000 * 60 * 5); - if (!skipEnvGeneration) { - this.logger.info(`Regenerating app.env file for app ${appId}`); - await generateEnvFile(appId, config); - } - await compose(appId, 'rm --force --stop'); + await queueEvents.close(); + await queue.close(); - this.logger.info(`App ${appId} stopped`); - spinner.done(`App ${appId} stopped`); - return { success: true, message: `App ${appId} stopped successfully` }; - } catch (err) { + if (!result?.success) { + this.logger.error(result?.message); spinner.fail(`Failed to stop app ${appId} see logs for more details (logs/error.log)`); - return this.handleAppError(err); + } else { + spinner.done(`App ${appId} stopped`); } }; - public startApp = async (appId: string, config: Record) => { + public startApp = async (appId: string) => { const spinner = new TerminalSpinner(`Starting app ${appId}`); - try { - spinner.start(); - const { appDataDirPath } = this.getAppPaths(appId); - - this.logger.info(`Starting app ${appId}`); + spinner.start(); - this.logger.info(`Regenerating app.env file for app ${appId}`); - await this.ensureAppDir(appId); - await generateEnvFile(appId, config); + const jobid = this.generateJobId({ appId, action: 'start' }); - await compose(appId, 'up --detach --force-recreate --remove-orphans --pull always'); + const { queue, queueEvents } = this.getQueue(); + const event = { type: 'app', command: 'start', appid: appId, form: {}, skipEnv: true } satisfies SystemEvent; + const job = await queue.add(jobid, eventSchema.parse(event)); + const result = await job.waitUntilFinished(queueEvents, 1000 * 60 * 5); - this.logger.info(`App ${appId} started`); + await queueEvents.close(); + await queue.close(); - this.logger.info(`Setting permissions for app ${appId}`); - await execAsync(`chmod -R a+rwx ${path.join(appDataDirPath)}`).catch(() => { - this.logger.error(`Error setting permissions for app ${appId}`); - }); - - spinner.done(`App ${appId} started`); - return { success: true, message: `App ${appId} started successfully` }; - } catch (err) { + if (!result.success) { spinner.fail(`Failed to start app ${appId} see logs for more details (logs/error.log)`); - return this.handleAppError(err); - } - }; - - public uninstallApp = async (appId: string, config: Record) => { - try { - const { appDirPath, appDataDirPath } = this.getAppPaths(appId); - this.logger.info(`Uninstalling app ${appId}`); - - this.logger.info(`Regenerating app.env file for app ${appId}`); - await this.ensureAppDir(appId); - await generateEnvFile(appId, config); - await compose(appId, 'down --remove-orphans --volumes --rmi all'); - - this.logger.info(`Deleting folder ${appDirPath}`); - await fs.promises.rm(appDirPath, { recursive: true, force: true }).catch((err) => { - this.logger.error(`Error deleting folder ${appDirPath}: ${err.message}`); - }); - - this.logger.info(`Deleting folder ${appDataDirPath}`); - await fs.promises.rm(appDataDirPath, { recursive: true, force: true }).catch((err) => { - this.logger.error(`Error deleting folder ${appDataDirPath}: ${err.message}`); - }); - - this.logger.info(`App ${appId} uninstalled`); - return { success: true, message: `App ${appId} uninstalled successfully` }; - } catch (err) { - return this.handleAppError(err); - } - }; - - public updateApp = async (appId: string, config: Record) => { - try { - const { appDirPath, repoPath } = this.getAppPaths(appId); - this.logger.info(`Updating app ${appId}`); - await this.ensureAppDir(appId); - await generateEnvFile(appId, config); - - await compose(appId, 'up --detach --force-recreate --remove-orphans'); - await compose(appId, 'down --rmi all --remove-orphans'); - - this.logger.info(`Deleting folder ${appDirPath}`); - await fs.promises.rm(appDirPath, { recursive: true, force: true }); - - this.logger.info(`Copying folder ${repoPath} to ${appDirPath}`); - await fs.promises.cp(repoPath, appDirPath, { recursive: true }); - - await compose(appId, 'pull'); - - return { success: true, message: `App ${appId} updated successfully` }; - } catch (err) { - return this.handleAppError(err); - } - }; - - public regenerateAppEnv = async (appId: string, config: Record) => { - try { - this.logger.info(`Regenerating app.env file for app ${appId}`); - await this.ensureAppDir(appId); - await generateEnvFile(appId, config); - return { success: true, message: `App ${appId} env file regenerated successfully` }; - } catch (err) { - return this.handleAppError(err); - } - }; - - /** - * Start all apps with status running - */ - public startAllApps = async () => { - const spinner = new TerminalSpinner('Starting apps...'); - const client = await getDbClient(); - - try { - // Get all apps with status running - const { rows } = await client.query(`SELECT * FROM app WHERE status = 'running'`); - - // Update all apps with status different than running or stopped to stopped - await client.query(`UPDATE app SET status = 'stopped' WHERE status != 'stopped' AND status != 'running' AND status != 'missing'`); - - // Start all apps - for (const row of rows) { - const { id, config } = row; - - const { success } = await this.startApp(id, config); - - if (!success) { - this.logger.error(`Error starting app ${id}`); - await client.query(`UPDATE app SET status = 'stopped' WHERE id = '${id}'`); - } else { - await client.query(`UPDATE app SET status = 'running' WHERE id = '${id}'`); - } - } - } catch (err) { - this.logger.error(`Error starting apps: ${err}`); - spinner.fail(`Error starting apps see logs for details (logs/error.log)`); - } finally { - await client.end(); + } else { + spinner.done(`App ${appId} started`); } }; } diff --git a/packages/cli/src/executors/index.ts b/packages/cli/src/executors/index.ts index d6d749fdbeae488c42468445dea4a3fd0c65e5fc..05bb5cfde37f31d21ca950151dcb45a2edc0bb06 100644 --- a/packages/cli/src/executors/index.ts +++ b/packages/cli/src/executors/index.ts @@ -1,3 +1,2 @@ export { AppExecutors } from './app/app.executors'; -export { RepoExecutors } from './repo/repo.executors'; export { SystemExecutors } from './system/system.executors'; diff --git a/packages/cli/src/executors/repo/repo.helpers.ts b/packages/cli/src/executors/repo/repo.helpers.ts deleted file mode 100644 index 4032bc46349c202b94dbae2143457a9e620b9d0b..0000000000000000000000000000000000000000 --- a/packages/cli/src/executors/repo/repo.helpers.ts +++ /dev/null @@ -1,12 +0,0 @@ -import crypto from 'crypto'; - -/** - * Given a repo url, return a hash of it to be used as a folder name - * - * @param {string} repoUrl - */ -export const getRepoHash = (repoUrl: string) => { - const hash = crypto.createHash('sha256'); - hash.update(repoUrl); - return hash.digest('hex'); -}; diff --git a/packages/cli/src/executors/system/system.executors.ts b/packages/cli/src/executors/system/system.executors.ts index 4cd8cf37fddeb8607fafcbae18ea3d20c86e43fc..c477bd0477e8f95ec561d108cf443a09c0ce5e87 100644 --- a/packages/cli/src/executors/system/system.executors.ts +++ b/packages/cli/src/executors/system/system.executors.ts @@ -1,7 +1,5 @@ /* eslint-disable no-restricted-syntax */ /* eslint-disable no-await-in-loop */ -import { Queue } from 'bullmq'; -import { Redis } from 'ioredis'; import fs from 'fs'; import cliProgress from 'cli-progress'; import semver from 'semver'; @@ -9,20 +7,14 @@ import axios from 'axios'; import boxen from 'boxen'; import path from 'path'; import { spawn } from 'child_process'; -import si from 'systeminformation'; import { Stream } from 'stream'; import dotenv from 'dotenv'; -import { SystemEvent } from '@runtipi/shared'; -import chalk from 'chalk'; -import { killOtherWorkers } from 'src/services/watcher/watcher'; +import { pathExists } from '@runtipi/shared'; import { AppExecutors } from '../app/app.executors'; -import { copySystemFiles, generateSystemEnvFile, generateTlsCertificates } from './system.helpers'; +import { copySystemFiles, generateSystemEnvFile } from './system.helpers'; import { TerminalSpinner } from '@/utils/logger/terminal-spinner'; -import { pathExists } from '@/utils/fs-helpers'; import { getEnv } from '@/utils/environment/environment'; -import { fileLogger } from '@/utils/logger/file-logger'; -import { runPostgresMigrations } from '@/utils/migrations/run-migration'; -import { getUserIds } from '@/utils/environment/user'; +import { logger } from '@/utils/logger/logger'; import { execAsync } from '@/utils/exec-async/execAsync'; export class SystemExecutors { @@ -34,7 +26,7 @@ export class SystemExecutors { constructor() { this.rootFolder = process.cwd(); - this.logger = fileLogger; + this.logger = logger; this.envFile = path.join(this.rootFolder, '.env'); } @@ -49,59 +41,6 @@ export class SystemExecutors { return { success: false, message: `An error occurred: ${err}` }; }; - private getSystemLoad = async () => { - const { currentLoad } = await si.currentLoad(); - const mem = await si.mem(); - const [disk0] = await si.fsSize(); - - return { - cpu: { load: currentLoad }, - memory: { total: mem.total, used: mem.used, available: mem.available }, - disk: { total: disk0?.size, used: disk0?.used, available: disk0?.available }, - }; - }; - - private ensureFilePermissions = async (rootFolderHost: string) => { - const logger = new TerminalSpinner(''); - - const filesAndFolders = [ - path.join(rootFolderHost, 'apps'), - path.join(rootFolderHost, 'logs'), - path.join(rootFolderHost, 'repos'), - path.join(rootFolderHost, 'state'), - path.join(rootFolderHost, 'traefik'), - path.join(rootFolderHost, '.env'), - path.join(rootFolderHost, 'VERSION'), - path.join(rootFolderHost, 'docker-compose.yml'), - ]; - - const files600 = [path.join(rootFolderHost, 'traefik', 'shared', 'acme.json')]; - - this.logger.info('Setting file permissions a+rwx on required files'); - // Give permission to read and write to all files and folders for the current user - for (const fileOrFolder of filesAndFolders) { - if (await pathExists(fileOrFolder)) { - this.logger.info(`Setting permissions on ${fileOrFolder}`); - await execAsync(`chmod -R a+rwx ${fileOrFolder}`).catch(() => { - logger.fail(`Failed to set permissions on ${fileOrFolder}`); - }); - this.logger.info(`Successfully set permissions on ${fileOrFolder}`); - } - } - - this.logger.info('Setting file permissions 600 on required files'); - - for (const fileOrFolder of files600) { - if (await pathExists(fileOrFolder)) { - this.logger.info(`Setting permissions on ${fileOrFolder}`); - await execAsync(`chmod 600 ${fileOrFolder}`).catch(() => { - logger.fail(`Failed to set permissions on ${fileOrFolder}`); - }); - this.logger.info(`Successfully set permissions on ${fileOrFolder}`); - } - } - }; - public cleanLogs = async () => { try { await this.logger.flush(); @@ -113,20 +52,6 @@ export class SystemExecutors { } }; - public systemInfo = async () => { - try { - const { rootFolderHost } = getEnv(); - const systemLoad = await this.getSystemLoad(); - - await fs.promises.writeFile(path.join(rootFolderHost, 'state', 'system-info.json'), JSON.stringify(systemLoad, null, 2)); - await fs.promises.chmod(path.join(rootFolderHost, 'state', 'system-info.json'), 0o777); - - return { success: true, message: '' }; - } catch (e) { - return this.handleSystemError(e); - } - }; - /** * This method will stop Tipi * It will stop all the apps and then stop the main containers. @@ -143,7 +68,7 @@ export class SystemExecutors { for (const app of apps) { spinner.setMessage(`Stopping ${app}...`); spinner.start(); - await appExecutor.stopApp(app, {}, true); + await appExecutor.stopApp(app); spinner.done(`${app} stopped`); } } @@ -167,41 +92,21 @@ export class SystemExecutors { * This method will start Tipi. * It will copy the system files, generate the system env file, pull the images and start the containers. */ - public start = async (sudo = true, killWatchers = true) => { + public start = async () => { const spinner = new TerminalSpinner('Starting Tipi...'); try { await this.logger.flush(); - const { isSudo } = getUserIds(); - - if (!sudo) { - console.log( - boxen( - "You are running in sudoless mode. While Tipi should work as expected, you'll probably run into permission issues and will have to manually fix them. We recommend running Tipi with sudo for beginners.", - { - title: '⛔️Sudoless mode', - titleAlignment: 'center', - textAlignment: 'center', - padding: 1, - borderStyle: 'double', - borderColor: 'red', - margin: { top: 1, bottom: 1 }, - width: 80, - }, - ), - ); - } - - this.logger.info('Killing other workers...'); - - if (killWatchers) { - await killOtherWorkers(); - } + // Check if user is in docker group + spinner.setMessage('Checking docker permissions...'); + spinner.start(); + const { stdout: dockerVersion } = await execAsync('docker --version'); - if (!isSudo && sudo) { - console.log(chalk.red('Tipi needs to run as root to start. Use sudo ./runtipi-cli start')); - throw new Error('Tipi needs to run as root to start. Use sudo ./runtipi-cli start'); + if (!dockerVersion) { + spinner.fail('Your user is not allowed to run docker commands. Please add your user to the docker group or run Tipi as root.'); + return { success: false, message: 'You need to be in the docker group to run Tipi' }; } + spinner.done('User allowed to run docker commands'); spinner.setMessage('Copying system files...'); spinner.start(); @@ -211,10 +116,6 @@ export class SystemExecutors { spinner.done('System files copied'); - if (sudo) { - await this.ensureFilePermissions(this.rootFolder); - } - spinner.setMessage('Generating system env file...'); spinner.start(); this.logger.info('Generating system env file...'); @@ -241,66 +142,6 @@ export class SystemExecutors { await execAsync(`docker compose --env-file ${this.envFile} up --detach --remove-orphans --build`); spinner.done('Containers started'); - // start watcher cli in the background - spinner.setMessage('Starting watcher...'); - spinner.start(); - - this.logger.info('Generating TLS certificates...'); - await generateTlsCertificates({ domain: envMap.get('LOCAL_DOMAIN') }); - - if (killWatchers) { - this.logger.info('Starting watcher...'); - const subprocess = spawn('./runtipi-cli', [process.argv[1] as string, 'watch'], { cwd: this.rootFolder, detached: true, stdio: ['ignore', 'ignore', 'ignore'] }); - subprocess.unref(); - } - - spinner.done('Watcher started'); - - // Flush redis cache - this.logger.info('Flushing redis cache...'); - const cache = new Redis({ host: '127.0.0.1', port: 6379, password: envMap.get('REDIS_PASSWORD'), lazyConnect: true }); - await cache.connect(); - await cache.flushdb(); - await cache.quit(); - - this.logger.info('Starting queue...'); - const queue = new Queue('events', { connection: { host: '127.0.0.1', port: 6379, password: envMap.get('REDIS_PASSWORD') } }); - this.logger.info('Obliterating queue...'); - await queue.obliterate({ force: true }); - - // Initial jobs - this.logger.info('Adding initial jobs to queue...'); - await queue.add(`${Math.random().toString()}_system_info`, { type: 'system', command: 'system_info' } as SystemEvent); - await queue.add(`${Math.random().toString()}_repo_clone`, { type: 'repo', command: 'clone', url: envMap.get('APPS_REPO_URL') } as SystemEvent); - await queue.add(`${Math.random().toString()}_repo_update`, { type: 'repo', command: 'update', url: envMap.get('APPS_REPO_URL') } as SystemEvent); - - // Scheduled jobs - this.logger.info('Adding scheduled jobs to queue...'); - await queue.add(`${Math.random().toString()}_repo_update`, { type: 'repo', command: 'update', url: envMap.get('APPS_REPO_URL') } as SystemEvent, { repeat: { pattern: '*/30 * * * *' } }); - await queue.add(`${Math.random().toString()}_system_info`, { type: 'system', command: 'system_info' } as SystemEvent, { repeat: { pattern: '* * * * *' } }); - - this.logger.info('Closing queue...'); - await queue.close(); - - spinner.setMessage('Running database migrations...'); - spinner.start(); - - this.logger.info('Running database migrations...'); - await runPostgresMigrations({ - postgresHost: '127.0.0.1', - postgresDatabase: envMap.get('POSTGRES_DBNAME') as string, - postgresUsername: envMap.get('POSTGRES_USERNAME') as string, - postgresPassword: envMap.get('POSTGRES_PASSWORD') as string, - postgresPort: envMap.get('POSTGRES_PORT') as string, - }); - - spinner.done('Database migrations complete'); - - // Start all apps - const appExecutor = new AppExecutors(); - this.logger.info('Starting all apps...'); - await appExecutor.startAllApps(); - console.log( boxen( `Visit: http://${envMap.get('INTERNAL_IP')}:${envMap.get( @@ -332,7 +173,7 @@ export class SystemExecutors { public restart = async () => { try { await this.stop(); - await this.start(true, false); + await this.start(); return { success: true, message: '' }; } catch (e) { return this.handleSystemError(e); diff --git a/packages/cli/src/executors/system/system.helpers.ts b/packages/cli/src/executors/system/system.helpers.ts index a4fb69b94e4c8d36dceb64d167626f707fbe30da..28a6fb7e14cb080b43952efdd0d3a2edfb3b3117 100644 --- a/packages/cli/src/executors/system/system.helpers.ts +++ b/packages/cli/src/executors/system/system.helpers.ts @@ -2,12 +2,8 @@ import crypto from 'crypto'; import fs from 'fs'; import path from 'path'; import os from 'os'; -import { envMapToString, envStringToMap, settingsSchema } from '@runtipi/shared'; -import chalk from 'chalk'; -import { pathExists } from '@/utils/fs-helpers'; -import { getRepoHash } from '../repo/repo.helpers'; -import { fileLogger } from '@/utils/logger/file-logger'; -import { execAsync } from '@/utils/exec-async/execAsync'; +import { envMapToString, envStringToMap, pathExists, settingsSchema } from '@runtipi/shared'; +import { logger } from '@/utils/logger/logger'; type EnvKeys = | 'APPS_REPO_ID' @@ -38,9 +34,6 @@ type EnvKeys = // eslint-disable-next-line @typescript-eslint/ban-types | (string & {}); -const OLD_DEFAULT_REPO_URL = 'https://github.com/meienberger/runtipi-appstore'; -const DEFAULT_REPO_URL = 'https://github.com/runtipi/runtipi-appstore'; - /** * Reads and returns the generated seed */ @@ -147,173 +140,43 @@ export const generateSystemEnvFile = async () => { const { data } = settings; - if (data.appsRepoUrl === OLD_DEFAULT_REPO_URL) { - data.appsRepoUrl = DEFAULT_REPO_URL; - } - - const jwtSecret = envMap.get('JWT_SECRET') || (await deriveEntropy('jwt_secret')); - const repoId = getRepoHash(data.appsRepoUrl || DEFAULT_REPO_URL); const postgresPassword = envMap.get('POSTGRES_PASSWORD') || (await deriveEntropy('postgres_password')); const redisPassword = envMap.get('REDIS_PASSWORD') || (await deriveEntropy('redis_password')); const version = await fs.promises.readFile(path.join(rootFolder, 'VERSION'), 'utf-8'); - envMap.set('APPS_REPO_ID', repoId); - envMap.set('APPS_REPO_URL', data.appsRepoUrl || DEFAULT_REPO_URL); - envMap.set('TZ', Intl.DateTimeFormat().resolvedOptions().timeZone); envMap.set('INTERNAL_IP', data.listenIp || getInternalIp()); - envMap.set('DNS_IP', data.dnsIp || '9.9.9.9'); envMap.set('ARCHITECTURE', getArchitecture()); envMap.set('TIPI_VERSION', version); - envMap.set('JWT_SECRET', jwtSecret); envMap.set('ROOT_FOLDER_HOST', rootFolder); envMap.set('NGINX_PORT', String(data.port || 80)); envMap.set('NGINX_PORT_SSL', String(data.sslPort || 443)); - envMap.set('DOMAIN', data.domain || 'example.com'); envMap.set('STORAGE_PATH', data.storagePath || rootFolder); - envMap.set('POSTGRES_HOST', 'tipi-db'); - envMap.set('POSTGRES_DBNAME', 'tipi'); - envMap.set('POSTGRES_USERNAME', 'tipi'); envMap.set('POSTGRES_PASSWORD', postgresPassword); - envMap.set('POSTGRES_PORT', String(5432)); + envMap.set('POSTGRES_PORT', String(data.postgresPort || 5432)); envMap.set('REDIS_HOST', 'tipi-redis'); envMap.set('REDIS_PASSWORD', redisPassword); - envMap.set('DEMO_MODE', String(data.demoMode || 'false')); - envMap.set('GUEST_DASHBOARD', String(data.guestDashboard || 'false')); - envMap.set('LOCAL_DOMAIN', data.localDomain || 'tipi.lan'); envMap.set('NODE_ENV', 'production'); - - const currentUserGroup = process.getgid ? String(process.getgid()) : '1000'; - const currentUserId = process.getuid ? String(process.getuid()) : '1000'; - - envMap.set('TIPI_GID', currentUserGroup); - envMap.set('TIPI_UID', currentUserId); + envMap.set('DOMAIN', data.domain || 'example.com'); + envMap.set('LOCAL_DOMAIN', data.localDomain || 'tipi.lan'); await fs.promises.writeFile(envFilePath, envMapToString(envMap)); return envMap; }; -/** - * Sets the value of an environment variable in the .env file - * - * @param {string} key - The key of the environment variable - * @param {string} value - The value of the environment variable - */ -export const setEnvVariable = async (key: EnvKeys, value: string) => { - const rootFolder = process.cwd(); - - const envFilePath = path.join(rootFolder, '.env'); - - if (!(await pathExists(envFilePath))) { - await fs.promises.writeFile(envFilePath, ''); - } - - const envFile = await fs.promises.readFile(envFilePath, 'utf-8'); - const envMap: Map = envStringToMap(envFile); - - envMap.set(key, value); - - await fs.promises.writeFile(envFilePath, envMapToString(envMap)); -}; - /** * Copies the system files from the assets folder to the current working directory */ export const copySystemFiles = async () => { // Remove old unused files - if (await pathExists(path.join(process.cwd(), 'scripts'))) { - fileLogger.info('Removing old scripts folder'); - await fs.promises.rmdir(path.join(process.cwd(), 'scripts'), { recursive: true }); - } - const assetsFolder = path.join('/snapshot', 'runtipi', 'packages', 'cli', 'assets'); // Copy docker-compose.yml file - fileLogger.info('Copying file docker-compose.yml'); + logger.info('Copying file docker-compose.yml'); await fs.promises.copyFile(path.join(assetsFolder, 'docker-compose.yml'), path.join(process.cwd(), 'docker-compose.yml')); // Copy VERSION file - fileLogger.info('Copying file VERSION'); + logger.info('Copying file VERSION'); await fs.promises.copyFile(path.join(assetsFolder, 'VERSION'), path.join(process.cwd(), 'VERSION')); - - // Copy traefik folder from assets - fileLogger.info('Creating traefik folders'); - await fs.promises.mkdir(path.join(process.cwd(), 'traefik', 'dynamic'), { recursive: true }); - await fs.promises.mkdir(path.join(process.cwd(), 'traefik', 'shared'), { recursive: true }); - await fs.promises.mkdir(path.join(process.cwd(), 'traefik', 'tls'), { recursive: true }); - - fileLogger.info('Copying traefik files'); - await fs.promises.copyFile(path.join(assetsFolder, 'traefik', 'traefik.yml'), path.join(process.cwd(), 'traefik', 'traefik.yml')); - await fs.promises.copyFile(path.join(assetsFolder, 'traefik', 'dynamic', 'dynamic.yml'), path.join(process.cwd(), 'traefik', 'dynamic', 'dynamic.yml')); - - // Create base folders - fileLogger.info('Creating base folders'); - await fs.promises.mkdir(path.join(process.cwd(), 'apps'), { recursive: true }); - await fs.promises.mkdir(path.join(process.cwd(), 'app-data'), { recursive: true }); - await fs.promises.mkdir(path.join(process.cwd(), 'state'), { recursive: true }); - await fs.promises.mkdir(path.join(process.cwd(), 'repos'), { recursive: true }); - - // Create media folders - fileLogger.info('Creating media folders'); - await fs.promises.mkdir(path.join(process.cwd(), 'media', 'torrents', 'watch'), { recursive: true }); - await fs.promises.mkdir(path.join(process.cwd(), 'media', 'torrents', 'complete'), { recursive: true }); - await fs.promises.mkdir(path.join(process.cwd(), 'media', 'torrents', 'incomplete'), { recursive: true }); - - await fs.promises.mkdir(path.join(process.cwd(), 'media', 'usenet', 'watch'), { recursive: true }); - await fs.promises.mkdir(path.join(process.cwd(), 'media', 'usenet', 'complete'), { recursive: true }); - await fs.promises.mkdir(path.join(process.cwd(), 'media', 'usenet', 'incomplete'), { recursive: true }); - - await fs.promises.mkdir(path.join(process.cwd(), 'media', 'downloads', 'watch'), { recursive: true }); - await fs.promises.mkdir(path.join(process.cwd(), 'media', 'downloads', 'complete'), { recursive: true }); - await fs.promises.mkdir(path.join(process.cwd(), 'media', 'downloads', 'incomplete'), { recursive: true }); - - await fs.promises.mkdir(path.join(process.cwd(), 'media', 'data', 'books'), { recursive: true }); - await fs.promises.mkdir(path.join(process.cwd(), 'media', 'data', 'comics'), { recursive: true }); - await fs.promises.mkdir(path.join(process.cwd(), 'media', 'data', 'movies'), { recursive: true }); - await fs.promises.mkdir(path.join(process.cwd(), 'media', 'data', 'music'), { recursive: true }); - await fs.promises.mkdir(path.join(process.cwd(), 'media', 'data', 'tv'), { recursive: true }); - await fs.promises.mkdir(path.join(process.cwd(), 'media', 'data', 'podcasts'), { recursive: true }); - await fs.promises.mkdir(path.join(process.cwd(), 'media', 'data', 'images'), { recursive: true }); - await fs.promises.mkdir(path.join(process.cwd(), 'media', 'data', 'roms'), { recursive: true }); -}; - -/** - * Given a domain, generates the TLS certificates for it to be used with Traefik - * - * @param {string} data.domain The domain to generate the certificates for - */ -export const generateTlsCertificates = async (data: { domain?: string }) => { - if (!data.domain) { - return; - } - - // If the certificate already exists, don't generate it again - if (await pathExists(path.join(process.cwd(), 'traefik', 'tls', `${data.domain}.txt`))) { - fileLogger.info(`TLS certificate for ${data.domain} already exists`); - return; - } - - // Remove old certificates - if (await pathExists(path.join(process.cwd(), 'traefik', 'tls', 'cert.pem'))) { - fileLogger.info('Removing old TLS certificate'); - await fs.promises.unlink(path.join(process.cwd(), 'traefik', 'tls', 'cert.pem')); - } - if (await pathExists(path.join(process.cwd(), 'traefik', 'tls', 'key.pem'))) { - fileLogger.info('Removing old TLS key'); - await fs.promises.unlink(path.join(process.cwd(), 'traefik', 'tls', 'key.pem')); - } - - const subject = `/O=runtipi.io/OU=IT/CN=*.${data.domain}/emailAddress=webmaster@${data.domain}`; - const subjectAltName = `DNS:*.${data.domain},DNS:${data.domain}`; - - try { - fileLogger.info(`Generating TLS certificate for ${data.domain}`); - await execAsync(`openssl req -x509 -newkey rsa:4096 -keyout traefik/tls/key.pem -out traefik/tls/cert.pem -days 365 -subj "${subject}" -addext "subjectAltName = ${subjectAltName}" -nodes`); - fileLogger.info(`Writing txt file for ${data.domain}`); - await fs.promises.writeFile(path.join(process.cwd(), 'traefik', 'tls', `${data.domain}.txt`), ''); - } catch (error) { - fileLogger.error(error); - console.error(chalk.red('βœ—'), 'Failed to generate TLS certificates'); - } }; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index a02cbaa7087a9a342be9d2c7fdc9a76565360e77..f73affbb448556d78a040da523010bc38536d0ed 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -3,7 +3,6 @@ import { program } from 'commander'; import chalk from 'chalk'; import { description, version } from '../package.json'; -import { startWorker } from './services/watcher/watcher'; import { AppExecutors, SystemExecutors } from './executors'; const main = async () => { @@ -11,22 +10,13 @@ const main = async () => { program.name('./runtipi-cli').usage(' [options]'); - program - .command('watch') - .description('Watcher script for events queue') - .action(async () => { - console.log('Starting watcher'); - startWorker(); - }); - program .command('start') .description('Start tipi') .addHelpText('after', '\nExample call: sudo ./runtipi-cli start') - .option('--no-sudo', 'Skip sudo usage') - .action(async (options) => { + .action(async () => { const systemExecutors = new SystemExecutors(); - await systemExecutors.start(options.sudo); + await systemExecutors.start(); }); program @@ -81,10 +71,10 @@ const main = async () => { const appExecutors = new AppExecutors(); switch (command) { case 'start': - await appExecutors.startApp(app, {}); + await appExecutors.startApp(app); break; case 'stop': - await appExecutors.stopApp(app, {}, true); + await appExecutors.stopApp(app); break; default: console.log(chalk.red('βœ—'), 'Unknown command'); diff --git a/packages/cli/src/utils/environment/user.ts b/packages/cli/src/utils/environment/user.ts deleted file mode 100644 index 56ba7771bea6aae35c9145185f65907188d31b00..0000000000000000000000000000000000000000 --- a/packages/cli/src/utils/environment/user.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Returns the user id and group id of the current user - */ -export const getUserIds = () => { - if (process.getgid && process.getuid) { - const isSudo = process.getgid() === 0 && process.getuid() === 0; - - return { uid: process.getuid(), gid: process.getgid(), isSudo }; - } - - return { uid: 1000, gid: 1000, isSudo: false }; -}; diff --git a/packages/cli/src/utils/logger/logger.ts b/packages/cli/src/utils/logger/logger.ts new file mode 100644 index 0000000000000000000000000000000000000000..f731e3385653aa7ea7b2235a7d65e06c22ca8a54 --- /dev/null +++ b/packages/cli/src/utils/logger/logger.ts @@ -0,0 +1,4 @@ +import { FileLogger } from '@runtipi/shared'; +import path from 'node:path'; + +export const logger = new FileLogger('cli', path.join(process.cwd(), 'logs')); diff --git a/packages/cli/src/utils/fs-helpers/fs-helpers.ts b/packages/shared/src/helpers/fs-helpers/fs-helpers.ts similarity index 100% rename from packages/cli/src/utils/fs-helpers/fs-helpers.ts rename to packages/shared/src/helpers/fs-helpers/fs-helpers.ts diff --git a/packages/cli/src/utils/fs-helpers/index.ts b/packages/shared/src/helpers/fs-helpers/index.ts similarity index 100% rename from packages/cli/src/utils/fs-helpers/index.ts rename to packages/shared/src/helpers/fs-helpers/index.ts diff --git a/packages/shared/src/helpers/index.ts b/packages/shared/src/helpers/index.ts index d719393274ed3e9a191e772f613a90b81f0f0a77..5da7f7263f63435a6563c2feab8750c31cf4d49e 100644 --- a/packages/shared/src/helpers/index.ts +++ b/packages/shared/src/helpers/index.ts @@ -1 +1,2 @@ export * from './env-helpers'; +export * from './fs-helpers'; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index c8f15517c409fcd086d2f10c545b016cabe898ee..6c5420e32785ff1723e657c750c5005311fe7bc7 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,3 +1,5 @@ export * from './schemas'; export * from './helpers'; export { createLogger } from './utils/logger'; +export { FileLogger } from './lib/FileLogger'; +export { execAsync } from './lib/exec-async'; diff --git a/packages/cli/src/utils/logger/file-logger.ts b/packages/shared/src/lib/FileLogger/FileLogger.ts similarity index 83% rename from packages/cli/src/utils/logger/file-logger.ts rename to packages/shared/src/lib/FileLogger/FileLogger.ts index 446f4aa03fb046e68fd3e6e5cb5d2da7ab7ad2cc..08d4f739843ec65da78f2047a4ccdd990770b539 100644 --- a/packages/cli/src/utils/logger/file-logger.ts +++ b/packages/shared/src/lib/FileLogger/FileLogger.ts @@ -1,6 +1,6 @@ import fs from 'fs'; -import { createLogger } from '@runtipi/shared'; import path from 'path'; +import { createLogger } from '../../utils/logger'; function streamLogToHistory(logsFolder: string, logFile: string) { return new Promise((resolve, reject) => { @@ -19,10 +19,15 @@ function streamLogToHistory(logsFolder: string, logFile: string) { }); } -class FileLogger { - private winstonLogger = createLogger('cli', path.join(process.cwd(), 'logs')); +export class FileLogger { + private winstonLogger; - private logsFolder = path.join(process.cwd(), 'logs'); + private logsFolder; + + constructor(name: string, folder: string, console?: boolean) { + this.winstonLogger = createLogger(name, folder, console); + this.logsFolder = folder; + } public flush = async () => { try { @@ -54,5 +59,3 @@ class FileLogger { this.winstonLogger.debug(message.join(' ')); }; } - -export const fileLogger = new FileLogger(); diff --git a/packages/shared/src/lib/FileLogger/index.ts b/packages/shared/src/lib/FileLogger/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..214e1c2a8753cf1f8871cd0b2c691de00906d920 --- /dev/null +++ b/packages/shared/src/lib/FileLogger/index.ts @@ -0,0 +1 @@ +export { FileLogger } from './FileLogger'; diff --git a/packages/shared/src/lib/exec-async/execAsync.tsx b/packages/shared/src/lib/exec-async/execAsync.tsx new file mode 100644 index 0000000000000000000000000000000000000000..aecf77f690beb9a47a791cac5a65ee23f9f79a0d --- /dev/null +++ b/packages/shared/src/lib/exec-async/execAsync.tsx @@ -0,0 +1,20 @@ +import { exec } from 'child_process'; +import { promisify } from 'util'; + +type ExecAsyncParams = [command: string]; + +type ExecResult = { stdout: string; stderr: string }; + +export const execAsync = async (...args: ExecAsyncParams): Promise => { + try { + const { stdout, stderr } = await promisify(exec)(...args); + + return { stdout, stderr }; + } catch (error) { + if (error instanceof Error) { + return { stderr: error.message, stdout: '' }; + } + + return { stderr: String(error), stdout: '' }; + } +}; diff --git a/packages/shared/src/lib/exec-async/index.ts b/packages/shared/src/lib/exec-async/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..0d927bc22bc5b231e78fc262450432a7397b81d0 --- /dev/null +++ b/packages/shared/src/lib/exec-async/index.ts @@ -0,0 +1 @@ +export { execAsync } from './execAsync'; diff --git a/packages/shared/src/schemas/env-schemas.ts b/packages/shared/src/schemas/env-schemas.ts index 5e8cee944a717c69b215e1ebe169f7d7431f8406..122fc0b000dd486bb6fb219d2a6cccbb9400fcb1 100644 --- a/packages/shared/src/schemas/env-schemas.ts +++ b/packages/shared/src/schemas/env-schemas.ts @@ -11,7 +11,6 @@ export const envSchema = z.object({ NODE_ENV: z.union([z.literal('development'), z.literal('production'), z.literal('test')]), REDIS_HOST: z.string(), redisPassword: z.string(), - status: z.union([z.literal('RUNNING'), z.literal('UPDATING'), z.literal('RESTARTING')]), architecture: z.nativeEnum(ARCHITECTURES), dnsIp: z.string().ip().trim(), rootFolder: z.string(), @@ -59,9 +58,17 @@ export const envSchema = z.object({ if (typeof value === 'boolean') return value; return value === 'true'; }), + allowAutoThemes: z + .string() + .or(z.boolean()) + .optional() + .transform((value) => { + if (typeof value === 'boolean') return value; + return value === 'true'; + }), }); export const settingsSchema = envSchema .partial() - .pick({ dnsIp: true, internalIp: true, appsRepoUrl: true, domain: true, storagePath: true, localDomain: true, demoMode: true, guestDashboard: true }) + .pick({ dnsIp: true, internalIp: true, postgresPort: true, appsRepoUrl: true, domain: true, storagePath: true, localDomain: true, demoMode: true, guestDashboard: true, allowAutoThemes: true }) .and(z.object({ port: z.number(), sslPort: z.number(), listenIp: z.string().ip().trim() }).partial()); diff --git a/packages/shared/src/schemas/queue-schemas.ts b/packages/shared/src/schemas/queue-schemas.ts index 22eb5f3d6a6ef2dc6689cbee522be5d89be4b125..d647710e3eb6a27d3dc0f42f126102e0a605d331 100644 --- a/packages/shared/src/schemas/queue-schemas.ts +++ b/packages/shared/src/schemas/queue-schemas.ts @@ -12,6 +12,7 @@ const appCommandSchema = z.object({ type: z.literal(EVENT_TYPES.APP), command: z.union([z.literal('start'), z.literal('stop'), z.literal('install'), z.literal('uninstall'), z.literal('update'), z.literal('generate_env')]), appid: z.string(), + skipEnv: z.boolean().optional().default(false), form: z.object({}).catchall(z.any()), }); @@ -23,20 +24,14 @@ const repoCommandSchema = z.object({ const systemCommandSchema = z.object({ type: z.literal(EVENT_TYPES.SYSTEM), - command: z.union([z.literal('restart'), z.literal('system_info')]), + command: z.literal('system_info'), }); -const updateSchema = z.object({ - type: z.literal(EVENT_TYPES.SYSTEM), - command: z.literal('update'), - version: z.string(), -}); - -export const eventSchema = appCommandSchema.or(repoCommandSchema).or(systemCommandSchema).or(updateSchema); +export const eventSchema = appCommandSchema.or(repoCommandSchema).or(systemCommandSchema); export const eventResultSchema = z.object({ success: z.boolean(), stdout: z.string(), }); -export type SystemEvent = z.infer; +export type SystemEvent = z.input; diff --git a/packages/shared/src/utils/logger/Logger.ts b/packages/shared/src/utils/logger/Logger.ts index 72c6cbf1a003102b3d2342a0a81391ec5f94929c..fb1041eafa8b6a2c7c69489d3b9c6c20ba144c74 100644 --- a/packages/shared/src/utils/logger/Logger.ts +++ b/packages/shared/src/utils/logger/Logger.ts @@ -12,7 +12,7 @@ type Transports = transports.ConsoleTransportInstance | transports.FileTransport * @param {string} id - The id of the logger, used to identify the logger in the logs * @param {string} logsFolder - The folder where the logs will be stored */ -export const newLogger = (id: string, logsFolder: string) => { +export const newLogger = (id: string, logsFolder: string, console?: boolean) => { if (!fs.existsSync(logsFolder)) { fs.mkdirSync(logsFolder, { recursive: true }); } @@ -36,6 +36,8 @@ export const newLogger = (id: string, logsFolder: string) => { if (process.env.NODE_ENV === 'development') { tr.push(new transports.Console({ level: 'debug' })); + } else if (console) { + tr.push(new transports.Console({ level: 'info' })); } return createLogger({ diff --git a/packages/worker/.env.test b/packages/worker/.env.test new file mode 100644 index 0000000000000000000000000000000000000000..5e05045d92b009972d1ae107b9ce6d0172a4c8a1 --- /dev/null +++ b/packages/worker/.env.test @@ -0,0 +1,14 @@ +INTERNAL_IP=localhost +ARCHITECTURE=arm64 +APPS_REPO_ID=repo-id +APPS_REPO_URL=https://test.com/test +ROOT_FOLDER_HOST=/runtipi +STORAGE_PATH=/runtipi +TIPI_VERSION=1 +REDIS_PASSWORD=redis +REDIS_HOST=localhost +POSTGRES_HOST=localhost +POSTGRES_DBNAME=postgres +POSTGRES_USERNAME=postgres +POSTGRES_PASSWORD=postgres +POSTGRES_PORT=5433 diff --git a/packages/worker/.eslintrc.js b/packages/worker/.eslintrc.js new file mode 100644 index 0000000000000000000000000000000000000000..13f66352677e577d4f7a9f5cf90cca00c8de841f --- /dev/null +++ b/packages/worker/.eslintrc.js @@ -0,0 +1,39 @@ +module.exports = { + root: true, + plugins: ['@typescript-eslint', 'import'], + extends: ['plugin:@typescript-eslint/recommended', 'airbnb', 'airbnb-typescript', 'eslint:recommended', 'plugin:import/typescript', 'prettier'], + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + project: './tsconfig.json', + tsconfigRootDir: __dirname, + }, + rules: { + 'import/prefer-default-export': 0, + 'class-methods-use-this': 0, + 'import/extensions': [ + 'error', + 'ignorePackages', + { + '': 'never', + js: 'never', + jsx: 'never', + ts: 'never', + tsx: 'never', + }, + ], + 'import/no-extraneous-dependencies': [ + 'error', + { + devDependencies: ['build.js', '**/*.test.{ts,tsx}', '**/mocks/**', '**/__mocks__/**', '**/*.setup.{ts,js}', '**/*.config.{ts,js}', '**/tests/**'], + }, + ], + 'arrow-body-style': 0, + 'no-underscore-dangle': 0, + 'no-console': 0, + }, + globals: { + NodeJS: true, + }, +}; diff --git a/packages/worker/.gitignore b/packages/worker/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..a60030e3cbb16893d47710e64a0759386c6c9cfc --- /dev/null +++ b/packages/worker/.gitignore @@ -0,0 +1,2 @@ +dist/ +coverage/ diff --git a/packages/worker/.prettierrc.js b/packages/worker/.prettierrc.js new file mode 100644 index 0000000000000000000000000000000000000000..18502e8fec5a75f508260a89ec4e0db37e415fb6 --- /dev/null +++ b/packages/worker/.prettierrc.js @@ -0,0 +1,6 @@ +module.exports = { + singleQuote: true, + semi: true, + trailingComma: 'all', + printWidth: 200, +}; diff --git a/packages/worker/Dockerfile b/packages/worker/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..a70ada2991d4d29d8ea62646787368e20aa5ca0f --- /dev/null +++ b/packages/worker/Dockerfile @@ -0,0 +1,67 @@ +ARG NODE_VERSION="18.16" +ARG ALPINE_VERSION="3.18" + +FROM node:${NODE_VERSION}-alpine${ALPINE_VERSION} AS node_base + +# ---- BUILDER BASE ---- +FROM node_base AS builder_base + +RUN npm install pnpm -g +RUN apk add curl + +# ---- RUNNER BASE ---- +FROM node_base AS runner_base + +RUN apk add curl openssl git && rm -rf /var/cache/apk/* + +ARG NODE_ENV="production" + +# ---- BUILDER ---- +FROM builder_base AS builder + +WORKDIR /app + +ARG TARGETARCH +ENV TARGETARCH=${TARGETARCH} + +RUN echo "Building for ${TARGETARCH}" + +RUN if [ "${TARGETARCH}" = "arm64" ]; then \ + curl -L -o docker-binary "https://github.com/docker/compose/releases/download/v2.23.1/docker-compose-linux-aarch64"; \ + elif [ "${TARGETARCH}" = "amd64" ]; then \ + curl -L -o docker-binary "https://github.com/docker/compose/releases/download/v2.23.1/docker-compose-linux-x86_64"; \ + else \ + echo "Unsupported architecture"; \ + fi + +RUN chmod +x docker-binary + +COPY ./pnpm-lock.yaml ./ +COPY ./pnpm-workspace.yaml ./ +COPY ./patches ./patches +RUN pnpm fetch --no-scripts + +COPY ./packages ./packages + +RUN pnpm install -r --prefer-offline + +COPY ./packages/worker/build.js ./packages/worker/build.js +COPY ./packages/worker/src ./packages/worker/src +COPY ./packages/worker/package.json ./packages/worker/package.json +COPY ./packages/worker/assets ./packages/worker/assets + +RUN pnpm -r build --filter @runtipi/worker + +# ---- RUNNER ---- +FROM runner_base AS app + +WORKDIR /app + +ENV NODE_ENV=production + +COPY --from=builder /app/packages/worker/dist . +COPY --from=builder /app/packages/worker/assets ./assets +COPY --from=builder /app/docker-binary /usr/local/bin/docker-compose + +CMD ["node", "index.js", "start"] + diff --git a/packages/worker/Dockerfile.dev b/packages/worker/Dockerfile.dev new file mode 100644 index 0000000000000000000000000000000000000000..0347c5ab1d59c16c916c72b4f8166c62c5d167f2 --- /dev/null +++ b/packages/worker/Dockerfile.dev @@ -0,0 +1,41 @@ +ARG NODE_VERSION="18.16" +ARG ALPINE_VERSION="3.18" + +FROM node:${NODE_VERSION}-alpine${ALPINE_VERSION} AS node_base + +# Install docker +RUN apk upgrade --update-cache --available && \ + apk add openssl git docker docker-cli-compose curl && \ + rm -rf /var/cache/apk/* + +ARG TARGETARCH +ENV TARGETARCH=${TARGETARCH} + +RUN echo "Building for ${TARGETARCH}" + +RUN if [ "${TARGETARCH}" = "arm64" ]; then \ + curl -L -o docker-binary "https://github.com/docker/compose/releases/download/v2.23.1/docker-compose-linux-aarch64"; \ + elif [ "${TARGETARCH}" = "amd64" ]; then \ + curl -L -o docker-binary "https://github.com/docker/compose/releases/download/v2.23.1/docker-compose-linux-x86_64"; \ + fi + +RUN chmod +x docker-binary + +RUN mv docker-binary /usr/local/bin/docker-compose + +RUN npm install pnpm -g + +WORKDIR /app + +COPY ./pnpm-lock.yaml ./ +COPY ./pnpm-workspace.yaml ./ +COPY ./patches ./patches +RUN pnpm fetch --no-scripts + +COPY ./packages/worker/assets ./assets +COPY ./packages ./packages + +RUN pnpm install -r --prefer-offline + +CMD ["pnpm", "--filter", "@runtipi/worker", "-r", "dev"] + diff --git a/packages/cli/assets/migrations/00000-create-migrations-table.sql b/packages/worker/assets/migrations/00000-create-migrations-table.sql similarity index 100% rename from packages/cli/assets/migrations/00000-create-migrations-table.sql rename to packages/worker/assets/migrations/00000-create-migrations-table.sql diff --git a/packages/cli/assets/migrations/00001-initial.sql b/packages/worker/assets/migrations/00001-initial.sql similarity index 100% rename from packages/cli/assets/migrations/00001-initial.sql rename to packages/worker/assets/migrations/00001-initial.sql diff --git a/packages/cli/assets/migrations/00002-add-app-version.sql b/packages/worker/assets/migrations/00002-add-app-version.sql similarity index 100% rename from packages/cli/assets/migrations/00002-add-app-version.sql rename to packages/worker/assets/migrations/00002-add-app-version.sql diff --git a/packages/cli/assets/migrations/00003-add-status-updating.sql b/packages/worker/assets/migrations/00003-add-status-updating.sql similarity index 100% rename from packages/cli/assets/migrations/00003-add-status-updating.sql rename to packages/worker/assets/migrations/00003-add-status-updating.sql diff --git a/packages/cli/assets/migrations/00004-add-exposed-domain-fields.sql b/packages/worker/assets/migrations/00004-add-exposed-domain-fields.sql similarity index 100% rename from packages/cli/assets/migrations/00004-add-exposed-domain-fields.sql rename to packages/worker/assets/migrations/00004-add-exposed-domain-fields.sql diff --git a/packages/cli/assets/migrations/00005-add-user-operator.sql b/packages/worker/assets/migrations/00005-add-user-operator.sql similarity index 100% rename from packages/cli/assets/migrations/00005-add-user-operator.sql rename to packages/worker/assets/migrations/00005-add-user-operator.sql diff --git a/packages/cli/assets/migrations/00006-add-totp-user-fields.sql b/packages/worker/assets/migrations/00006-add-totp-user-fields.sql similarity index 100% rename from packages/cli/assets/migrations/00006-add-totp-user-fields.sql rename to packages/worker/assets/migrations/00006-add-totp-user-fields.sql diff --git a/packages/cli/assets/migrations/00007-add-locale-user-col.sql b/packages/worker/assets/migrations/00007-add-locale-user-col.sql similarity index 100% rename from packages/cli/assets/migrations/00007-add-locale-user-col.sql rename to packages/worker/assets/migrations/00007-add-locale-user-col.sql diff --git a/packages/cli/assets/migrations/00008-merge-config-with-domain-and-exposed.sql b/packages/worker/assets/migrations/00008-merge-config-with-domain-and-exposed.sql similarity index 100% rename from packages/cli/assets/migrations/00008-merge-config-with-domain-and-exposed.sql rename to packages/worker/assets/migrations/00008-merge-config-with-domain-and-exposed.sql diff --git a/packages/cli/assets/migrations/00009-add-guest-dashboard.sql b/packages/worker/assets/migrations/00009-add-guest-dashboard.sql similarity index 100% rename from packages/cli/assets/migrations/00009-add-guest-dashboard.sql rename to packages/worker/assets/migrations/00009-add-guest-dashboard.sql diff --git a/packages/cli/assets/traefik/dynamic/dynamic.yml b/packages/worker/assets/traefik/dynamic/dynamic.yml similarity index 100% rename from packages/cli/assets/traefik/dynamic/dynamic.yml rename to packages/worker/assets/traefik/dynamic/dynamic.yml diff --git a/packages/cli/assets/traefik/traefik.yml b/packages/worker/assets/traefik/traefik.yml similarity index 77% rename from packages/cli/assets/traefik/traefik.yml rename to packages/worker/assets/traefik/traefik.yml index 84c5da2b9f68b27dec0efe5d2d380e3557bea56b..5a815a69e1ff2a74ef4c13a2f270598881905df8 100644 --- a/packages/cli/assets/traefik/traefik.yml +++ b/packages/worker/assets/traefik/traefik.yml @@ -4,7 +4,7 @@ api: providers: docker: - endpoint: "unix:///var/run/docker.sock" + endpoint: 'unix:///var/run/docker.sock' watch: true exposedByDefault: false file: @@ -13,9 +13,9 @@ providers: entryPoints: web: - address: ":80" + address: ':80' websecure: - address: ":443" + address: ':443' http: tls: certResolver: myresolver @@ -23,7 +23,7 @@ entryPoints: certificatesResolvers: myresolver: acme: - email: acme@thisprops.com + email: acme@thisprops.com storage: /shared/acme.json httpChallenge: entryPoint: web diff --git a/packages/worker/build.js b/packages/worker/build.js new file mode 100644 index 0000000000000000000000000000000000000000..98e885ccc7f6de46109cfbabe2c7639beb5560f5 --- /dev/null +++ b/packages/worker/build.js @@ -0,0 +1,21 @@ +const { build } = require('esbuild'); + +const commandArgs = process.argv.slice(2); + +async function bundle() { + const start = Date.now(); + const options = { + entryPoints: ['./src/index.ts'], + outfile: './dist/index.js', + platform: 'node', + target: 'node18', + bundle: true, + color: true, + sourcemap: commandArgs.includes('--sourcemap'), + }; + + await build({ ...options, minify: true }); + console.log(`Build time: ${Date.now() - start}ms`); +} + +bundle(); diff --git a/packages/worker/nodemon.json b/packages/worker/nodemon.json new file mode 100644 index 0000000000000000000000000000000000000000..8e05f7783790ff841d47164d633f2d451c8f80a5 --- /dev/null +++ b/packages/worker/nodemon.json @@ -0,0 +1,5 @@ +{ + "watch": ["src"], + "exec": "NODE_ENV=development tsx ./src/index.ts", + "ext": "js ts" +} diff --git a/packages/worker/package.json b/packages/worker/package.json new file mode 100644 index 0000000000000000000000000000000000000000..f04633cb151d84d0f83e2c06785d4df69f2d28ee --- /dev/null +++ b/packages/worker/package.json @@ -0,0 +1,42 @@ +{ + "name": "@runtipi/worker", + "version": "1.0.0", + "description": "", + "main": "src/index.ts", + "scripts": { + "test": "dotenv -e .env.test vitest -- --coverage --watch=false", + "test:watch": "dotenv -e .env.test vitest", + "build": "node build.js", + "tsc": "tsc", + "dev": "dotenv -e ../../.env nodemon", + "knip": "knip", + "lint": "eslint . --ext .ts" + }, + "keywords": [], + "author": "", + "license": "ISC", + "devDependencies": { + "@faker-js/faker": "^8.2.0", + "@types/web-push": "^3.6.3", + "dotenv-cli": "^7.3.0", + "esbuild": "^0.19.4", + "knip": "^2.41.3", + "memfs": "^4.6.0", + "nodemon": "^3.0.1", + "tsx": "^3.14.0", + "typescript": "^5.2.2", + "vite-tsconfig-paths": "^4.2.1", + "vitest": "^0.34.6" + }, + "dependencies": { + "@runtipi/postgres-migrations": "^5.3.0", + "@runtipi/shared": "workspace:^", + "bullmq": "^4.13.0", + "dotenv": "^16.3.1", + "ioredis": "^5.3.2", + "pg": "^8.11.3", + "systeminformation": "^5.21.15", + "web-push": "^3.6.6", + "zod": "^3.22.4" + } +} diff --git a/packages/worker/src/config/constants.ts b/packages/worker/src/config/constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..2a934ccf9b6db67db913f12908ac937ede0e9071 --- /dev/null +++ b/packages/worker/src/config/constants.ts @@ -0,0 +1,2 @@ +export const ROOT_FOLDER = '/app'; +export const STORAGE_FOLDER = '/storage'; diff --git a/packages/worker/src/config/index.ts b/packages/worker/src/config/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..c94f80f843a1ad2b1a576f42b4d4c20b796dce32 --- /dev/null +++ b/packages/worker/src/config/index.ts @@ -0,0 +1 @@ +export * from './constants'; diff --git a/packages/worker/src/index.ts b/packages/worker/src/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..5c56afb142a146b4faf053e1c588c0cdd9294e70 --- /dev/null +++ b/packages/worker/src/index.ts @@ -0,0 +1,94 @@ +import { SystemEvent } from '@runtipi/shared'; +import http from 'node:http'; +import path from 'node:path'; +import Redis from 'ioredis'; +import dotenv from 'dotenv'; +import { Queue } from 'bullmq'; +import { copySystemFiles, ensureFilePermissions, generateSystemEnvFile, generateTlsCertificates } from '@/lib/system'; +import { runPostgresMigrations } from '@/lib/migrations'; +import { startWorker } from './watcher/watcher'; +import { logger } from '@/lib/logger'; +import { AppExecutors } from './services'; + +const rootFolder = '/app'; +const envFile = path.join(rootFolder, '.env'); + +const main = async () => { + try { + await logger.flush(); + + logger.info('Copying system files...'); + await copySystemFiles(); + + logger.info('Generating system env file...'); + const envMap = await generateSystemEnvFile(); + + // Reload env variables after generating the env file + logger.info('Reloading env variables...'); + dotenv.config({ path: envFile, override: true }); + + logger.info('Generating TLS certificates...'); + await generateTlsCertificates({ domain: envMap.get('LOCAL_DOMAIN') }); + + logger.info('Ensuring file permissions...'); + await ensureFilePermissions(); + + logger.info('Starting queue...'); + const queue = new Queue('events', { connection: { host: envMap.get('REDIS_HOST'), port: 6379, password: envMap.get('REDIS_PASSWORD') } }); + logger.info('Obliterating queue...'); + await queue.obliterate({ force: true }); + + // Initial jobs + logger.info('Adding initial jobs to queue...'); + await queue.add(`${Math.random().toString()}_system_info`, { type: 'system', command: 'system_info' } as SystemEvent); + await queue.add(`${Math.random().toString()}_repo_clone`, { type: 'repo', command: 'clone', url: envMap.get('APPS_REPO_URL') } as SystemEvent); + await queue.add(`${Math.random().toString()}_repo_update`, { type: 'repo', command: 'update', url: envMap.get('APPS_REPO_URL') } as SystemEvent); + + // Scheduled jobs + logger.info('Adding scheduled jobs to queue...'); + await queue.add(`${Math.random().toString()}_repo_update`, { type: 'repo', command: 'update', url: envMap.get('APPS_REPO_URL') } as SystemEvent, { repeat: { pattern: '*/30 * * * *' } }); + await queue.add(`${Math.random().toString()}_system_info`, { type: 'system', command: 'system_info' } as SystemEvent, { repeat: { pattern: '* * * * *' } }); + + logger.info('Closing queue...'); + await queue.close(); + + logger.info('Running database migrations...'); + await runPostgresMigrations({ + postgresHost: envMap.get('POSTGRES_HOST') as string, + postgresDatabase: envMap.get('POSTGRES_DBNAME') as string, + postgresUsername: envMap.get('POSTGRES_USERNAME') as string, + postgresPassword: envMap.get('POSTGRES_PASSWORD') as string, + postgresPort: envMap.get('POSTGRES_PORT') as string, + }); + + // Set status to running + logger.info('Setting status to running...'); + const cache = new Redis({ host: envMap.get('REDIS_HOST'), port: 6379, password: envMap.get('REDIS_PASSWORD') }); + await cache.set('status', 'RUNNING'); + await cache.quit(); + + // Start all apps + const appExecutor = new AppExecutors(); + logger.info('Starting all apps...'); + appExecutor.startAllApps(); + + const server = http.createServer((req, res) => { + if (req.url === '/healthcheck') { + res.writeHead(200); + res.end('OK'); + } else { + res.writeHead(404); + res.end('Not Found'); + } + }); + + server.listen(3000, () => { + startWorker(); + }); + } catch (e) { + logger.error(e); + process.exit(1); + } +}; + +main(); diff --git a/packages/worker/src/lib/docker/docker-helpers.test.ts b/packages/worker/src/lib/docker/docker-helpers.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..bc1ff6dc047c01c24fe02d8543d7b4f0e7dca82d --- /dev/null +++ b/packages/worker/src/lib/docker/docker-helpers.test.ts @@ -0,0 +1,125 @@ +// const spy = vi.spyOn(dockerHelpers, 'compose').mockImplementation(() => Promise.resolve({ stdout: '', stderr: randomError })); + +import { vi, it, describe, expect } from 'vitest'; +import { faker } from '@faker-js/faker'; +import fs from 'fs'; +import { compose } from './docker-helpers'; + +const execAsync = vi.fn().mockImplementation(() => Promise.resolve({ stdout: '', stderr: '' })); + +vi.mock('@runtipi/shared', async (importOriginal) => { + const mod = (await importOriginal()) as object; + + return { + ...mod, + FileLogger: vi.fn().mockImplementation(() => ({ + flush: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + })), + execAsync: (cmd: string) => execAsync(cmd), + }; +}); + +describe('docker helpers', async () => { + it('should call execAsync with correct args', async () => { + // arrange + const appId = faker.word.noun().toLowerCase(); + const command = faker.word.noun().toLowerCase(); + + // act + await compose(appId, command); + + // assert + const expected = [ + 'docker-compose', + `--env-file /storage/app-data/${appId}/app.env`, + `--project-name ${appId}`, + `-f /app/apps/${appId}/docker-compose.yml`, + '-f /app/repos/repo-id/apps/docker-compose.common.yml', + command, + ].join(' '); + + expect(execAsync).toHaveBeenCalledWith(expected); + }); + + it('should add user env file if exists', async () => { + // arrange + const appId = faker.word.noun().toLowerCase(); + const command = faker.word.noun().toLowerCase(); + await fs.promises.mkdir(`/app/user-config/${appId}`, { recursive: true }); + const userEnvFile = `/app/user-config/${appId}/app.env`; + await fs.promises.writeFile(userEnvFile, 'test'); + + // act + await compose(appId, command); + + // assert + const expected = [ + 'docker-compose', + `--env-file /storage/app-data/${appId}/app.env`, + `--env-file ${userEnvFile}`, + `--project-name ${appId}`, + `-f /app/apps/${appId}/docker-compose.yml`, + '-f /app/repos/repo-id/apps/docker-compose.common.yml', + command, + ].join(' '); + + expect(execAsync).toHaveBeenCalledWith(expected); + }); + + it('should add user compose file if exists', async () => { + // arrange + const appId = faker.word.noun().toLowerCase(); + const command = faker.word.noun().toLowerCase(); + await fs.promises.mkdir(`/app/user-config/${appId}`, { recursive: true }); + const userComposeFile = `/app/user-config/${appId}/docker-compose.yml`; + await fs.promises.writeFile(userComposeFile, 'test'); + + // act + await compose(appId, command); + + // assert + const expected = [ + 'docker-compose', + `--env-file /storage/app-data/${appId}/app.env`, + `--project-name ${appId}`, + `-f /app/apps/${appId}/docker-compose.yml`, + '-f /app/repos/repo-id/apps/docker-compose.common.yml', + `--file ${userComposeFile}`, + command, + ].join(' '); + + expect(execAsync).toHaveBeenCalledWith(expected); + }); + + it('should add arm64 compose file if exists and arch is arm64', async () => { + // arrange + vi.mock('@/lib/environment', async (importOriginal) => { + const mod = (await importOriginal()) as object; + return { ...mod, getEnv: () => ({ arch: 'arm64', appsRepoId: 'repo-id' }) }; + }); + const appId = faker.word.noun().toLowerCase(); + const command = faker.word.noun().toLowerCase(); + await fs.promises.mkdir(`/app/apps/${appId}`, { recursive: true }); + const arm64ComposeFile = `/app/apps/${appId}/docker-compose.arm64.yml`; + await fs.promises.writeFile(arm64ComposeFile, 'test'); + + // act + await compose(appId, command); + + // assert + const expected = [ + 'docker-compose', + `--env-file /storage/app-data/${appId}/app.env`, + `--project-name ${appId}`, + `-f ${arm64ComposeFile}`, + `-f /app/repos/repo-id/apps/docker-compose.common.yml`, + command, + ].join(' '); + + expect(execAsync).toHaveBeenCalledWith(expected); + }); +}); diff --git a/packages/cli/src/utils/docker-helpers/docker-helpers.ts b/packages/worker/src/lib/docker/docker-helpers.ts similarity index 54% rename from packages/cli/src/utils/docker-helpers/docker-helpers.ts rename to packages/worker/src/lib/docker/docker-helpers.ts index 441295f3a9b7bfb4d39a7280e60c1d097f49db0b..7a8e5efb05f378f504492cbde052efa464c97e02 100644 --- a/packages/cli/src/utils/docker-helpers/docker-helpers.ts +++ b/packages/worker/src/lib/docker/docker-helpers.ts @@ -1,12 +1,16 @@ import path from 'path'; -import { getEnv } from '../environment/environment'; -import { pathExists } from '../fs-helpers/fs-helpers'; -import { fileLogger } from '../logger/file-logger'; -import { execAsync } from '../exec-async/execAsync'; +import { execAsync, pathExists } from '@runtipi/shared'; +import { logger } from '@/lib/logger'; +import { getEnv } from '@/lib/environment'; +import { ROOT_FOLDER, STORAGE_FOLDER } from '@/config/constants'; const composeUp = async (args: string[]) => { - fileLogger.info(`Running docker compose with args ${args.join(' ')}`); - const { stdout, stderr } = await execAsync(`docker compose ${args.join(' ')}`); + logger.info(`Running docker compose with args ${args.join(' ')}`); + const { stdout, stderr } = await execAsync(`docker-compose ${args.join(' ')}`); + + if (stderr && stderr.includes('Command failed:')) { + throw new Error(stderr); + } return { stdout, stderr }; }; @@ -17,14 +21,14 @@ const composeUp = async (args: string[]) => { * @param {string} command - Command to execute */ export const compose = async (appId: string, command: string) => { - const { arch, rootFolderHost, appsRepoId, storagePath } = getEnv(); - const appDataDirPath = path.join(storagePath, 'app-data', appId); - const appDirPath = path.join(rootFolderHost, 'apps', appId); + const { arch, appsRepoId } = getEnv(); + const appDataDirPath = path.join(STORAGE_FOLDER, 'app-data', appId); + const appDirPath = path.join(ROOT_FOLDER, 'apps', appId); const args: string[] = [`--env-file ${path.join(appDataDirPath, 'app.env')}`]; // User custom env file - const userEnvFile = path.join(rootFolderHost, 'user-config', appId, 'app.env'); + const userEnvFile = path.join(ROOT_FOLDER, 'user-config', appId, 'app.env'); if (await pathExists(userEnvFile)) { args.push(`--env-file ${userEnvFile}`); } @@ -37,11 +41,11 @@ export const compose = async (appId: string, command: string) => { } args.push(`-f ${composeFile}`); - const commonComposeFile = path.join(rootFolderHost, 'repos', appsRepoId, 'apps', 'docker-compose.common.yml'); + const commonComposeFile = path.join(ROOT_FOLDER, 'repos', appsRepoId, 'apps', 'docker-compose.common.yml'); args.push(`-f ${commonComposeFile}`); // User defined overrides - const userComposeFile = path.join(rootFolderHost, 'user-config', appId, 'docker-compose.yml'); + const userComposeFile = path.join(ROOT_FOLDER, 'user-config', appId, 'docker-compose.yml'); if (await pathExists(userComposeFile)) { args.push(`--file ${userComposeFile}`); } diff --git a/packages/cli/src/utils/docker-helpers/index.ts b/packages/worker/src/lib/docker/index.ts similarity index 100% rename from packages/cli/src/utils/docker-helpers/index.ts rename to packages/worker/src/lib/docker/index.ts diff --git a/packages/worker/src/lib/environment/environment.ts b/packages/worker/src/lib/environment/environment.ts new file mode 100644 index 0000000000000000000000000000000000000000..45b0e374e3d3608f47c490cdb9c8ec2458d2a865 --- /dev/null +++ b/packages/worker/src/lib/environment/environment.ts @@ -0,0 +1,62 @@ +import { z } from 'zod'; +import dotenv from 'dotenv'; + +if (process.env.NODE_ENV === 'development') { + dotenv.config({ path: '.env.dev', override: true }); +} else { + dotenv.config({ override: true }); +} + +const environmentSchema = z + .object({ + STORAGE_PATH: z.string(), + ROOT_FOLDER_HOST: z.string(), + APPS_REPO_ID: z.string(), + ARCHITECTURE: z.enum(['arm64', 'amd64']), + INTERNAL_IP: z.string().ip().or(z.literal('localhost')), + TIPI_VERSION: z.string(), + REDIS_PASSWORD: z.string(), + REDIS_HOST: z.string(), + POSTGRES_PORT: z.string(), + POSTGRES_USERNAME: z.string(), + POSTGRES_PASSWORD: z.string(), + POSTGRES_DBNAME: z.string(), + POSTGRES_HOST: z.string(), + }) + .transform((env) => { + const { + STORAGE_PATH = '/app', + ARCHITECTURE, + ROOT_FOLDER_HOST, + APPS_REPO_ID, + INTERNAL_IP, + TIPI_VERSION, + REDIS_PASSWORD, + REDIS_HOST, + POSTGRES_DBNAME, + POSTGRES_PASSWORD, + POSTGRES_USERNAME, + POSTGRES_PORT, + POSTGRES_HOST, + ...rest + } = env; + + return { + storagePath: STORAGE_PATH, + rootFolderHost: ROOT_FOLDER_HOST, + appsRepoId: APPS_REPO_ID, + arch: ARCHITECTURE, + tipiVersion: TIPI_VERSION, + internalIp: INTERNAL_IP, + redisPassword: REDIS_PASSWORD, + redisHost: REDIS_HOST, + postgresPort: POSTGRES_PORT, + postgresUsername: POSTGRES_USERNAME, + postgresPassword: POSTGRES_PASSWORD, + postgresDatabase: POSTGRES_DBNAME, + postgresHost: POSTGRES_HOST, + ...rest, + }; + }); + +export const getEnv = () => environmentSchema.parse(process.env); diff --git a/packages/worker/src/lib/environment/index.ts b/packages/worker/src/lib/environment/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..425c9962344da8730508364273c5cc5c92a2492f --- /dev/null +++ b/packages/worker/src/lib/environment/index.ts @@ -0,0 +1 @@ +export { getEnv } from './environment'; diff --git a/packages/worker/src/lib/logger/index.ts b/packages/worker/src/lib/logger/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..16b4cac812e63d6c18ff60a1f6de99580d2bb713 --- /dev/null +++ b/packages/worker/src/lib/logger/index.ts @@ -0,0 +1 @@ +export { logger } from './logger'; diff --git a/packages/worker/src/lib/logger/logger.ts b/packages/worker/src/lib/logger/logger.ts new file mode 100644 index 0000000000000000000000000000000000000000..70f848a335973cdf625f102c571e5b8cd956e6b7 --- /dev/null +++ b/packages/worker/src/lib/logger/logger.ts @@ -0,0 +1,4 @@ +import { FileLogger } from '@runtipi/shared'; +import path from 'node:path'; + +export const logger = new FileLogger('worker', path.join('/app', 'logs'), true); diff --git a/packages/worker/src/lib/migrations/index.ts b/packages/worker/src/lib/migrations/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..1966768f2ee8c46998304b00b784451e13010b9f --- /dev/null +++ b/packages/worker/src/lib/migrations/index.ts @@ -0,0 +1 @@ +export { runPostgresMigrations } from './run-migration'; diff --git a/packages/cli/src/utils/migrations/run-migration.ts b/packages/worker/src/lib/migrations/run-migration.ts similarity index 67% rename from packages/cli/src/utils/migrations/run-migration.ts rename to packages/worker/src/lib/migrations/run-migration.ts index 65654110fc4316364f48b41054c8361db21b1e8b..27c965c4effba788c6d8c139b91754d2e2e7bfc2 100644 --- a/packages/cli/src/utils/migrations/run-migration.ts +++ b/packages/worker/src/lib/migrations/run-migration.ts @@ -1,7 +1,8 @@ import path from 'path'; import pg from 'pg'; import { migrate } from '@runtipi/postgres-migrations'; -import { fileLogger } from '../logger/file-logger'; +import { logger } from '@/lib/logger'; +import { ROOT_FOLDER } from '@/config/constants'; type MigrationParams = { postgresHost: string; @@ -12,13 +13,13 @@ type MigrationParams = { }; export const runPostgresMigrations = async (params: MigrationParams) => { - const assetsFolder = path.join('/snapshot', 'runtipi', 'packages', 'cli', 'assets'); + const assetsFolder = path.join(ROOT_FOLDER, 'assets'); const { postgresHost, postgresDatabase, postgresUsername, postgresPassword, postgresPort } = params; - fileLogger.info('Starting database migration'); + logger.info('Starting database migration'); - fileLogger.info(`Connecting to database ${postgresDatabase} on ${postgresHost} as ${postgresUsername} on port ${postgresPort}`); + logger.info(`Connecting to database ${postgresDatabase} on ${postgresHost} as ${postgresUsername} on port ${postgresPort}`); const client = new pg.Client({ user: postgresUsername, @@ -29,28 +30,28 @@ export const runPostgresMigrations = async (params: MigrationParams) => { }); await client.connect(); - fileLogger.info('Client connected'); + logger.info('Client connected'); try { const { rows } = await client.query('SELECT * FROM migrations'); // if rows contains a migration with name 'Initial1657299198975' (legacy typeorm) delete table migrations. As all migrations are idempotent we can safely delete the table and start over. if (rows.find((row) => row.name === 'Initial1657299198975')) { - fileLogger.info('Found legacy migration. Deleting table migrations'); + logger.info('Found legacy migration. Deleting table migrations'); await client.query('DROP TABLE migrations'); } } catch (e) { - fileLogger.info('Migrations table not found, creating it'); + logger.info('Migrations table not found, creating it'); } - fileLogger.info('Running migrations'); + logger.info('Running migrations'); try { await migrate({ client }, path.join(assetsFolder, 'migrations'), { skipCreateMigrationTable: true }); } catch (e) { - fileLogger.error('Error running migrations. Dropping table migrations and trying again'); + logger.error('Error running migrations. Dropping table migrations and trying again'); await client.query('DROP TABLE migrations'); await migrate({ client }, path.join(assetsFolder, 'migrations'), { skipCreateMigrationTable: true }); } - fileLogger.info('Migration complete'); + logger.info('Migration complete'); await client.end(); }; diff --git a/packages/worker/src/lib/system/index.ts b/packages/worker/src/lib/system/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..bf480304960644917161137265e5610b7546ac65 --- /dev/null +++ b/packages/worker/src/lib/system/index.ts @@ -0,0 +1 @@ +export { copySystemFiles, generateSystemEnvFile, ensureFilePermissions, generateTlsCertificates } from './system.helpers'; diff --git a/packages/worker/src/lib/system/system.helpers.ts b/packages/worker/src/lib/system/system.helpers.ts new file mode 100644 index 0000000000000000000000000000000000000000..b1a96fb287d594e7c5fca40c197f758ab1553c0a --- /dev/null +++ b/packages/worker/src/lib/system/system.helpers.ts @@ -0,0 +1,278 @@ +/* eslint-disable no-await-in-loop */ +/* eslint-disable no-restricted-syntax */ +import crypto from 'crypto'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import { envMapToString, envStringToMap, execAsync, pathExists, settingsSchema } from '@runtipi/shared'; +import { logger } from '../logger/logger'; +import { getRepoHash } from '../../services/repo/repo.helpers'; +import { ROOT_FOLDER } from '@/config/constants'; + +type EnvKeys = + | 'APPS_REPO_ID' + | 'APPS_REPO_URL' + | 'TZ' + | 'INTERNAL_IP' + | 'DNS_IP' + | 'ARCHITECTURE' + | 'TIPI_VERSION' + | 'JWT_SECRET' + | 'ROOT_FOLDER_HOST' + | 'NGINX_PORT' + | 'NGINX_PORT_SSL' + | 'DOMAIN' + | 'STORAGE_PATH' + | 'POSTGRES_PORT' + | 'POSTGRES_HOST' + | 'POSTGRES_DBNAME' + | 'POSTGRES_PASSWORD' + | 'POSTGRES_USERNAME' + | 'REDIS_HOST' + | 'REDIS_PASSWORD' + | 'LOCAL_DOMAIN' + | 'DEMO_MODE' + | 'GUEST_DASHBOARD' + | 'TIPI_GID' + | 'TIPI_UID' + // eslint-disable-next-line @typescript-eslint/ban-types + | (string & {}); + +const OLD_DEFAULT_REPO_URL = 'https://github.com/meienberger/runtipi-appstore'; +const DEFAULT_REPO_URL = 'https://github.com/runtipi/runtipi-appstore'; + +/** + * Reads and returns the generated seed + */ +const getSeed = async () => { + const seedFilePath = path.join(ROOT_FOLDER, 'state', 'seed'); + + if (!(await pathExists(seedFilePath))) { + throw new Error('Seed file not found'); + } + + const seed = await fs.promises.readFile(seedFilePath, 'utf-8'); + + return seed; +}; + +/** + * Derives a new entropy value from the provided entropy and the seed + * @param {string} entropy - The entropy value to derive from + */ +const deriveEntropy = async (entropy: string) => { + const seed = await getSeed(); + const hmac = crypto.createHmac('sha256', seed); + hmac.update(entropy); + + return hmac.digest('hex'); +}; + +/** + * Generates a random seed if it does not exist yet + */ +const generateSeed = async () => { + if (!(await pathExists(path.join(ROOT_FOLDER, 'state', 'seed')))) { + const randomBytes = crypto.randomBytes(32); + const seed = randomBytes.toString('hex'); + + await fs.promises.writeFile(path.join(ROOT_FOLDER, 'state', 'seed'), seed); + } +}; + +/** + * Returns the architecture of the current system + */ +const getArchitecture = () => { + const arch = os.arch(); + + if (arch === 'arm64') return 'arm64'; + if (arch === 'x64') return 'amd64'; + + throw new Error(`Unsupported architecture: ${arch}`); +}; + +/** + * Generates a valid .env file from the settings.json file + */ +export const generateSystemEnvFile = async () => { + await fs.promises.mkdir(path.join(ROOT_FOLDER, 'state'), { recursive: true }); + const settingsFilePath = path.join(ROOT_FOLDER, 'state', 'settings.json'); + const envFilePath = path.join(ROOT_FOLDER, '.env'); + + if (!(await pathExists(envFilePath))) { + await fs.promises.writeFile(envFilePath, ''); + } + + const envFile = await fs.promises.readFile(envFilePath, 'utf-8'); + + const envMap: Map = envStringToMap(envFile); + + if (!(await pathExists(settingsFilePath))) { + await fs.promises.writeFile(settingsFilePath, JSON.stringify({})); + } + + const settingsFile = await fs.promises.readFile(settingsFilePath, 'utf-8'); + + const settings = settingsSchema.safeParse(JSON.parse(settingsFile)); + + if (!settings.success) { + throw new Error(`Invalid settings.json file: ${settings.error.message}`); + } + + await generateSeed(); + + const { data } = settings; + + if (data.appsRepoUrl === OLD_DEFAULT_REPO_URL) { + data.appsRepoUrl = DEFAULT_REPO_URL; + } + + const jwtSecret = envMap.get('JWT_SECRET') || (await deriveEntropy('jwt_secret')); + const repoId = getRepoHash(data.appsRepoUrl || DEFAULT_REPO_URL); + + const rootFolderHost = envMap.get('ROOT_FOLDER_HOST'); + const internalIp = envMap.get('INTERNAL_IP'); + + if (!rootFolderHost) { + throw new Error('ROOT_FOLDER_HOST not set in .env file'); + } + + if (!internalIp) { + throw new Error('INTERNAL_IP not set in .env file'); + } + + envMap.set('APPS_REPO_ID', repoId); + envMap.set('APPS_REPO_URL', data.appsRepoUrl || DEFAULT_REPO_URL); + envMap.set('TZ', Intl.DateTimeFormat().resolvedOptions().timeZone); + envMap.set('INTERNAL_IP', data.listenIp || internalIp); + envMap.set('DNS_IP', data.dnsIp || '9.9.9.9'); + envMap.set('ARCHITECTURE', getArchitecture()); + envMap.set('JWT_SECRET', jwtSecret); + envMap.set('DOMAIN', data.domain || 'example.com'); + envMap.set('STORAGE_PATH', data.storagePath || envMap.get('STORAGE_PATH') || rootFolderHost); + envMap.set('POSTGRES_HOST', 'tipi-db'); + envMap.set('POSTGRES_DBNAME', 'tipi'); + envMap.set('POSTGRES_USERNAME', 'tipi'); + envMap.set('POSTGRES_PORT', String(5432)); + envMap.set('REDIS_HOST', 'tipi-redis'); + envMap.set('DEMO_MODE', String(data.demoMode || 'false')); + envMap.set('GUEST_DASHBOARD', String(data.guestDashboard || 'false')); + envMap.set('LOCAL_DOMAIN', data.localDomain || 'tipi.lan'); + envMap.set('NODE_ENV', 'production'); + + await fs.promises.writeFile(envFilePath, envMapToString(envMap)); + + return envMap; +}; + +/** + * Copies the system files from the assets folder to the current working directory + */ +export const copySystemFiles = async () => { + // Remove old unused files + if (await pathExists(path.join(ROOT_FOLDER, 'scripts'))) { + logger.info('Removing old scripts folder'); + await fs.promises.rmdir(path.join(ROOT_FOLDER, 'scripts'), { recursive: true }); + } + + const assetsFolder = path.join(ROOT_FOLDER, 'assets'); + + // Copy traefik folder from assets + logger.info('Creating traefik folders'); + await fs.promises.mkdir(path.join(ROOT_FOLDER, 'traefik', 'dynamic'), { recursive: true }); + await fs.promises.mkdir(path.join(ROOT_FOLDER, 'traefik', 'shared'), { recursive: true }); + await fs.promises.mkdir(path.join(ROOT_FOLDER, 'traefik', 'tls'), { recursive: true }); + + logger.info('Copying traefik files'); + await fs.promises.copyFile(path.join(assetsFolder, 'traefik', 'traefik.yml'), path.join(ROOT_FOLDER, 'traefik', 'traefik.yml')); + await fs.promises.copyFile(path.join(assetsFolder, 'traefik', 'dynamic', 'dynamic.yml'), path.join(ROOT_FOLDER, 'traefik', 'dynamic', 'dynamic.yml')); + + // Create base folders + logger.info('Creating base folders'); + await fs.promises.mkdir(path.join(ROOT_FOLDER, 'apps'), { recursive: true }); + await fs.promises.mkdir(path.join(ROOT_FOLDER, 'app-data'), { recursive: true }); + await fs.promises.mkdir(path.join(ROOT_FOLDER, 'state'), { recursive: true }); + await fs.promises.mkdir(path.join(ROOT_FOLDER, 'repos'), { recursive: true }); + + // Create media folders + logger.info('Creating media folders'); + await fs.promises.mkdir(path.join(ROOT_FOLDER, 'media', 'torrents', 'watch'), { recursive: true }); + await fs.promises.mkdir(path.join(ROOT_FOLDER, 'media', 'torrents', 'complete'), { recursive: true }); + await fs.promises.mkdir(path.join(ROOT_FOLDER, 'media', 'torrents', 'incomplete'), { recursive: true }); + + await fs.promises.mkdir(path.join(ROOT_FOLDER, 'media', 'usenet', 'watch'), { recursive: true }); + await fs.promises.mkdir(path.join(ROOT_FOLDER, 'media', 'usenet', 'complete'), { recursive: true }); + await fs.promises.mkdir(path.join(ROOT_FOLDER, 'media', 'usenet', 'incomplete'), { recursive: true }); + + await fs.promises.mkdir(path.join(ROOT_FOLDER, 'media', 'downloads', 'watch'), { recursive: true }); + await fs.promises.mkdir(path.join(ROOT_FOLDER, 'media', 'downloads', 'complete'), { recursive: true }); + await fs.promises.mkdir(path.join(ROOT_FOLDER, 'media', 'downloads', 'incomplete'), { recursive: true }); + + await fs.promises.mkdir(path.join(ROOT_FOLDER, 'media', 'data', 'books'), { recursive: true }); + await fs.promises.mkdir(path.join(ROOT_FOLDER, 'media', 'data', 'comics'), { recursive: true }); + await fs.promises.mkdir(path.join(ROOT_FOLDER, 'media', 'data', 'movies'), { recursive: true }); + await fs.promises.mkdir(path.join(ROOT_FOLDER, 'media', 'data', 'music'), { recursive: true }); + await fs.promises.mkdir(path.join(ROOT_FOLDER, 'media', 'data', 'tv'), { recursive: true }); + await fs.promises.mkdir(path.join(ROOT_FOLDER, 'media', 'data', 'podcasts'), { recursive: true }); + await fs.promises.mkdir(path.join(ROOT_FOLDER, 'media', 'data', 'images'), { recursive: true }); + await fs.promises.mkdir(path.join(ROOT_FOLDER, 'media', 'data', 'roms'), { recursive: true }); +}; + +/** + * Given a domain, generates the TLS certificates for it to be used with Traefik + * + * @param {string} data.domain The domain to generate the certificates for + */ +export const generateTlsCertificates = async (data: { domain?: string }) => { + if (!data.domain) { + return; + } + + // If the certificate already exists, don't generate it again + if (await pathExists(path.join(ROOT_FOLDER, 'traefik', 'tls', `${data.domain}.txt`))) { + logger.info(`TLS certificate for ${data.domain} already exists`); + return; + } + + // Remove old certificates + if (await pathExists(path.join(ROOT_FOLDER, 'traefik', 'tls', 'cert.pem'))) { + logger.info('Removing old TLS certificate'); + await fs.promises.unlink(path.join(ROOT_FOLDER, 'traefik', 'tls', 'cert.pem')); + } + if (await pathExists(path.join(ROOT_FOLDER, 'traefik', 'tls', 'key.pem'))) { + logger.info('Removing old TLS key'); + await fs.promises.unlink(path.join(ROOT_FOLDER, 'traefik', 'tls', 'key.pem')); + } + + const subject = `/O=runtipi.io/OU=IT/CN=*.${data.domain}/emailAddress=webmaster@${data.domain}`; + const subjectAltName = `DNS:*.${data.domain},DNS:${data.domain}`; + + try { + logger.info(`Generating TLS certificate for ${data.domain}`); + await execAsync(`openssl req -x509 -newkey rsa:4096 -keyout traefik/tls/key.pem -out traefik/tls/cert.pem -days 365 -subj "${subject}" -addext "subjectAltName = ${subjectAltName}" -nodes`); + logger.info(`Writing txt file for ${data.domain}`); + await fs.promises.writeFile(path.join(ROOT_FOLDER, 'traefik', 'tls', `${data.domain}.txt`), ''); + } catch (error) { + logger.error(error); + } +}; + +export const ensureFilePermissions = async () => { + const filesAndFolders = [path.join(ROOT_FOLDER, 'state'), path.join(ROOT_FOLDER, 'traefik')]; + + const files600 = [path.join(ROOT_FOLDER, 'traefik', 'shared', 'acme.json')]; + + // Give permission to read and write to all files and folders for the current user + for (const fileOrFolder of filesAndFolders) { + if (await pathExists(fileOrFolder)) { + await execAsync(`chmod -R a+rwx ${fileOrFolder}`).catch(() => {}); + } + } + + for (const fileOrFolder of files600) { + if (await pathExists(fileOrFolder)) { + await execAsync(`chmod 600 ${fileOrFolder}`).catch(() => {}); + } + } +}; diff --git a/packages/cli/src/executors/app/__tests__/app.executors.test.ts b/packages/worker/src/services/app/__tests__/app.executors.test.ts similarity index 63% rename from packages/cli/src/executors/app/__tests__/app.executors.test.ts rename to packages/worker/src/services/app/__tests__/app.executors.test.ts index 9376811583c43133593049c84ec964c1ba0942ca..b185d8f78e8b97b8a4ad4fd27b0ff269f4401512 100644 --- a/packages/cli/src/executors/app/__tests__/app.executors.test.ts +++ b/packages/worker/src/services/app/__tests__/app.executors.test.ts @@ -2,13 +2,14 @@ import fs from 'fs'; import { describe, it, expect, vi } from 'vitest'; import path from 'path'; import { faker } from '@faker-js/faker'; +import { pathExists } from '@runtipi/shared'; import { AppExecutors } from '../app.executors'; import { createAppConfig } from '@/tests/apps.factory'; -import * as dockerHelpers from '@/utils/docker-helpers'; -import { getEnv } from '@/utils/environment/environment'; -import { pathExists } from '@/utils/fs-helpers'; +import * as dockerHelpers from '@/lib/docker'; +import { getEnv } from '@/lib/environment'; +import { ROOT_FOLDER, STORAGE_FOLDER } from '@/config/constants'; -const { storagePath, rootFolderHost, appsRepoId } = getEnv(); +const { appsRepoId } = getEnv(); describe('test: app executors', () => { const appExecutors = new AppExecutors(); @@ -23,7 +24,7 @@ describe('test: app executors', () => { const { message, success } = await appExecutors.installApp(config.id, config); // assert - const envExists = await pathExists(path.join(storagePath, 'app-data', config.id, 'app.env')); + const envExists = await pathExists(path.join(STORAGE_FOLDER, 'app-data', config.id, 'app.env')); expect(success).toBe(true); expect(message).toBe(`App ${config.id} installed successfully`); @@ -32,17 +33,34 @@ describe('test: app executors', () => { spy.mockRestore(); }); + it('should return error if compose script fails', async () => { + // arrange + const randomError = faker.system.fileName(); + const spy = vi.spyOn(dockerHelpers, 'compose').mockImplementation(() => { + throw new Error(randomError); + }); + const config = createAppConfig({}, false); + + // act + const { message, success } = await appExecutors.installApp(config.id, config); + + // assert + expect(success).toBe(false); + expect(message).toContain(randomError); + spy.mockRestore(); + }); + it('should delete existing app folder', async () => { // arrange const config = createAppConfig(); - await fs.promises.mkdir(path.join(rootFolderHost, 'apps', config.id), { recursive: true }); - await fs.promises.writeFile(path.join(rootFolderHost, 'apps', config.id, 'test.txt'), 'test'); + await fs.promises.mkdir(path.join(ROOT_FOLDER, 'apps', config.id), { recursive: true }); + await fs.promises.writeFile(path.join(ROOT_FOLDER, 'apps', config.id, 'test.txt'), 'test'); // act await appExecutors.installApp(config.id, config); // assert - const exists = await pathExists(path.join(storagePath, 'apps', config.id, 'test.txt')); + const exists = await pathExists(path.join(STORAGE_FOLDER, 'apps', config.id, 'test.txt')); expect(exists).toBe(false); }); @@ -51,13 +69,13 @@ describe('test: app executors', () => { // arrange const config = createAppConfig(); const filename = faker.system.fileName(); - await fs.promises.writeFile(path.join(storagePath, 'app-data', config.id, filename), 'test'); + await fs.promises.writeFile(path.join(STORAGE_FOLDER, 'app-data', config.id, filename), 'test'); // act await appExecutors.installApp(config.id, config); // assert - const exists = await pathExists(path.join(storagePath, 'app-data', config.id, filename)); + const exists = await pathExists(path.join(STORAGE_FOLDER, 'app-data', config.id, filename)); expect(exists).toBe(true); }); @@ -66,15 +84,15 @@ describe('test: app executors', () => { // arrange const config = createAppConfig({}, false); const filename = faker.system.fileName(); - await fs.promises.mkdir(path.join(rootFolderHost, 'repos', appsRepoId, 'apps', config.id, 'data'), { recursive: true }); - await fs.promises.writeFile(path.join(rootFolderHost, 'repos', appsRepoId, 'apps', config.id, 'data', filename), 'test'); + await fs.promises.mkdir(path.join(ROOT_FOLDER, 'repos', appsRepoId, 'apps', config.id, 'data'), { recursive: true }); + await fs.promises.writeFile(path.join(ROOT_FOLDER, 'repos', appsRepoId, 'apps', config.id, 'data', filename), 'test'); // act await appExecutors.installApp(config.id, config); // assert - const exists = await pathExists(path.join(storagePath, 'app-data', config.id, 'data', filename)); - const data = await fs.promises.readFile(path.join(storagePath, 'app-data', config.id, 'data', filename), 'utf-8'); + const exists = await pathExists(path.join(STORAGE_FOLDER, 'app-data', config.id, 'data', filename)); + const data = await fs.promises.readFile(path.join(STORAGE_FOLDER, 'app-data', config.id, 'data', filename), 'utf-8'); expect(exists).toBe(true); expect(data).toBe('test'); @@ -84,16 +102,16 @@ describe('test: app executors', () => { // arrange const config = createAppConfig(); const filename = faker.system.fileName(); - await fs.promises.writeFile(path.join(storagePath, 'app-data', config.id, 'data', filename), 'test'); - await fs.promises.mkdir(path.join(rootFolderHost, 'repos', appsRepoId, 'apps', config.id, 'data'), { recursive: true }); - await fs.promises.writeFile(path.join(rootFolderHost, 'repos', appsRepoId, 'apps', config.id, 'data', filename), 'yeah'); + await fs.promises.writeFile(path.join(STORAGE_FOLDER, 'app-data', config.id, 'data', filename), 'test'); + await fs.promises.mkdir(path.join(ROOT_FOLDER, 'repos', appsRepoId, 'apps', config.id, 'data'), { recursive: true }); + await fs.promises.writeFile(path.join(ROOT_FOLDER, 'repos', appsRepoId, 'apps', config.id, 'data', filename), 'yeah'); // act await appExecutors.installApp(config.id, config); // assert - const exists = await pathExists(path.join(storagePath, 'app-data', config.id, 'data', filename)); - const data = await fs.promises.readFile(path.join(storagePath, 'app-data', config.id, 'data', filename), 'utf-8'); + const exists = await pathExists(path.join(STORAGE_FOLDER, 'app-data', config.id, 'data', filename)); + const data = await fs.promises.readFile(path.join(STORAGE_FOLDER, 'app-data', config.id, 'data', filename), 'utf-8'); expect(exists).toBe(true); expect(data).toBe('test'); diff --git a/packages/cli/src/executors/app/__tests__/app.helpers.test.ts b/packages/worker/src/services/app/__tests__/app.helpers.test.ts similarity index 88% rename from packages/cli/src/executors/app/__tests__/app.helpers.test.ts rename to packages/worker/src/services/app/__tests__/app.helpers.test.ts index fdb862d394ca75524dd803d9f24c8c8228dd6c60..b816fbc4e8f0cc29ab28aa3f130fe33b8a8335aa 100644 --- a/packages/cli/src/executors/app/__tests__/app.helpers.test.ts +++ b/packages/worker/src/services/app/__tests__/app.helpers.test.ts @@ -1,20 +1,18 @@ import fs from 'fs'; import { describe, it, expect } from 'vitest'; import { faker } from '@faker-js/faker'; +import { pathExists } from '@runtipi/shared'; import { copyDataDir, generateEnvFile } from '../app.helpers'; import { createAppConfig } from '@/tests/apps.factory'; import { getAppEnvMap } from '../env.helpers'; -import { getEnv } from '@/utils/environment/environment'; -import { pathExists } from '@/utils/fs-helpers'; - -const { rootFolderHost, storagePath } = getEnv(); +import { ROOT_FOLDER, STORAGE_FOLDER } from '@/config/constants'; describe('app helpers', () => { describe('Test: generateEnvFile()', () => { it('should throw an error if the app has an invalid config.json file', async () => { // arrange const appConfig = createAppConfig(); - await fs.promises.writeFile(`${rootFolderHost}/apps/${appConfig.id}/config.json`, '{}'); + await fs.promises.writeFile(`${ROOT_FOLDER}/apps/${appConfig.id}/config.json`, '{}'); // act & assert expect(generateEnvFile(appConfig.id, {})).rejects.toThrowError(`App ${appConfig.id} has invalid config.json file`); @@ -50,8 +48,8 @@ describe('app helpers', () => { // arrange const appConfig = createAppConfig({ form_fields: [{ env_variable: 'RANDOM_FIELD', type: 'random', label: 'test', min: 32, max: 32, required: true }] }); const randomField = faker.string.alphanumeric(32); - await fs.promises.mkdir(`${rootFolderHost}/app-data/${appConfig.id}`, { recursive: true }); - await fs.promises.writeFile(`${rootFolderHost}/app-data/${appConfig.id}/app.env`, `RANDOM_FIELD=${randomField}`); + await fs.promises.mkdir(`${STORAGE_FOLDER}/app-data/${appConfig.id}`, { recursive: true }); + await fs.promises.writeFile(`${STORAGE_FOLDER}/app-data/${appConfig.id}/app.env`, `RANDOM_FIELD=${randomField}`); // act await generateEnvFile(appConfig.id, {}); @@ -117,7 +115,7 @@ describe('app helpers', () => { it('Should not re-create app-data folder if it already exists', async () => { // arrange const appConfig = createAppConfig({}); - await fs.promises.mkdir(`${rootFolderHost}/app-data/${appConfig.id}`, { recursive: true }); + await fs.promises.mkdir(`${ROOT_FOLDER}/app-data/${appConfig.id}`, { recursive: true }); // act await generateEnvFile(appConfig.id, {}); @@ -161,8 +159,8 @@ describe('app helpers', () => { const vapidPublicKey = faker.string.alphanumeric(32); // act - await fs.promises.mkdir(`${rootFolderHost}/app-data/${appConfig.id}`, { recursive: true }); - await fs.promises.writeFile(`${rootFolderHost}/app-data/${appConfig.id}/app.env`, `VAPID_PRIVATE_KEY=${vapidPrivateKey}\nVAPID_PUBLIC_KEY=${vapidPublicKey}`); + await fs.promises.mkdir(`${STORAGE_FOLDER}/app-data/${appConfig.id}`, { recursive: true }); + await fs.promises.writeFile(`${STORAGE_FOLDER}/app-data/${appConfig.id}/app.env`, `VAPID_PRIVATE_KEY=${vapidPrivateKey}\nVAPID_PUBLIC_KEY=${vapidPublicKey}`); await generateEnvFile(appConfig.id, {}); const envmap = await getAppEnvMap(appConfig.id); @@ -181,13 +179,13 @@ describe('app helpers', () => { await copyDataDir(appConfig.id); // assert - expect(await pathExists(`${rootFolderHost}/apps/${appConfig.id}/data`)).toBe(false); + expect(await pathExists(`${ROOT_FOLDER}/apps/${appConfig.id}/data`)).toBe(false); }); it('should copy data dir to app-data folder', async () => { // arrange const appConfig = createAppConfig({}); - const dataDir = `${rootFolderHost}/apps/${appConfig.id}/data`; + const dataDir = `${ROOT_FOLDER}/apps/${appConfig.id}/data`; await fs.promises.mkdir(dataDir, { recursive: true }); await fs.promises.writeFile(`${dataDir}/test.txt`, 'test'); @@ -196,14 +194,14 @@ describe('app helpers', () => { await copyDataDir(appConfig.id); // assert - const appDataDir = `${storagePath}/app-data/${appConfig.id}`; + const appDataDir = `${STORAGE_FOLDER}/app-data/${appConfig.id}`; expect(await fs.promises.readFile(`${appDataDir}/data/test.txt`, 'utf8')).toBe('test'); }); it('should copy folders recursively', async () => { // arrange const appConfig = createAppConfig({}); - const dataDir = `${rootFolderHost}/apps/${appConfig.id}/data`; + const dataDir = `${ROOT_FOLDER}/apps/${appConfig.id}/data`; await fs.promises.mkdir(dataDir, { recursive: true }); @@ -217,7 +215,7 @@ describe('app helpers', () => { await copyDataDir(appConfig.id); // assert - const appDataDir = `${storagePath}/app-data/${appConfig.id}`; + const appDataDir = `${STORAGE_FOLDER}/app-data/${appConfig.id}`; expect(await fs.promises.readFile(`${appDataDir}/data/subdir/subsubdir/test.txt`, 'utf8')).toBe('test'); expect(await fs.promises.readFile(`${appDataDir}/data/test.txt`, 'utf8')).toBe('test'); }); @@ -225,8 +223,8 @@ describe('app helpers', () => { it('should replace the content of .template files with the content of the app.env file', async () => { // arrange const appConfig = createAppConfig({}); - const dataDir = `${rootFolderHost}/apps/${appConfig.id}/data`; - const appDataDir = `${storagePath}/app-data/${appConfig.id}`; + const dataDir = `${ROOT_FOLDER}/apps/${appConfig.id}/data`; + const appDataDir = `${STORAGE_FOLDER}/app-data/${appConfig.id}`; await fs.promises.mkdir(dataDir, { recursive: true }); await fs.promises.mkdir(appDataDir, { recursive: true }); diff --git a/packages/worker/src/services/app/app.executors.ts b/packages/worker/src/services/app/app.executors.ts new file mode 100644 index 0000000000000000000000000000000000000000..bc3cf952b383fffa763f59d7bcde49e7ac5d8115 --- /dev/null +++ b/packages/worker/src/services/app/app.executors.ts @@ -0,0 +1,297 @@ +/* eslint-disable no-await-in-loop */ +/* eslint-disable no-restricted-syntax */ +import fs from 'fs'; +import path from 'path'; +import pg from 'pg'; +import { execAsync, pathExists } from '@runtipi/shared'; +import { copyDataDir, generateEnvFile } from './app.helpers'; +import { logger } from '@/lib/logger'; +import { compose } from '@/lib/docker'; +import { getEnv } from '@/lib/environment'; +import { ROOT_FOLDER, STORAGE_FOLDER } from '@/config/constants'; + +const getDbClient = async () => { + const { postgresHost, postgresDatabase, postgresUsername, postgresPassword, postgresPort } = getEnv(); + + const client = new pg.Client({ + host: postgresHost, + database: postgresDatabase, + user: postgresUsername, + password: postgresPassword, + port: Number(postgresPort), + }); + + await client.connect(); + + return client; +}; + +export class AppExecutors { + private readonly logger; + + constructor() { + this.logger = logger; + } + + private handleAppError = (err: unknown) => { + if (err instanceof Error) { + this.logger.error(`An error occurred: ${err.message}`); + return { success: false, message: err.message }; + } + + return { success: false, message: `An error occurred: ${err}` }; + }; + + private getAppPaths = (appId: string) => { + const { appsRepoId } = getEnv(); + + const appDataDirPath = path.join(STORAGE_FOLDER, 'app-data', appId); + const appDirPath = path.join(ROOT_FOLDER, 'apps', appId); + const configJsonPath = path.join(appDirPath, 'config.json'); + const repoPath = path.join(ROOT_FOLDER, 'repos', appsRepoId, 'apps', appId); + + return { appDataDirPath, appDirPath, configJsonPath, repoPath }; + }; + + /** + * Given an app id, ensures that the app folder exists in the apps folder + * If not, copies the app folder from the repo + * @param {string} appId - App id + */ + private ensureAppDir = async (appId: string) => { + const { appDirPath, repoPath } = this.getAppPaths(appId); + const dockerFilePath = path.join(ROOT_FOLDER, 'apps', appId, 'docker-compose.yml'); + + if (!(await pathExists(dockerFilePath))) { + // delete eventual app folder if exists + this.logger.info(`Deleting app ${appId} folder if exists`); + await fs.promises.rm(appDirPath, { recursive: true, force: true }); + + // Copy app folder from repo + this.logger.info(`Copying app ${appId} from repo ${getEnv().appsRepoId}`); + await fs.promises.cp(repoPath, appDirPath, { recursive: true }); + } + }; + + /** + * Install an app from the repo + * @param {string} appId - The id of the app to install + * @param {Record} config - The config of the app + */ + public installApp = async (appId: string, config: Record) => { + try { + if (process.getuid && process.getgid) { + this.logger.info(`Installing app ${appId} as User ID: ${process.getuid()}, Group ID: ${process.getgid()}`); + } else { + this.logger.info(`Installing app ${appId}. No User ID or Group ID found.`); + } + + const { appsRepoId } = getEnv(); + + const { appDirPath, repoPath, appDataDirPath } = this.getAppPaths(appId); + + // Check if app exists in repo + const apps = await fs.promises.readdir(path.join(ROOT_FOLDER, 'repos', appsRepoId, 'apps')); + + if (!apps.includes(appId)) { + this.logger.error(`App ${appId} not found in repo ${appsRepoId}`); + return { success: false, message: `App ${appId} not found in repo ${appsRepoId}` }; + } + + // Delete app folder if exists + this.logger.info(`Deleting folder ${appDirPath} if exists`); + await fs.promises.rm(appDirPath, { recursive: true, force: true }); + + // Create app folder + this.logger.info(`Creating folder ${appDirPath}`); + await fs.promises.mkdir(appDirPath, { recursive: true }); + + // Copy app folder from repo + this.logger.info(`Copying folder ${repoPath} to ${appDirPath}`); + await fs.promises.cp(repoPath, appDirPath, { recursive: true }); + + // Create folder app-data folder + this.logger.info(`Creating folder ${appDataDirPath}`); + await fs.promises.mkdir(appDataDirPath, { recursive: true }); + + // Create app.env file + this.logger.info(`Creating app.env file for app ${appId}`); + await generateEnvFile(appId, config); + + // Copy data dir + this.logger.info(`Copying data dir for app ${appId}`); + if (!(await pathExists(`${appDataDirPath}/data`))) { + await copyDataDir(appId); + } + + await execAsync(`chmod -R a+rwx ${path.join(appDataDirPath)}`).catch(() => { + this.logger.error(`Error setting permissions for app ${appId}`); + }); + + // run docker-compose up + this.logger.info(`Running docker-compose up for app ${appId}`); + await compose(appId, 'up -d'); + + this.logger.info(`Docker-compose up for app ${appId} finished`); + + return { success: true, message: `App ${appId} installed successfully` }; + } catch (err) { + return this.handleAppError(err); + } + }; + + /** + * Stops an app + * @param {string} appId - The id of the app to stop + * @param {Record} config - The config of the app + */ + public stopApp = async (appId: string, config: Record, skipEnvGeneration = false) => { + try { + this.logger.info(`Stopping app ${appId}`); + + await this.ensureAppDir(appId); + + if (!skipEnvGeneration) { + this.logger.info(`Regenerating app.env file for app ${appId}`); + await generateEnvFile(appId, config); + } + await compose(appId, 'rm --force --stop'); + + this.logger.info(`App ${appId} stopped`); + return { success: true, message: `App ${appId} stopped successfully` }; + } catch (err) { + return this.handleAppError(err); + } + }; + + public startApp = async (appId: string, config: Record, skipEnvGeneration = false) => { + try { + const { appDataDirPath } = this.getAppPaths(appId); + + this.logger.info(`Starting app ${appId}`); + + await this.ensureAppDir(appId); + + if (!skipEnvGeneration) { + this.logger.info(`Regenerating app.env file for app ${appId}`); + await generateEnvFile(appId, config); + } + + await compose(appId, 'up --detach --force-recreate --remove-orphans --pull always'); + + this.logger.info(`App ${appId} started`); + + this.logger.info(`Setting permissions for app ${appId}`); + await execAsync(`chmod -R a+rwx ${path.join(appDataDirPath)}`).catch(() => { + this.logger.error(`Error setting permissions for app ${appId}`); + }); + + return { success: true, message: `App ${appId} started successfully` }; + } catch (err) { + return this.handleAppError(err); + } + }; + + public uninstallApp = async (appId: string, config: Record) => { + try { + const { appDirPath, appDataDirPath } = this.getAppPaths(appId); + this.logger.info(`Uninstalling app ${appId}`); + + this.logger.info(`Regenerating app.env file for app ${appId}`); + await this.ensureAppDir(appId); + await generateEnvFile(appId, config); + try { + await compose(appId, 'down --remove-orphans --volumes --rmi all'); + } catch (err) { + if (err instanceof Error && err.message.includes('conflict')) { + this.logger.warn(`Could not fully uninstall app ${appId}. Some images are in use by other apps. Consider cleaning unused images docker system prune -a`); + } else { + throw err; + } + } + + this.logger.info(`Deleting folder ${appDirPath}`); + await fs.promises.rm(appDirPath, { recursive: true, force: true }).catch((err) => { + this.logger.error(`Error deleting folder ${appDirPath}: ${err.message}`); + }); + + this.logger.info(`Deleting folder ${appDataDirPath}`); + await fs.promises.rm(appDataDirPath, { recursive: true, force: true }).catch((err) => { + this.logger.error(`Error deleting folder ${appDataDirPath}: ${err.message}`); + }); + + this.logger.info(`App ${appId} uninstalled`); + return { success: true, message: `App ${appId} uninstalled successfully` }; + } catch (err) { + return this.handleAppError(err); + } + }; + + public updateApp = async (appId: string, config: Record) => { + try { + const { appDirPath, repoPath } = this.getAppPaths(appId); + this.logger.info(`Updating app ${appId}`); + await this.ensureAppDir(appId); + await generateEnvFile(appId, config); + + await compose(appId, 'up --detach --force-recreate --remove-orphans'); + await compose(appId, 'down --rmi all --remove-orphans'); + + this.logger.info(`Deleting folder ${appDirPath}`); + await fs.promises.rm(appDirPath, { recursive: true, force: true }); + + this.logger.info(`Copying folder ${repoPath} to ${appDirPath}`); + await fs.promises.cp(repoPath, appDirPath, { recursive: true }); + + await compose(appId, 'pull'); + + return { success: true, message: `App ${appId} updated successfully` }; + } catch (err) { + return this.handleAppError(err); + } + }; + + public regenerateAppEnv = async (appId: string, config: Record) => { + try { + this.logger.info(`Regenerating app.env file for app ${appId}`); + await this.ensureAppDir(appId); + await generateEnvFile(appId, config); + return { success: true, message: `App ${appId} env file regenerated successfully` }; + } catch (err) { + return this.handleAppError(err); + } + }; + + /** + * Start all apps with status running + */ + public startAllApps = async () => { + const client = await getDbClient(); + + try { + // Get all apps with status running + const { rows } = await client.query(`SELECT * FROM app WHERE status = 'running'`); + + // Update all apps with status different than running or stopped to stopped + await client.query(`UPDATE app SET status = 'stopped' WHERE status != 'stopped' AND status != 'running' AND status != 'missing'`); + + // Start all apps + for (const row of rows) { + const { id, config } = row; + + const { success } = await this.startApp(id, config); + + if (!success) { + this.logger.error(`Error starting app ${id}`); + await client.query(`UPDATE app SET status = 'stopped' WHERE id = '${id}'`); + } else { + await client.query(`UPDATE app SET status = 'running' WHERE id = '${id}'`); + } + } + } catch (err) { + this.logger.error(`Error starting apps: ${err}`); + } finally { + await client.end(); + } + }; +} diff --git a/packages/cli/src/executors/app/app.helpers.ts b/packages/worker/src/services/app/app.helpers.ts similarity index 74% rename from packages/cli/src/executors/app/app.helpers.ts rename to packages/worker/src/services/app/app.helpers.ts index 01053264b69e98ac89d7f86d3459551d83984967..232d21eef54e5198747d6e5388c5bbe98e91551d 100644 --- a/packages/cli/src/executors/app/app.helpers.ts +++ b/packages/worker/src/services/app/app.helpers.ts @@ -1,11 +1,10 @@ import crypto from 'crypto'; import fs from 'fs'; import path from 'path'; -import { appInfoSchema, envMapToString, envStringToMap } from '@runtipi/shared'; -import { getEnv } from '@/utils/environment/environment'; +import { appInfoSchema, envMapToString, envStringToMap, execAsync, pathExists } from '@runtipi/shared'; import { generateVapidKeys, getAppEnvMap } from './env.helpers'; -import { pathExists } from '@/utils/fs-helpers'; -import { execAsync } from '@/utils/exec-async/execAsync'; +import { getEnv } from '@/lib/environment'; +import { ROOT_FOLDER, STORAGE_FOLDER } from '@/config/constants'; /** * This function generates a random string of the provided length by using the SHA-256 hash algorithm. @@ -17,7 +16,7 @@ import { execAsync } from '@/utils/exec-async/execAsync'; */ const getEntropy = async (name: string, length: number) => { const hash = crypto.createHash('sha256'); - const seed = await fs.promises.readFile(path.join(getEnv().rootFolderHost, 'state', 'seed')); + const seed = await fs.promises.readFile(path.join(ROOT_FOLDER, 'state', 'seed')); hash.update(name + seed.toString()); return hash.digest('hex').substring(0, length); @@ -36,16 +35,16 @@ const getEntropy = async (name: string, length: number) => { * @throws Will throw an error if the app has an invalid config.json file or if a required variable is missing. */ export const generateEnvFile = async (appId: string, config: Record) => { - const { rootFolderHost, storagePath, internalIp } = getEnv(); + const { internalIp, storagePath, rootFolderHost } = getEnv(); - const configFile = await fs.promises.readFile(path.join(rootFolderHost, 'apps', appId, 'config.json')); + const configFile = await fs.promises.readFile(path.join(ROOT_FOLDER, 'apps', appId, 'config.json')); const parsedConfig = appInfoSchema.safeParse(JSON.parse(configFile.toString())); if (!parsedConfig.success) { throw new Error(`App ${appId} has invalid config.json file`); } - const baseEnvFile = await fs.promises.readFile(path.join(rootFolderHost, '.env')); + const baseEnvFile = await fs.promises.readFile(path.join(ROOT_FOLDER, '.env')); const envMap = envStringToMap(baseEnvFile.toString()); // Default always present env variables @@ -101,12 +100,12 @@ export const generateEnvFile = async (appId: string, config: Record false); + const appDataDirectoryExists = await fs.promises.stat(path.join(STORAGE_FOLDER, 'app-data', appId)).catch(() => false); if (!appDataDirectoryExists) { - await fs.promises.mkdir(path.join(storagePath, 'app-data', appId), { recursive: true }); + await fs.promises.mkdir(path.join(STORAGE_FOLDER, 'app-data', appId), { recursive: true }); } - await fs.promises.writeFile(path.join(storagePath, 'app-data', appId, 'app.env'), envMapToString(envMap)); + await fs.promises.writeFile(path.join(STORAGE_FOLDER, 'app-data', appId, 'app.env'), envMapToString(envMap)); }; /** @@ -133,40 +132,38 @@ const renderTemplate = (template: string, envMap: Map) => { * @param {string} id - The id of the app. */ export const copyDataDir = async (id: string) => { - const { rootFolderHost, storagePath } = getEnv(); - const envMap = await getAppEnvMap(id); // return if app does not have a data directory - if (!(await pathExists(`${rootFolderHost}/apps/${id}/data`))) { + if (!(await pathExists(`${ROOT_FOLDER}/apps/${id}/data`))) { return; } // Create app-data folder if it doesn't exist - if (!(await pathExists(`${storagePath}/app-data/${id}/data`))) { - await fs.promises.mkdir(`${storagePath}/app-data/${id}/data`, { recursive: true }); + if (!(await pathExists(`${STORAGE_FOLDER}/app-data/${id}/data`))) { + await fs.promises.mkdir(`${STORAGE_FOLDER}/app-data/${id}/data`, { recursive: true }); } - const dataDir = await fs.promises.readdir(`${rootFolderHost}/apps/${id}/data`); + const dataDir = await fs.promises.readdir(`${ROOT_FOLDER}/apps/${id}/data`); const processFile = async (file: string) => { if (file.endsWith('.template')) { - const template = await fs.promises.readFile(`${rootFolderHost}/apps/${id}/data/${file}`, 'utf-8'); + const template = await fs.promises.readFile(`${ROOT_FOLDER}/apps/${id}/data/${file}`, 'utf-8'); const renderedTemplate = renderTemplate(template, envMap); - await fs.promises.writeFile(`${storagePath}/app-data/${id}/data/${file.replace('.template', '')}`, renderedTemplate); + await fs.promises.writeFile(`${STORAGE_FOLDER}/app-data/${id}/data/${file.replace('.template', '')}`, renderedTemplate); } else { - await fs.promises.copyFile(`${rootFolderHost}/apps/${id}/data/${file}`, `${storagePath}/app-data/${id}/data/${file}`); + await fs.promises.copyFile(`${ROOT_FOLDER}/apps/${id}/data/${file}`, `${STORAGE_FOLDER}/app-data/${id}/data/${file}`); } }; const processDir = async (p: string) => { - await fs.promises.mkdir(`${storagePath}/app-data/${id}/data/${p}`, { recursive: true }); - const files = await fs.promises.readdir(`${rootFolderHost}/apps/${id}/data/${p}`); + await fs.promises.mkdir(`${STORAGE_FOLDER}/app-data/${id}/data/${p}`, { recursive: true }); + const files = await fs.promises.readdir(`${ROOT_FOLDER}/apps/${id}/data/${p}`); await Promise.all( files.map(async (file) => { - const fullPath = `${rootFolderHost}/apps/${id}/data/${p}/${file}`; + const fullPath = `${ROOT_FOLDER}/apps/${id}/data/${p}/${file}`; if ((await fs.promises.lstat(fullPath)).isDirectory()) { await processDir(`${p}/${file}`); @@ -179,7 +176,7 @@ export const copyDataDir = async (id: string) => { await Promise.all( dataDir.map(async (file) => { - const fullPath = `${rootFolderHost}/apps/${id}/data/${file}`; + const fullPath = `${ROOT_FOLDER}/apps/${id}/data/${file}`; if ((await fs.promises.lstat(fullPath)).isDirectory()) { await processDir(file); @@ -190,7 +187,7 @@ export const copyDataDir = async (id: string) => { ); // Remove any .gitkeep files from the app-data folder at any level - if (await pathExists(`${storagePath}/app-data/${id}/data`)) { - await execAsync(`find ${storagePath}/app-data/${id}/data -name .gitkeep -delete`).catch(() => {}); + if (await pathExists(`${STORAGE_FOLDER}/app-data/${id}/data`)) { + await execAsync(`find ${STORAGE_FOLDER}/app-data/${id}/data -name .gitkeep -delete`).catch(() => {}); } }; diff --git a/packages/cli/src/executors/app/env.helpers.ts b/packages/worker/src/services/app/env.helpers.ts similarity index 86% rename from packages/cli/src/executors/app/env.helpers.ts rename to packages/worker/src/services/app/env.helpers.ts index ea44550fe113b5e51200bd9d617e85b657e772c6..b46fa907d2a6cafeef7d5028adad304fd6d5abd4 100644 --- a/packages/cli/src/executors/app/env.helpers.ts +++ b/packages/worker/src/services/app/env.helpers.ts @@ -1,7 +1,7 @@ import webpush from 'web-push'; import fs from 'fs'; import path from 'path'; -import { getEnv } from '@/utils/environment/environment'; +import { STORAGE_FOLDER } from '@/config/constants'; /** * This function reads the env file for the app with the provided id and returns a Map containing the key-value pairs of the environment variables. @@ -11,7 +11,7 @@ import { getEnv } from '@/utils/environment/environment'; */ export const getAppEnvMap = async (appId: string) => { try { - const envFile = await fs.promises.readFile(path.join(getEnv().storagePath, 'app-data', appId, 'app.env')); + const envFile = await fs.promises.readFile(path.join(STORAGE_FOLDER, 'app-data', appId, 'app.env')); const envVars = envFile.toString().split('\n'); const envVarsMap = new Map(); diff --git a/packages/worker/src/services/index.ts b/packages/worker/src/services/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..d6d749fdbeae488c42468445dea4a3fd0c65e5fc --- /dev/null +++ b/packages/worker/src/services/index.ts @@ -0,0 +1,3 @@ +export { AppExecutors } from './app/app.executors'; +export { RepoExecutors } from './repo/repo.executors'; +export { SystemExecutors } from './system/system.executors'; diff --git a/packages/cli/src/executors/repo/repo.executors.ts b/packages/worker/src/services/repo/repo.executors.ts similarity index 68% rename from packages/cli/src/executors/repo/repo.executors.ts rename to packages/worker/src/services/repo/repo.executors.ts index 463339b7b98084124dbe3c98446491564a898460..9f1d6b5a79609f8d9c6b1a2bc04f3b8b78b68add 100644 --- a/packages/cli/src/executors/repo/repo.executors.ts +++ b/packages/worker/src/services/repo/repo.executors.ts @@ -1,15 +1,13 @@ -import { getEnv } from 'src/utils/environment/environment'; import path from 'path'; -import { pathExists } from '@/utils/fs-helpers'; -import { getRepoHash } from './repo.helpers'; -import { fileLogger } from '@/utils/logger/file-logger'; -import { execAsync } from '@/utils/exec-async/execAsync'; +import { execAsync, pathExists } from '@runtipi/shared'; +import { getRepoHash, getRepoBaseUrlAndBranch } from './repo.helpers'; +import { logger } from '@/lib/logger'; export class RepoExecutors { private readonly logger; constructor() { - this.logger = fileLogger; + this.logger = logger; } /** @@ -28,23 +26,32 @@ export class RepoExecutors { /** * Given a repo url, clone it to the repos folder if it doesn't exist * - * @param {string} repoUrl + * @param {string} url */ - public cloneRepo = async (repoUrl: string) => { + public cloneRepo = async (url: string) => { try { - const { rootFolderHost } = getEnv(); - - const repoHash = getRepoHash(repoUrl); - const repoPath = path.join(rootFolderHost, 'repos', repoHash); + // We may have a potential branch computed in the hash (see getRepoBaseUrlAndBranch) + // so we do it here before splitting the url into repoUrl and branch + const repoHash = getRepoHash(url); + const repoPath = path.join('/app', 'repos', repoHash); if (await pathExists(repoPath)) { - this.logger.info(`Repo ${repoUrl} already exists`); + this.logger.info(`Repo ${url} already exists`); return { success: true, message: '' }; } - this.logger.info(`Cloning repo ${repoUrl} to ${repoPath}`); + const [repoUrl, branch] = getRepoBaseUrlAndBranch(url); + + let cloneCommand; + if (branch) { + this.logger.info(`Cloning repo ${repoUrl} on branch ${branch} to ${repoPath}`); + cloneCommand = `git clone -b ${branch} ${repoUrl} ${repoPath}`; + } else { + this.logger.info(`Cloning repo ${repoUrl} to ${repoPath}`); + cloneCommand = `git clone ${repoUrl} ${repoPath}`; + } - await execAsync(`git clone ${repoUrl} ${repoPath}`); + await execAsync(cloneCommand); this.logger.info(`Cloned repo ${repoUrl} to ${repoPath}`); return { success: true, message: '' }; @@ -60,10 +67,8 @@ export class RepoExecutors { */ public pullRepo = async (repoUrl: string) => { try { - const { rootFolderHost } = getEnv(); - const repoHash = getRepoHash(repoUrl); - const repoPath = path.join(rootFolderHost, 'repos', repoHash); + const repoPath = path.join('/app', 'repos', repoHash); if (!(await pathExists(repoPath))) { this.logger.info(`Repo ${repoUrl} does not exist`); @@ -93,11 +98,7 @@ export class RepoExecutors { }); this.logger.info(`Executing: git -C ${repoPath} fetch origin && git -C ${repoPath} reset --hard origin/${currentBranch}`); - await execAsync(`git -C ${repoPath} fetch origin && git -C ${repoPath} reset --hard origin/${currentBranch}`).then(({ stderr }) => { - if (stderr) { - this.logger.error(`stderr: ${stderr}`); - } - }); + await execAsync(`git -C ${repoPath} fetch origin && git -C ${repoPath} reset --hard origin/${currentBranch}`); this.logger.info(`Pulled repo ${repoUrl} to ${repoPath}`); return { success: true, message: '' }; diff --git a/packages/worker/src/services/repo/repo.helpers.ts b/packages/worker/src/services/repo/repo.helpers.ts new file mode 100644 index 0000000000000000000000000000000000000000..b488dfff8a77de97981e1d453fa8e77a856e9bad --- /dev/null +++ b/packages/worker/src/services/repo/repo.helpers.ts @@ -0,0 +1,27 @@ +import crypto from 'crypto'; + +/** + * Given a repo url, return a hash of it to be used as a folder name + * + * @param {string} repoUrl + */ +export const getRepoHash = (repoUrl: string) => { + const hash = crypto.createHash('sha256'); + hash.update(repoUrl); + return hash.digest('hex'); +}; + + +/** + * Extracts the base URL and branch from a repository URL. + * @param repoUrl The repository URL. + * @returns An array containing the base URL and branch, or just the base URL if no branch is found. + */ +export const getRepoBaseUrlAndBranch = (repoUrl: string) => { + const branchMatch = repoUrl.match(/^(.*)\/tree\/(.*)$/); + if (branchMatch) { + return [branchMatch[1], branchMatch[2]] ; + } + + return [repoUrl, undefined] ; +}; diff --git a/packages/worker/src/services/system/system.executors.ts b/packages/worker/src/services/system/system.executors.ts new file mode 100644 index 0000000000000000000000000000000000000000..80f6342137f98a3c22ce63f23291f843604b168f --- /dev/null +++ b/packages/worker/src/services/system/system.executors.ts @@ -0,0 +1,60 @@ +import fs from 'fs'; +import path from 'path'; +import si from 'systeminformation'; +import { logger } from '@/lib/logger'; +import { ROOT_FOLDER } from '@/config/constants'; + +export class SystemExecutors { + private readonly logger; + + constructor() { + this.logger = logger; + } + + private handleSystemError = (err: unknown) => { + if (err instanceof Error) { + this.logger.error(`An error occurred: ${err.message}`); + return { success: false, message: err.message }; + } + this.logger.error(`An error occurred: ${err}`); + + return { success: false, message: `An error occurred: ${err}` }; + }; + + private getSystemLoad = async () => { + const { currentLoad } = await si.currentLoad(); + + const memResult = { total: 0, used: 0, available: 0 }; + + try { + const memInfo = await fs.promises.readFile('/host/proc/meminfo'); + + memResult.total = Number(memInfo.toString().match(/MemTotal:\s+(\d+)/)?.[1] ?? 0) * 1024; + memResult.available = Number(memInfo.toString().match(/MemAvailable:\s+(\d+)/)?.[1] ?? 0) * 1024; + memResult.used = memResult.total - memResult.available; + } catch (e) { + this.logger.error(`Unable to read /host/proc/meminfo: ${e}`); + } + + const [disk0] = await si.fsSize(); + + return { + cpu: { load: currentLoad }, + memory: memResult, + disk: { total: disk0?.size, used: disk0?.used, available: disk0?.available }, + }; + }; + + public systemInfo = async () => { + try { + const systemLoad = await this.getSystemLoad(); + + await fs.promises.writeFile(path.join(ROOT_FOLDER, 'state', 'system-info.json'), JSON.stringify(systemLoad, null, 2)); + await fs.promises.chmod(path.join(ROOT_FOLDER, 'state', 'system-info.json'), 0o777); + + return { success: true, message: '' }; + } catch (e) { + return this.handleSystemError(e); + } + }; +} diff --git a/packages/cli/src/services/watcher/watcher.ts b/packages/worker/src/watcher/watcher.ts similarity index 53% rename from packages/cli/src/services/watcher/watcher.ts rename to packages/worker/src/watcher/watcher.ts index e7472ceb18aab8770cdb310fc2ef59c5ddf00007..54f7d8de6696f318bf1551f7ae569b75cf29fe55 100644 --- a/packages/cli/src/services/watcher/watcher.ts +++ b/packages/worker/src/watcher/watcher.ts @@ -1,18 +1,13 @@ import { eventSchema } from '@runtipi/shared'; import { Worker } from 'bullmq'; -import { AppExecutors, RepoExecutors, SystemExecutors } from '@/executors'; -import { getEnv } from '@/utils/environment/environment'; -import { getUserIds } from '@/utils/environment/user'; -import { fileLogger } from '@/utils/logger/file-logger'; -import { execAsync } from '@/utils/exec-async/execAsync'; +import { AppExecutors, RepoExecutors, SystemExecutors } from '@/services'; +import { logger } from '@/lib/logger'; +import { getEnv } from '@/lib/environment'; const runCommand = async (jobData: unknown) => { - const { gid, uid } = getUserIds(); - fileLogger.info(`Running command with uid ${uid} and gid ${gid}`); - const { installApp, startApp, stopApp, uninstallApp, updateApp, regenerateAppEnv } = new AppExecutors(); const { cloneRepo, pullRepo } = new RepoExecutors(); - const { systemInfo, restart, update } = new SystemExecutors(); + const { systemInfo } = new SystemExecutors(); const event = eventSchema.safeParse(jobData); @@ -31,11 +26,11 @@ const runCommand = async (jobData: unknown) => { } if (data.command === 'stop') { - ({ success, message } = await stopApp(data.appid, data.form)); + ({ success, message } = await stopApp(data.appid, data.form, data.skipEnv)); } if (data.command === 'start') { - ({ success, message } = await startApp(data.appid, data.form)); + ({ success, message } = await startApp(data.appid, data.form, data.skipEnv)); } if (data.command === 'uninstall') { @@ -61,38 +56,11 @@ const runCommand = async (jobData: unknown) => { if (data.command === 'system_info') { ({ success, message } = await systemInfo()); } - - if (data.command === 'restart') { - ({ success, message } = await restart()); - } - - if (data.command === 'update') { - ({ success, message } = await update(data.version)); - } } return { success, message }; }; -export const killOtherWorkers = async () => { - const { stdout } = await execAsync('ps aux | grep "index.js watch" | grep -v grep | awk \'{print $2}\''); - const { stdout: stdoutInherit } = await execAsync('ps aux | grep "runtipi-cli watch" | grep -v grep | awk \'{print $2}\''); - - fileLogger.info(`Killing other workers with pids ${stdout} and ${stdoutInherit}`); - - const pids = stdout.split('\n').filter((pid: string) => pid !== ''); - const pidsInherit = stdoutInherit.split('\n').filter((pid: string) => pid !== ''); - - pids.concat(pidsInherit).forEach((pid) => { - fileLogger.info(`Killing worker with pid ${pid}`); - try { - process.kill(Number(pid)); - } catch (e) { - fileLogger.error(`Error killing worker with pid ${pid}: ${e}`); - } - }); -}; - /** * Start the worker for the events queue */ @@ -100,27 +68,27 @@ export const startWorker = async () => { const worker = new Worker( 'events', async (job) => { - fileLogger.info(`Processing job ${job.id} with data ${JSON.stringify(job.data)}`); + logger.info(`Processing job ${job.id} with data ${JSON.stringify(job.data)}`); const { message, success } = await runCommand(job.data); return { success, stdout: message }; }, - { connection: { host: '127.0.0.1', port: 6379, password: getEnv().redisPassword, connectTimeout: 60000 }, removeOnComplete: { count: 200 }, removeOnFail: { count: 500 } }, + { connection: { host: getEnv().redisHost, port: 6379, password: getEnv().redisPassword, connectTimeout: 60000 }, removeOnComplete: { count: 200 }, removeOnFail: { count: 500 } }, ); worker.on('ready', () => { - fileLogger.info('Worker is ready'); + logger.info('Worker is ready'); }); worker.on('completed', (job) => { - fileLogger.info(`Job ${job.id} completed with result:`, JSON.stringify(job.returnvalue)); + logger.info(`Job ${job.id} completed with result:`, JSON.stringify(job.returnvalue)); }); worker.on('failed', (job) => { - fileLogger.error(`Job ${job?.id} failed with reason ${job?.failedReason}`); + logger.error(`Job ${job?.id} failed with reason ${job?.failedReason}`); }); worker.on('error', async (e) => { - fileLogger.debug(`Worker error: ${e}`); + logger.debug(`Worker error: ${e}`); }); }; diff --git a/packages/worker/tests/apps.factory.ts b/packages/worker/tests/apps.factory.ts new file mode 100644 index 0000000000000000000000000000000000000000..72f917619ad1e2d8b17cbb5296475e31fe85a017 --- /dev/null +++ b/packages/worker/tests/apps.factory.ts @@ -0,0 +1,38 @@ +import { faker } from '@faker-js/faker'; +import fs from 'fs'; +import { APP_CATEGORIES, AppInfo, appInfoSchema } from '@runtipi/shared'; +import { ROOT_FOLDER, STORAGE_FOLDER } from '@/config/constants'; + +export const createAppConfig = (props?: Partial, isInstalled = true) => { + const appInfo = appInfoSchema.parse({ + id: faker.string.alphanumeric(32), + available: true, + port: faker.number.int({ min: 30, max: 65535 }), + name: faker.string.alphanumeric(32), + description: faker.string.alphanumeric(32), + tipi_version: 1, + short_desc: faker.string.alphanumeric(32), + author: faker.string.alphanumeric(32), + source: faker.internet.url(), + categories: [APP_CATEGORIES.AUTOMATION], + ...props, + }); + + const mockFiles: Record = {}; + mockFiles[`${ROOT_FOLDER}/.env`] = 'TEST=test'; + mockFiles[`${ROOT_FOLDER}/repos/repo-id/apps/${appInfo.id}/config.json`] = JSON.stringify(appInfoSchema.parse(appInfo)); + mockFiles[`${ROOT_FOLDER}/repos/repo-id/apps/${appInfo.id}/docker-compose.yml`] = 'compose'; + mockFiles[`${ROOT_FOLDER}/repos/repo-id/apps/${appInfo.id}/metadata/description.md`] = 'md desc'; + + if (isInstalled) { + mockFiles[`${ROOT_FOLDER}/apps/${appInfo.id}/config.json`] = JSON.stringify(appInfoSchema.parse(appInfo)); + mockFiles[`${ROOT_FOLDER}/apps/${appInfo.id}/docker-compose.yml`] = 'compose'; + mockFiles[`${ROOT_FOLDER}/apps/${appInfo.id}/metadata/description.md`] = 'md desc'; + mockFiles[`${STORAGE_FOLDER}/app-data/${appInfo.id}/data/test.txt`] = 'data'; + } + + // @ts-expect-error - custom mock method + fs.__applyMockFiles(mockFiles); + + return appInfo; +}; diff --git a/packages/worker/tests/mocks/fs.ts b/packages/worker/tests/mocks/fs.ts new file mode 100644 index 0000000000000000000000000000000000000000..6f2fa5b89427cacf08a6175cd98a1bad3c5c3212 --- /dev/null +++ b/packages/worker/tests/mocks/fs.ts @@ -0,0 +1,41 @@ +import { fs, vol } from 'memfs'; + +const copyFolderRecursiveSync = (src: string, dest: string) => { + const exists = vol.existsSync(src); + const stats = vol.statSync(src); + const isDirectory = exists && stats.isDirectory(); + if (isDirectory) { + vol.mkdirSync(dest, { recursive: true }); + vol.readdirSync(src).forEach((childItemName) => { + copyFolderRecursiveSync(`${src}/${childItemName}`, `${dest}/${childItemName}`); + }); + } else { + vol.copyFileSync(src, dest); + } +}; + +export const fsMock = { + default: { + ...fs, + promises: { + ...fs.promises, + cp: copyFolderRecursiveSync, + }, + copySync: (src: string, dest: string) => { + copyFolderRecursiveSync(src, dest); + }, + __resetAllMocks: () => { + vol.reset(); + }, + __applyMockFiles: (newMockFiles: Record) => { + // Create folder tree + vol.fromJSON(newMockFiles, 'utf8'); + }, + __createMockFiles: (newMockFiles: Record) => { + vol.reset(); + // Create folder tree + vol.fromJSON(newMockFiles, 'utf8'); + }, + __printVol: () => console.log(vol.toTree()), + }, +}; diff --git a/packages/worker/tests/vite.setup.ts b/packages/worker/tests/vite.setup.ts new file mode 100644 index 0000000000000000000000000000000000000000..f2d379d271f9323a25ffbea5ad601e236d7a2e84 --- /dev/null +++ b/packages/worker/tests/vite.setup.ts @@ -0,0 +1,42 @@ +import fs from 'fs'; +import path from 'path'; +import { vi, beforeEach } from 'vitest'; +import { getEnv } from '@/lib/environment'; +import { ROOT_FOLDER } from '@/config/constants'; + +vi.mock('@runtipi/shared', async (importOriginal) => { + const mod = (await importOriginal()) as object; + + return { + ...mod, + createLogger: vi.fn().mockReturnValue({ + info: vi.fn(), + error: vi.fn(), + }), + FileLogger: vi.fn().mockImplementation(() => ({ + flush: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + })), + }; +}); + +vi.mock('fs', async () => { + const { fsMock } = await import('@/tests/mocks/fs'); + return { + ...fsMock, + }; +}); + +beforeEach(async () => { + // @ts-expect-error - custom mock method + fs.__resetAllMocks(); + + const { appsRepoId } = getEnv(); + + await fs.promises.mkdir(path.join(ROOT_FOLDER, 'state'), { recursive: true }); + await fs.promises.writeFile(path.join(ROOT_FOLDER, 'state', 'seed'), 'seed'); + await fs.promises.mkdir(path.join(ROOT_FOLDER, 'repos', appsRepoId, 'apps'), { recursive: true }); +}); diff --git a/packages/worker/tsconfig.json b/packages/worker/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..247ac25ac58a0434a483ad48633ed6a8e82813f0 --- /dev/null +++ b/packages/worker/tsconfig.json @@ -0,0 +1,55 @@ +{ + "compilerOptions": { + "target": "es2017", + "baseUrl": ".", + "outDir": "./dist", + "paths": { + "@/lib/*": [ + "./src/lib/*" + ], + "@/services": [ + "./src/services" + ], + "@/config/*": [ + "./src/config/*" + ], + "@/tests/*": [ + "./tests/*" + ], + }, + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "CommonJS", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "strictNullChecks": true, + "allowSyntheticDefaultImports": true, + "noUncheckedIndexedAccess": true, + "types": [ + "node" + ], + "experimentalDecorators": true + }, + "include": [ + "**/*.ts", + "**/*.tsx", + "**/*.mjs", + "**/*.js", + "**/*.jsx" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/packages/worker/vitest.config.ts b/packages/worker/vitest.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..e623d863a8a823652e0ce515f9f41fc6148fae25 --- /dev/null +++ b/packages/worker/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; +import tsconfigPaths from 'vite-tsconfig-paths'; + +export default defineConfig({ + plugins: [tsconfigPaths()], + test: { + setupFiles: ['./tests/vite.setup.ts'], + coverage: { all: true, reporter: ['lcov', 'text-summary'] }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1e0a7beb759d29964bdb61cfae69e85c8cece169..65c8e0fbe527382a9fc59b3edc115ae8b84ff714 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,8 +51,8 @@ importers: specifier: ^2.40.0 version: 2.40.0(react@18.2.0) argon2: - specifier: ^0.31.1 - version: 0.31.1 + specifier: ^0.31.2 + version: 0.31.2 bullmq: specifier: ^4.13.0 version: 4.13.0 @@ -68,6 +68,9 @@ importers: fs-extra: specifier: ^11.1.1 version: 11.1.1 + let-it-go: + specifier: ^1.0.0 + version: 1.0.0 lodash.merge: specifier: ^4.6.2 version: 4.6.2 @@ -184,8 +187,8 @@ importers: specifier: ^0.5.1 version: 0.5.1 '@types/fs-extra': - specifier: ^11.0.3 - version: 11.0.3 + specifier: ^11.0.4 + version: 11.0.4 '@types/jest': specifier: ^29.5.7 version: 29.5.7 @@ -274,8 +277,8 @@ importers: specifier: ^29.7.0 version: 29.7.0 knip: - specifier: ^2.39.0 - version: 2.39.0 + specifier: ^2.41.3 + version: 2.41.3 memfs: specifier: ^4.6.0 version: 4.6.0(quill-delta@5.1.0)(rxjs@7.8.1)(tslib@2.6.2) @@ -312,9 +315,6 @@ importers: packages/cli: dependencies: - '@runtipi/postgres-migrations': - specifier: ^5.3.0 - version: 5.3.0 '@runtipi/shared': specifier: workspace:^ version: link:../shared @@ -342,24 +342,12 @@ importers: dotenv: specifier: ^16.3.1 version: 16.3.1 - ioredis: - specifier: ^5.3.2 - version: 5.3.2 log-update: specifier: ^5.0.1 version: 5.0.1 - pg: - specifier: ^8.11.3 - version: 8.11.3 semver: specifier: ^7.5.4 version: 7.5.4 - systeminformation: - specifier: ^5.21.15 - version: 5.21.15 - web-push: - specifier: ^3.6.6 - version: 3.6.6 zod: specifier: ^3.22.4 version: 3.22.4 @@ -373,9 +361,6 @@ importers: '@types/node': specifier: 20.8.10 version: 20.8.10 - '@types/web-push': - specifier: ^3.6.2 - version: 3.6.2 dotenv-cli: specifier: ^7.3.0 version: 7.3.0 @@ -385,6 +370,9 @@ importers: eslint-config-prettier: specifier: ^9.0.0 version: 9.0.0(eslint@8.52.0) + knip: + specifier: ^2.41.3 + version: 2.41.3 memfs: specifier: ^4.6.0 version: 4.6.0(quill-delta@5.1.0)(rxjs@7.8.1)(tslib@2.6.2) @@ -404,6 +392,63 @@ importers: specifier: ^0.34.6 version: 0.34.6(sass@1.69.5) + packages/cli/dist/bin/repos/7a92c8307e0a8074763c80be1fcfa4f87da6641daea9211aea6743b0116aba3b: + devDependencies: + '@commitlint/cli': + specifier: ^17.0.3 + version: 17.8.1 + '@commitlint/config-conventional': + specifier: ^17.0.3 + version: 17.8.1 + '@commitlint/cz-commitlint': + specifier: ^17.0.3 + version: 17.8.1(commitizen@4.3.0)(inquirer@8.2.5) + '@types/jest': + specifier: ^28.1.6 + version: 28.1.8 + '@types/js-yaml': + specifier: ^4.0.5 + version: 4.0.9 + '@types/node': + specifier: ^18.6.2 + version: 18.18.13 + '@types/semver': + specifier: ^7.5.0 + version: 7.5.4 + commitizen: + specifier: ^4.2.5 + version: 4.3.0(typescript@4.9.5) + eslint: + specifier: ^8.22.0 + version: 8.52.0 + eslint-plugin-json-schema-validator: + specifier: ^4.0.1 + version: 4.7.3(eslint@8.52.0) + eslint-plugin-jsonc: + specifier: ^2.4.0 + version: 2.10.0(eslint@8.52.0) + husky: + specifier: ^8.0.1 + version: 8.0.3 + jest: + specifier: ^28.1.3 + version: 28.1.3(@types/node@18.18.13)(ts-node@10.9.1) + js-yaml: + specifier: ^4.1.0 + version: 4.1.0 + prettier: + specifier: ^2.8.8 + version: 2.8.8 + semver: + specifier: ^7.5.2 + version: 7.5.4 + ts-jest: + specifier: ^28.0.7 + version: 28.0.8(@babel/core@7.23.2)(jest@28.1.3)(typescript@4.9.5) + typescript: + specifier: ^4.7.4 + version: 4.9.5 + packages/shared: dependencies: winston: @@ -413,6 +458,70 @@ importers: specifier: ^3.22.4 version: 3.22.4 + packages/worker: + dependencies: + '@runtipi/postgres-migrations': + specifier: ^5.3.0 + version: 5.3.0 + '@runtipi/shared': + specifier: workspace:^ + version: link:../shared + bullmq: + specifier: ^4.13.0 + version: 4.13.0 + dotenv: + specifier: ^16.3.1 + version: 16.3.1 + ioredis: + specifier: ^5.3.2 + version: 5.3.2 + pg: + specifier: ^8.11.3 + version: 8.11.3 + systeminformation: + specifier: ^5.21.15 + version: 5.21.15 + web-push: + specifier: ^3.6.6 + version: 3.6.6 + zod: + specifier: ^3.22.4 + version: 3.22.4 + devDependencies: + '@faker-js/faker': + specifier: ^8.2.0 + version: 8.2.0 + '@types/web-push': + specifier: ^3.6.3 + version: 3.6.3 + dotenv-cli: + specifier: ^7.3.0 + version: 7.3.0 + esbuild: + specifier: ^0.19.4 + version: 0.19.4 + knip: + specifier: ^2.41.3 + version: 2.41.3 + memfs: + specifier: ^4.6.0 + version: 4.6.0(quill-delta@5.1.0)(rxjs@7.8.1)(tslib@2.6.2) + nodemon: + specifier: ^3.0.1 + version: 3.0.1 + tsx: + specifier: ^3.14.0 + version: 3.14.0 + typescript: + specifier: ^5.2.2 + version: 5.2.2 + vite-tsconfig-paths: + specifier: ^4.2.1 + version: 4.2.1(typescript@5.2.2)(vite@4.5.0) + vitest: + specifier: ^0.34.6 + version: 0.34.6(sass@1.69.5) + packages: /@aashutoshrathi/word-wrap@1.2.6: @@ -829,6 +938,256 @@ packages: engines: {node: '>=0.1.90'} dev: false + /@commitlint/cli@17.8.1: + resolution: {integrity: sha512-ay+WbzQesE0Rv4EQKfNbSMiJJ12KdKTDzIt0tcK4k11FdsWmtwP0Kp1NWMOUswfIWo6Eb7p7Ln721Nx9FLNBjg==} + engines: {node: '>=v14'} + hasBin: true + dependencies: + '@commitlint/format': 17.8.1 + '@commitlint/lint': 17.8.1 + '@commitlint/load': 17.8.1 + '@commitlint/read': 17.8.1 + '@commitlint/types': 17.8.1 + execa: 5.1.1 + lodash.isfunction: 3.0.9 + resolve-from: 5.0.0 + resolve-global: 1.0.0 + yargs: 17.7.2 + transitivePeerDependencies: + - '@swc/core' + - '@swc/wasm' + dev: true + + /@commitlint/config-conventional@17.8.1: + resolution: {integrity: sha512-NxCOHx1kgneig3VLauWJcDWS40DVjg7nKOpBEEK9E5fjJpQqLCilcnKkIIjdBH98kEO1q3NpE5NSrZ2kl/QGJg==} + engines: {node: '>=v14'} + dependencies: + conventional-changelog-conventionalcommits: 6.1.0 + dev: true + + /@commitlint/config-validator@17.8.1: + resolution: {integrity: sha512-UUgUC+sNiiMwkyiuIFR7JG2cfd9t/7MV8VB4TZ+q02ZFkHoduUS4tJGsCBWvBOGD9Btev6IecPMvlWUfJorkEA==} + engines: {node: '>=v14'} + dependencies: + '@commitlint/types': 17.8.1 + ajv: 8.12.0 + dev: true + + /@commitlint/config-validator@18.4.3: + resolution: {integrity: sha512-FPZZmTJBARPCyef9ohRC9EANiQEKSWIdatx5OlgeHKu878dWwpyeFauVkhzuBRJFcCA4Uvz/FDtlDKs008IHcA==} + engines: {node: '>=v18'} + requiresBuild: true + dependencies: + '@commitlint/types': 18.4.3 + ajv: 8.12.0 + dev: true + optional: true + + /@commitlint/cz-commitlint@17.8.1(commitizen@4.3.0)(inquirer@8.2.5): + resolution: {integrity: sha512-7/13k+NxxqwYnrrb52g70qrXs5NQS7r/qV9GAwcoE/8LLWoziV38nsgELajFu6sNgai9X8d8IX5UyiB1M1zGjg==} + engines: {node: '>=v14'} + peerDependencies: + commitizen: ^4.0.3 + inquirer: ^8.0.0 + dependencies: + '@commitlint/ensure': 17.8.1 + '@commitlint/load': 17.8.1 + '@commitlint/types': 17.8.1 + chalk: 4.1.2 + commitizen: 4.3.0(typescript@4.9.5) + inquirer: 8.2.5 + lodash.isplainobject: 4.0.6 + word-wrap: 1.2.5 + transitivePeerDependencies: + - '@swc/core' + - '@swc/wasm' + dev: true + + /@commitlint/ensure@17.8.1: + resolution: {integrity: sha512-xjafwKxid8s1K23NFpL8JNo6JnY/ysetKo8kegVM7c8vs+kWLP8VrQq+NbhgVlmCojhEDbzQKp4eRXSjVOGsow==} + engines: {node: '>=v14'} + dependencies: + '@commitlint/types': 17.8.1 + lodash.camelcase: 4.3.0 + lodash.kebabcase: 4.1.1 + lodash.snakecase: 4.1.1 + lodash.startcase: 4.4.0 + lodash.upperfirst: 4.3.1 + dev: true + + /@commitlint/execute-rule@17.8.1: + resolution: {integrity: sha512-JHVupQeSdNI6xzA9SqMF+p/JjrHTcrJdI02PwesQIDCIGUrv04hicJgCcws5nzaoZbROapPs0s6zeVHoxpMwFQ==} + engines: {node: '>=v14'} + dev: true + + /@commitlint/execute-rule@18.4.3: + resolution: {integrity: sha512-t7FM4c+BdX9WWZCPrrbV5+0SWLgT3kCq7e7/GhHCreYifg3V8qyvO127HF796vyFql75n4TFF+5v1asOOWkV1Q==} + engines: {node: '>=v18'} + requiresBuild: true + dev: true + optional: true + + /@commitlint/format@17.8.1: + resolution: {integrity: sha512-f3oMTyZ84M9ht7fb93wbCKmWxO5/kKSbwuYvS867duVomoOsgrgljkGGIztmT/srZnaiGbaK8+Wf8Ik2tSr5eg==} + engines: {node: '>=v14'} + dependencies: + '@commitlint/types': 17.8.1 + chalk: 4.1.2 + dev: true + + /@commitlint/is-ignored@17.8.1: + resolution: {integrity: sha512-UshMi4Ltb4ZlNn4F7WtSEugFDZmctzFpmbqvpyxD3la510J+PLcnyhf9chs7EryaRFJMdAKwsEKfNK0jL/QM4g==} + engines: {node: '>=v14'} + dependencies: + '@commitlint/types': 17.8.1 + semver: 7.5.4 + dev: true + + /@commitlint/lint@17.8.1: + resolution: {integrity: sha512-aQUlwIR1/VMv2D4GXSk7PfL5hIaFSfy6hSHV94O8Y27T5q+DlDEgd/cZ4KmVI+MWKzFfCTiTuWqjfRSfdRllCA==} + engines: {node: '>=v14'} + dependencies: + '@commitlint/is-ignored': 17.8.1 + '@commitlint/parse': 17.8.1 + '@commitlint/rules': 17.8.1 + '@commitlint/types': 17.8.1 + dev: true + + /@commitlint/load@17.8.1: + resolution: {integrity: sha512-iF4CL7KDFstP1kpVUkT8K2Wl17h2yx9VaR1ztTc8vzByWWcbO/WaKwxsnCOqow9tVAlzPfo1ywk9m2oJ9ucMqA==} + engines: {node: '>=v14'} + dependencies: + '@commitlint/config-validator': 17.8.1 + '@commitlint/execute-rule': 17.8.1 + '@commitlint/resolve-extends': 17.8.1 + '@commitlint/types': 17.8.1 + '@types/node': 20.5.1 + chalk: 4.1.2 + cosmiconfig: 8.3.6(typescript@5.2.2) + cosmiconfig-typescript-loader: 4.4.0(@types/node@20.5.1)(cosmiconfig@8.3.6)(ts-node@10.9.1)(typescript@5.2.2) + lodash.isplainobject: 4.0.6 + lodash.merge: 4.6.2 + lodash.uniq: 4.5.0 + resolve-from: 5.0.0 + ts-node: 10.9.1(@types/node@18.18.13)(typescript@4.9.5) + typescript: 5.2.2 + transitivePeerDependencies: + - '@swc/core' + - '@swc/wasm' + dev: true + + /@commitlint/load@18.4.3(typescript@4.9.5): + resolution: {integrity: sha512-v6j2WhvRQJrcJaj5D+EyES2WKTxPpxENmNpNG3Ww8MZGik3jWRXtph0QTzia5ZJyPh2ib5aC/6BIDymkUUM58Q==} + engines: {node: '>=v18'} + requiresBuild: true + dependencies: + '@commitlint/config-validator': 18.4.3 + '@commitlint/execute-rule': 18.4.3 + '@commitlint/resolve-extends': 18.4.3 + '@commitlint/types': 18.4.3 + '@types/node': 18.18.13 + chalk: 4.1.2 + cosmiconfig: 8.3.6(typescript@4.9.5) + cosmiconfig-typescript-loader: 5.0.0(@types/node@18.18.13)(cosmiconfig@8.3.6)(typescript@4.9.5) + lodash.isplainobject: 4.0.6 + lodash.merge: 4.6.2 + lodash.uniq: 4.5.0 + resolve-from: 5.0.0 + transitivePeerDependencies: + - typescript + dev: true + optional: true + + /@commitlint/message@17.8.1: + resolution: {integrity: sha512-6bYL1GUQsD6bLhTH3QQty8pVFoETfFQlMn2Nzmz3AOLqRVfNNtXBaSY0dhZ0dM6A2MEq4+2d7L/2LP8TjqGRkA==} + engines: {node: '>=v14'} + dev: true + + /@commitlint/parse@17.8.1: + resolution: {integrity: sha512-/wLUickTo0rNpQgWwLPavTm7WbwkZoBy3X8PpkUmlSmQJyWQTj0m6bDjiykMaDt41qcUbfeFfaCvXfiR4EGnfw==} + engines: {node: '>=v14'} + dependencies: + '@commitlint/types': 17.8.1 + conventional-changelog-angular: 6.0.0 + conventional-commits-parser: 4.0.0 + dev: true + + /@commitlint/read@17.8.1: + resolution: {integrity: sha512-Fd55Oaz9irzBESPCdMd8vWWgxsW3OWR99wOntBDHgf9h7Y6OOHjWEdS9Xzen1GFndqgyoaFplQS5y7KZe0kO2w==} + engines: {node: '>=v14'} + dependencies: + '@commitlint/top-level': 17.8.1 + '@commitlint/types': 17.8.1 + fs-extra: 11.1.1 + git-raw-commits: 2.0.11 + minimist: 1.2.8 + dev: true + + /@commitlint/resolve-extends@17.8.1: + resolution: {integrity: sha512-W/ryRoQ0TSVXqJrx5SGkaYuAaE/BUontL1j1HsKckvM6e5ZaG0M9126zcwL6peKSuIetJi7E87PRQF8O86EW0Q==} + engines: {node: '>=v14'} + dependencies: + '@commitlint/config-validator': 17.8.1 + '@commitlint/types': 17.8.1 + import-fresh: 3.3.0 + lodash.mergewith: 4.6.2 + resolve-from: 5.0.0 + resolve-global: 1.0.0 + dev: true + + /@commitlint/resolve-extends@18.4.3: + resolution: {integrity: sha512-30sk04LZWf8+SDgJrbJCjM90gTg2LxsD9cykCFeFu+JFHvBFq5ugzp2eO/DJGylAdVaqxej3c7eTSE64hR/lnw==} + engines: {node: '>=v18'} + requiresBuild: true + dependencies: + '@commitlint/config-validator': 18.4.3 + '@commitlint/types': 18.4.3 + import-fresh: 3.3.0 + lodash.mergewith: 4.6.2 + resolve-from: 5.0.0 + resolve-global: 1.0.0 + dev: true + optional: true + + /@commitlint/rules@17.8.1: + resolution: {integrity: sha512-2b7OdVbN7MTAt9U0vKOYKCDsOvESVXxQmrvuVUZ0rGFMCrCPJWWP1GJ7f0lAypbDAhaGb8zqtdOr47192LBrIA==} + engines: {node: '>=v14'} + dependencies: + '@commitlint/ensure': 17.8.1 + '@commitlint/message': 17.8.1 + '@commitlint/to-lines': 17.8.1 + '@commitlint/types': 17.8.1 + execa: 5.1.1 + dev: true + + /@commitlint/to-lines@17.8.1: + resolution: {integrity: sha512-LE0jb8CuR/mj6xJyrIk8VLz03OEzXFgLdivBytoooKO5xLt5yalc8Ma5guTWobw998sbR3ogDd+2jed03CFmJA==} + engines: {node: '>=v14'} + dev: true + + /@commitlint/top-level@17.8.1: + resolution: {integrity: sha512-l6+Z6rrNf5p333SHfEte6r+WkOxGlWK4bLuZKbtf/2TXRN+qhrvn1XE63VhD8Oe9oIHQ7F7W1nG2k/TJFhx2yA==} + engines: {node: '>=v14'} + dependencies: + find-up: 5.0.0 + dev: true + + /@commitlint/types@17.8.1: + resolution: {integrity: sha512-PXDQXkAmiMEG162Bqdh9ChML/GJZo6vU+7F03ALKDK8zYc6SuAr47LjG7hGYRqUOz+WK0dU7bQ0xzuqFMdxzeQ==} + engines: {node: '>=v14'} + dependencies: + chalk: 4.1.2 + dev: true + + /@commitlint/types@18.4.3: + resolution: {integrity: sha512-cvzx+vtY/I2hVBZHCLrpoh+sA0hfuzHwDc+BAFPimYLjJkpHnghQM+z8W/KyLGkygJh3BtI3xXXq+dKjnSWEmA==} + engines: {node: '>=v18'} + requiresBuild: true + dependencies: + chalk: 4.1.2 + dev: true + optional: true + /@cspotcode/source-map-support@0.8.1: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} @@ -1524,6 +1883,18 @@ packages: engines: {node: '>=8'} dev: true + /@jest/console@28.1.3: + resolution: {integrity: sha512-QPAkP5EwKdK/bxIr6C1I4Vs0rm2nHiANzj/Z5X2JQkrZo6IqvC4ldZ9K95tF0HdidhA8Bo6egxSzUFPYKcEXLw==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + dependencies: + '@jest/types': 28.1.3 + '@types/node': 18.18.13 + chalk: 4.1.2 + jest-message-util: 28.1.3 + jest-util: 28.1.3 + slash: 3.0.0 + dev: true + /@jest/console@29.7.0: resolution: {integrity: sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -1536,6 +1907,49 @@ packages: slash: 3.0.0 dev: true + /@jest/core@28.1.3(ts-node@10.9.1): + resolution: {integrity: sha512-CIKBrlaKOzA7YG19BEqCw3SLIsEwjZkeJzf5bdooVnW4bH5cktqe3JX+G2YV1aK5vP8N9na1IGWFzYaTp6k6NA==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + dependencies: + '@jest/console': 28.1.3 + '@jest/reporters': 28.1.3 + '@jest/test-result': 28.1.3 + '@jest/transform': 28.1.3 + '@jest/types': 28.1.3 + '@types/node': 18.18.13 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + ci-info: 3.8.0 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-changed-files: 28.1.3 + jest-config: 28.1.3(@types/node@18.18.13)(ts-node@10.9.1) + jest-haste-map: 28.1.3 + jest-message-util: 28.1.3 + jest-regex-util: 28.0.2 + jest-resolve: 28.1.3 + jest-resolve-dependencies: 28.1.3 + jest-runner: 28.1.3 + jest-runtime: 28.1.3 + jest-snapshot: 28.1.3 + jest-util: 28.1.3 + jest-validate: 28.1.3 + jest-watcher: 28.1.3 + micromatch: 4.0.5 + pretty-format: 28.1.3 + rimraf: 3.0.2 + slash: 3.0.0 + strip-ansi: 6.0.1 + transitivePeerDependencies: + - supports-color + - ts-node + dev: true + /@jest/core@29.7.0(ts-node@10.9.1): resolution: {integrity: sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -1579,6 +1993,16 @@ packages: - ts-node dev: true + /@jest/environment@28.1.3: + resolution: {integrity: sha512-1bf40cMFTEkKyEf585R9Iz1WayDjHoHqvts0XFYEqyKM3cFWDpeMoqKKTAF9LSYQModPUlh8FKptoM2YcMWAXA==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + dependencies: + '@jest/fake-timers': 28.1.3 + '@jest/types': 28.1.3 + '@types/node': 18.18.13 + jest-mock: 28.1.3 + dev: true + /@jest/environment@29.7.0: resolution: {integrity: sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -1589,6 +2013,13 @@ packages: jest-mock: 29.7.0 dev: true + /@jest/expect-utils@28.1.3: + resolution: {integrity: sha512-wvbi9LUrHJLn3NlDW6wF2hvIMtd4JUl2QNVrjq+IBSHirgfrR3o9RnVtxzdEGO2n9JyIWwHnLfby5KzqBGg2YA==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + dependencies: + jest-get-type: 28.0.2 + dev: true + /@jest/expect-utils@29.7.0: resolution: {integrity: sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -1596,6 +2027,16 @@ packages: jest-get-type: 29.6.3 dev: true + /@jest/expect@28.1.3: + resolution: {integrity: sha512-lzc8CpUbSoE4dqT0U+g1qODQjBRHPpCPXissXD4mS9+sWQdmmpeJ9zSH1rS1HEkrsMN0fb7nKrJ9giAR1d3wBw==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + dependencies: + expect: 28.1.3 + jest-snapshot: 28.1.3 + transitivePeerDependencies: + - supports-color + dev: true + /@jest/expect@29.7.0: resolution: {integrity: sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -1606,6 +2047,18 @@ packages: - supports-color dev: true + /@jest/fake-timers@28.1.3: + resolution: {integrity: sha512-D/wOkL2POHv52h+ok5Oj/1gOG9HSywdoPtFsRCUmlCILXNn5eIWmcnd3DIiWlJnpGvQtmajqBP95Ei0EimxfLw==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + dependencies: + '@jest/types': 28.1.3 + '@sinonjs/fake-timers': 9.1.2 + '@types/node': 18.18.13 + jest-message-util: 28.1.3 + jest-mock: 28.1.3 + jest-util: 28.1.3 + dev: true + /@jest/fake-timers@29.7.0: resolution: {integrity: sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -1618,6 +2071,17 @@ packages: jest-util: 29.7.0 dev: true + /@jest/globals@28.1.3: + resolution: {integrity: sha512-XFU4P4phyryCXu1pbcqMO0GSQcYe1IsalYCDzRNyhetyeyxMcIxa11qPNDpVNLeretItNqEmYYQn1UYz/5x1NA==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + dependencies: + '@jest/environment': 28.1.3 + '@jest/expect': 28.1.3 + '@jest/types': 28.1.3 + transitivePeerDependencies: + - supports-color + dev: true + /@jest/globals@29.7.0: resolution: {integrity: sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -1630,9 +2094,9 @@ packages: - supports-color dev: true - /@jest/reporters@29.7.0: - resolution: {integrity: sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + /@jest/reporters@28.1.3: + resolution: {integrity: sha512-JuAy7wkxQZVNU/V6g9xKzCGC5LVXx9FDcABKsSXp5MiKPEE2144a/vXTEDoyzjUpZKfVwp08Wqg5A4WfTMAzjg==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} peerDependencies: node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 peerDependenciesMeta: @@ -1640,40 +2104,94 @@ packages: optional: true dependencies: '@bcoe/v8-coverage': 0.2.3 - '@jest/console': 29.7.0 - '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 + '@jest/console': 28.1.3 + '@jest/test-result': 28.1.3 + '@jest/transform': 28.1.3 + '@jest/types': 28.1.3 '@jridgewell/trace-mapping': 0.3.19 - '@types/node': 20.8.10 + '@types/node': 18.18.13 chalk: 4.1.2 collect-v8-coverage: 1.0.1 exit: 0.1.2 glob: 7.2.3 graceful-fs: 4.2.11 istanbul-lib-coverage: 3.2.0 - istanbul-lib-instrument: 6.0.1 + istanbul-lib-instrument: 5.2.1 istanbul-lib-report: 3.0.1 istanbul-lib-source-maps: 4.0.1 istanbul-reports: 3.1.5 - jest-message-util: 29.7.0 - jest-util: 29.7.0 - jest-worker: 29.7.0 + jest-message-util: 28.1.3 + jest-util: 28.1.3 + jest-worker: 28.1.3 slash: 3.0.0 string-length: 4.0.2 strip-ansi: 6.0.1 + terminal-link: 2.1.1 v8-to-istanbul: 9.1.0 transitivePeerDependencies: - supports-color dev: true - /@jest/schemas@29.6.3: - resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + /@jest/reporters@29.7.0: + resolution: {integrity: sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + dependencies: + '@bcoe/v8-coverage': 0.2.3 + '@jest/console': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@jridgewell/trace-mapping': 0.3.19 + '@types/node': 20.8.10 + chalk: 4.1.2 + collect-v8-coverage: 1.0.1 + exit: 0.1.2 + glob: 7.2.3 + graceful-fs: 4.2.11 + istanbul-lib-coverage: 3.2.0 + istanbul-lib-instrument: 6.0.1 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 4.0.1 + istanbul-reports: 3.1.5 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + jest-worker: 29.7.0 + slash: 3.0.0 + string-length: 4.0.2 + strip-ansi: 6.0.1 + v8-to-istanbul: 9.1.0 + transitivePeerDependencies: + - supports-color + dev: true + + /@jest/schemas@28.1.3: + resolution: {integrity: sha512-/l/VWsdt/aBXgjshLWOFyFt3IVdYypu5y2Wn2rOO1un6nkqIn8SLXzgIMYXFyYsRWDyF5EthmKJMIdJvk08grg==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + dependencies: + '@sinclair/typebox': 0.24.51 + dev: true + + /@jest/schemas@29.6.3: + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: '@sinclair/typebox': 0.27.8 dev: true + /@jest/source-map@28.1.2: + resolution: {integrity: sha512-cV8Lx3BeStJb8ipPHnqVw/IM2VCMWO3crWZzYodSIkxXnRcXJipCdx1JCK0K5MsJJouZQTH73mzf4vgxRaH9ww==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + dependencies: + '@jridgewell/trace-mapping': 0.3.19 + callsites: 3.1.0 + graceful-fs: 4.2.11 + dev: true + /@jest/source-map@29.6.3: resolution: {integrity: sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -1683,6 +2201,16 @@ packages: graceful-fs: 4.2.11 dev: true + /@jest/test-result@28.1.3: + resolution: {integrity: sha512-kZAkxnSE+FqE8YjW8gNuoVkkC9I7S1qmenl8sGcDOLropASP+BkcGKwhXoyqQuGOGeYY0y/ixjrd/iERpEXHNg==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + dependencies: + '@jest/console': 28.1.3 + '@jest/types': 28.1.3 + '@types/istanbul-lib-coverage': 2.0.4 + collect-v8-coverage: 1.0.1 + dev: true + /@jest/test-result@29.7.0: resolution: {integrity: sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -1693,6 +2221,16 @@ packages: collect-v8-coverage: 1.0.1 dev: true + /@jest/test-sequencer@28.1.3: + resolution: {integrity: sha512-NIMPEqqa59MWnDi1kvXXpYbqsfQmSJsIbnd85mdVGkiDfQ9WQQTXOLsvISUfonmnBT+w85WEgneCigEEdHDFxw==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + dependencies: + '@jest/test-result': 28.1.3 + graceful-fs: 4.2.11 + jest-haste-map: 28.1.3 + slash: 3.0.0 + dev: true + /@jest/test-sequencer@29.7.0: resolution: {integrity: sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -1703,6 +2241,29 @@ packages: slash: 3.0.0 dev: true + /@jest/transform@28.1.3: + resolution: {integrity: sha512-u5dT5di+oFI6hfcLOHGTAfmUxFRrjK+vnaP0kkVow9Md/M7V/MxqQMOz/VV25UZO8pzeA9PjfTpOu6BDuwSPQA==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + dependencies: + '@babel/core': 7.23.2 + '@jest/types': 28.1.3 + '@jridgewell/trace-mapping': 0.3.19 + babel-plugin-istanbul: 6.1.1 + chalk: 4.1.2 + convert-source-map: 1.9.0 + fast-json-stable-stringify: 2.1.0 + graceful-fs: 4.2.11 + jest-haste-map: 28.1.3 + jest-regex-util: 28.0.2 + jest-util: 28.1.3 + micromatch: 4.0.5 + pirates: 4.0.5 + slash: 3.0.0 + write-file-atomic: 4.0.2 + transitivePeerDependencies: + - supports-color + dev: true + /@jest/transform@29.7.0: resolution: {integrity: sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -1726,6 +2287,18 @@ packages: - supports-color dev: true + /@jest/types@28.1.3: + resolution: {integrity: sha512-RyjiyMUZrKz/c+zlMFO1pm70DcIlST8AeWTkoUdZevew44wcNZQHsEVOiCVtgVnlFFD82FPaXycys58cf2muVQ==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + dependencies: + '@jest/schemas': 28.1.3 + '@types/istanbul-lib-coverage': 2.0.4 + '@types/istanbul-reports': 3.0.1 + '@types/node': 18.18.13 + '@types/yargs': 17.0.22 + chalk: 4.1.2 + dev: true + /@jest/types@29.6.3: resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -2009,6 +2582,18 @@ packages: requiresBuild: true dev: true + /@pkgr/utils@2.4.2: + resolution: {integrity: sha512-POgTXhjrTfbTV63DiFXav4lBHiICLKKwDeaKn9Nphwj7WH6m0hMMCaJkMyRWjgtPFyRKRVoMXXjczsTQRDEhYw==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + dependencies: + cross-spawn: 7.0.3 + fast-glob: 3.3.1 + is-glob: 4.0.3 + open: 9.1.0 + picocolors: 1.0.0 + tslib: 2.6.2 + dev: true + /@playwright/test@1.39.0: resolution: {integrity: sha512-3u1iFqgzl7zr004bGPYiN/5EZpRUSFddQBra8Rqll5N0/vfpqlP9I9EXqAoGacuAbX6c9Ulg/Cjqglp5VkK6UQ==} engines: {node: '>=16'} @@ -2876,10 +3461,20 @@ packages: resolution: {integrity: sha512-EF3948ckf3f5uPgYbQ6GhyA56Dmv8yg0+ir+BroRjwdxyZJsekhZzawOecC2rOTPCz173t7ZcR1HHZu0dZgOCw==} dev: true + /@sinclair/typebox@0.24.51: + resolution: {integrity: sha512-1P1OROm/rdubP5aFDSZQILU0vrLCJ4fvHt6EoqHEM+2D/G5MK3bIaymUKLit8Js9gbns5UyJnkP/TZROLw4tUA==} + dev: true + /@sinclair/typebox@0.27.8: resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} dev: true + /@sinonjs/commons@1.8.6: + resolution: {integrity: sha512-Ky+XkAkqPZSm3NLBeUng77EBQl3cmeJhITaGHdYH8kjVB+aun3S4XBRti2zt17mtt0mIUDiNxYeoJm6drVvBJQ==} + dependencies: + type-detect: 4.0.8 + dev: true + /@sinonjs/commons@2.0.0: resolution: {integrity: sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==} dependencies: @@ -2892,6 +3487,12 @@ packages: '@sinonjs/commons': 2.0.0 dev: true + /@sinonjs/fake-timers@9.1.2: + resolution: {integrity: sha512-BPS4ynJW/o92PUR4wgriz2Ud5gpST5vz6GQfMixEDK0Z8ZCUv2M7SkBLykH56T++Xs+8ln9zTGbOvNGIe02/jw==} + dependencies: + '@sinonjs/commons': 1.8.6 + dev: true + /@snyk/github-codeowners@1.1.0: resolution: {integrity: sha512-lGFf08pbkEac0NYgVf4hdANpAgApRjNByLXB+WBip3qj1iendOIyAwP2GKkKbQMNVy2r1xxDf0ssfWscoiC+Vw==} engines: {node: '>=8.10'} @@ -3152,8 +3753,8 @@ packages: dependencies: '@types/ms': 0.7.31 - /@types/fs-extra@11.0.3: - resolution: {integrity: sha512-sF59BlXtUdzEAL1u0MSvuzWd7PdZvZEtnaVkzX5mjpdWTJ8brG0jUqve3jPCzSzvAKKMHTG8F8o/WMQLtleZdQ==} + /@types/fs-extra@11.0.4: + resolution: {integrity: sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==} dependencies: '@types/jsonfile': 6.1.1 '@types/node': 20.8.10 @@ -3187,6 +3788,13 @@ packages: '@types/istanbul-lib-report': 3.0.0 dev: true + /@types/jest@28.1.8: + resolution: {integrity: sha512-8TJkV++s7B6XqnDrzR1m/TT0A0h948Pnl/097veySPN67VRAgQ4gZ7n2KfJo2rVq6njQjdxU3GCCyDvAeuHoiw==} + dependencies: + expect: 28.1.3 + pretty-format: 28.1.3 + dev: true + /@types/jest@29.5.7: resolution: {integrity: sha512-HLyetab6KVPSiF+7pFcUyMeLsx25LDNDemw9mGsJBkai/oouwrjTycocSDYopMEwFhN2Y4s9oPyOCZNofgSt2g==} dependencies: @@ -3198,6 +3806,10 @@ packages: resolution: {integrity: sha512-qC4bCqYGy1y/NP7dDVr7KJarn+PbX1nSpwA7JXdu0HxT3QYjO8MJ+cntENtHFVy2dRAyBV23OZ6MxsW1AM1L8g==} dev: true + /@types/js-yaml@4.0.9: + resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + dev: true + /@types/jsdom@20.0.1: resolution: {integrity: sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==} dependencies: @@ -3236,14 +3848,32 @@ packages: '@types/unist': 3.0.0 dev: false + /@types/minimist@1.2.5: + resolution: {integrity: sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==} + dev: true + /@types/ms@0.7.31: resolution: {integrity: sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==} + /@types/node@18.18.13: + resolution: {integrity: sha512-vXYZGRrSCreZmq1rEjMRLXJhiy8MrIeVasx+PCVlP414N7CJLHnMf+juVvjdprHyH+XRy3zKZLHeNueOpJCn0g==} + dependencies: + undici-types: 5.26.5 + dev: true + + /@types/node@20.5.1: + resolution: {integrity: sha512-4tT2UrL5LBqDwoed9wZ6N3umC4Yhz3W3FloMmiiG4JwmUJWpie0c7lcnUNd4gtMKuDEO4wRVS8B6Xa0uMRsMKg==} + dev: true + /@types/node@20.8.10: resolution: {integrity: sha512-TlgT8JntpcbmKUFzjhsyhGfP2fsiz1Mv56im6enJ905xG1DAYesxJaeSbGqQmAw8OWPdhyJGhGSQGKRNJ45u9w==} dependencies: undici-types: 5.26.5 + /@types/normalize-package-data@2.4.4: + resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} + dev: true + /@types/parse-json@4.0.0: resolution: {integrity: sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==} dev: false @@ -3255,6 +3885,10 @@ packages: pg-protocol: 1.6.0 pg-types: 4.0.1 + /@types/prettier@2.7.3: + resolution: {integrity: sha512-+68kP9yzs4LMp7VNh8gdzMSPZFL44MLGqiHWvttYJe+6qnuVr4Ek9wSBQoveqY/r+LwjCcU29kNVkidwim+kYA==} + dev: true + /@types/prop-types@15.7.5: resolution: {integrity: sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==} @@ -3317,8 +3951,8 @@ packages: resolution: {integrity: sha512-xW4qsT4UIYILu+7ZrBnfQdBYniZrMLYYK3wN9M/NdeIHgBN5pZI2/8Q7UfdWIcr5RLJv/OGENsx91JIpUUoC7Q==} dev: true - /@types/web-push@3.6.2: - resolution: {integrity: sha512-v6Wdk1eIVbAJQjEAa1ZxuG3cfOYTd6nSv55BVJMtLQUvQ07v80MPt2Voq/z71WKhy4CORu4L3aH+8SXKX4BD5g==} + /@types/web-push@3.6.3: + resolution: {integrity: sha512-v3oT4mMJsHeJ/rraliZ+7TbZtr5bQQuxcgD7C3/1q/zkAj29c8RE0F9lVZVu3hiQe5Z9fYcBreV7TLnfKR+4mg==} dependencies: '@types/node': 20.8.10 dev: true @@ -3669,6 +4303,14 @@ packages: dev: true optional: true + /JSONStream@1.3.5: + resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==} + hasBin: true + dependencies: + jsonparse: 1.3.1 + through: 2.3.8 + dev: true + /abab@2.0.6: resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==} dev: true @@ -3742,6 +4384,15 @@ packages: uri-js: 4.4.1 dev: true + /ajv@8.12.0: + resolution: {integrity: sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==} + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + uri-js: 4.4.1 + dev: true + /ansi-align@3.0.1: resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} dependencies: @@ -3819,8 +4470,8 @@ packages: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} dev: true - /argon2@0.31.1: - resolution: {integrity: sha512-ik2xnJrLXazya7m4Nz1XfBSRjXj8Koq8qF9PsQC8059p20ifWc9zx/hgU3ItZh/3TnwXkv0RbhvjodPkmFf0bg==} + /argon2@0.31.2: + resolution: {integrity: sha512-QSnJ8By5Mth60IEte45w9Y7v6bWcQw3YhRtJKKN8oNCxnTLDiv/AXXkDPf2srTMfxFVn3QJdVv2nhXESsUa+Yg==} engines: {node: '>=14.0.0'} requiresBuild: true dependencies: @@ -3872,6 +4523,10 @@ packages: is-array-buffer: 3.0.2 dev: true + /array-ify@1.0.0: + resolution: {integrity: sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==} + dev: true + /array-includes@3.1.6: resolution: {integrity: sha512-sgTbLvL6cNnw24FnbaDyjmvddQ2ML8arZsgaJhoABMoplz/4QRhtrYS+alr1BUM1Bwp6dhx8vVCBSLG+StwOFw==} engines: {node: '>= 0.4'} @@ -3970,6 +4625,11 @@ packages: is-shared-array-buffer: 1.0.2 dev: true + /arrify@1.0.1: + resolution: {integrity: sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==} + engines: {node: '>=0.10.0'} + dev: true + /asn1.js@5.4.1: resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==} dependencies: @@ -4035,6 +4695,24 @@ packages: resolution: {integrity: sha512-fpWrvyVHEKyeEvbKZTVOeZF3VSKKWtJxFIxX/jaVPf+cLbGUSitjb49pHLqPV2BUNNZ0LcoeEGfE/YCpyDYHIw==} dev: false + /babel-jest@28.1.3(@babel/core@7.23.2): + resolution: {integrity: sha512-epUaPOEWMk3cWX0M/sPvCHHCe9fMFAa/9hXEgKP8nFfNl/jlGkE9ucq9NqkZGXLDduCJYS0UvSlPUwC0S+rH6Q==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + peerDependencies: + '@babel/core': ^7.8.0 + dependencies: + '@babel/core': 7.23.2 + '@jest/transform': 28.1.3 + '@types/babel__core': 7.20.3 + babel-plugin-istanbul: 6.1.1 + babel-preset-jest: 28.1.3(@babel/core@7.23.2) + chalk: 4.1.2 + graceful-fs: 4.2.11 + slash: 3.0.0 + transitivePeerDependencies: + - supports-color + dev: true + /babel-jest@29.7.0(@babel/core@7.23.2): resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -4066,6 +4744,16 @@ packages: - supports-color dev: true + /babel-plugin-jest-hoist@28.1.3: + resolution: {integrity: sha512-Ys3tUKAmfnkRUpPdpa98eYrAR0nV+sSFUZZEGuQ2EbFd1y4SOLtD5QDNHAq+bb9a+bbXvYQC4b+ID/THIMcU6Q==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + dependencies: + '@babel/template': 7.22.15 + '@babel/types': 7.23.0 + '@types/babel__core': 7.20.3 + '@types/babel__traverse': 7.18.3 + dev: true + /babel-plugin-jest-hoist@29.6.3: resolution: {integrity: sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -4105,6 +4793,17 @@ packages: '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.23.2) dev: true + /babel-preset-jest@28.1.3(@babel/core@7.23.2): + resolution: {integrity: sha512-L+fupJvlWAHbQfn74coNX3zf60LXMJsezNvvx8eIh7iOR1luJ1poxYgQk1F8PYtNq/6QODDHCqsSnTFSWC491A==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.23.2 + babel-plugin-jest-hoist: 28.1.3 + babel-preset-current-node-syntax: 1.0.1(@babel/core@7.23.2) + dev: true + /babel-preset-jest@29.6.3(@babel/core@7.23.2): resolution: {integrity: sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -4131,6 +4830,11 @@ packages: /base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + /big-integer@1.6.52: + resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} + engines: {node: '>=0.6'} + dev: true + /binary-extensions@2.2.0: resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==} engines: {node: '>=8'} @@ -4175,6 +4879,13 @@ packages: wrap-ansi: 8.1.0 dev: false + /bplist-parser@0.2.0: + resolution: {integrity: sha512-z0M+byMThzQmD9NILRniCUXYsYpjwnlO8N5uCFaCqIOpqRsJCrQL9NK3JsD67CN5a08nF5oIL2bD6loTdHOuKw==} + engines: {node: '>= 5.10.0'} + dependencies: + big-integer: 1.6.52 + dev: true + /brace-expansion@1.1.11: resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} dependencies: @@ -4256,6 +4967,13 @@ packages: - supports-color dev: false + /bundle-name@3.0.0: + resolution: {integrity: sha512-PKA4BeSvBpQKQ8iPOGCSiell+N8P+Tf1DlwqmYhpe2gAhKPHn8EYOxVT+ShuGmhg8lN8XiSlS80yiExKXrURlw==} + engines: {node: '>=12'} + dependencies: + run-applescript: 5.0.0 + dev: true + /busboy@1.6.0: resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} engines: {node: '>=10.16.0'} @@ -4267,6 +4985,11 @@ packages: engines: {node: '>=8'} dev: true + /cachedir@2.3.0: + resolution: {integrity: sha512-A+Fezp4zxnit6FanDmv9EqXNAi3vt9DWp51/71UEhXukb7QUuvtv9344h91dyAxuTLoSYJFU299qzR3tzwPAhw==} + engines: {node: '>=6'} + dev: true + /call-bind@1.0.2: resolution: {integrity: sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==} dependencies: @@ -4278,6 +5001,15 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + /camelcase-keys@6.2.2: + resolution: {integrity: sha512-YrwaA0vEKazPBkn0ipTiMpSajYDSe+KjQfrjhcBMxJt/znbvlHd8Pw/Vamaz5EB4Wfhs3SUR3Z9mwRu/P3s3Yg==} + engines: {node: '>=8'} + dependencies: + camelcase: 5.3.1 + map-obj: 4.3.0 + quick-lru: 4.0.1 + dev: true + /camelcase@5.3.1: resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} engines: {node: '>=6'} @@ -4551,6 +5283,36 @@ packages: engines: {node: '>= 6'} dev: true + /commitizen@4.3.0(typescript@4.9.5): + resolution: {integrity: sha512-H0iNtClNEhT0fotHvGV3E9tDejDeS04sN1veIebsKYGMuGscFaswRoYJKmT3eW85eIJAs0F28bG2+a/9wCOfPw==} + engines: {node: '>= 12'} + hasBin: true + dependencies: + cachedir: 2.3.0 + cz-conventional-changelog: 3.3.0(typescript@4.9.5) + dedent: 0.7.0 + detect-indent: 6.1.0 + find-node-modules: 2.1.3 + find-root: 1.1.0 + fs-extra: 9.1.0 + glob: 7.2.3 + inquirer: 8.2.5 + is-utf8: 0.2.1 + lodash: 4.17.21 + minimist: 1.2.7 + strip-bom: 4.0.0 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - typescript + dev: true + + /compare-func@2.0.0: + resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==} + dependencies: + array-ify: 1.0.0 + dot-prop: 5.3.0 + dev: true + /compose-function@3.0.3: resolution: {integrity: sha512-xzhzTJ5eC+gmIzvZq+C3kCJHsp9os6tJkrigDRZclyGtOKINbZtE8n1Tzmeh32jW+BUDPbvZpibwvJHBLGMVwg==} dependencies: @@ -4577,6 +5339,35 @@ packages: resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==} dev: false + /conventional-changelog-angular@6.0.0: + resolution: {integrity: sha512-6qLgrBF4gueoC7AFVHu51nHL9pF9FRjXrH+ceVf7WmAfH3gs+gEYOkvxhjMPjZu57I4AGUGoNTY8V7Hrgf1uqg==} + engines: {node: '>=14'} + dependencies: + compare-func: 2.0.0 + dev: true + + /conventional-changelog-conventionalcommits@6.1.0: + resolution: {integrity: sha512-3cS3GEtR78zTfMzk0AizXKKIdN4OvSh7ibNz6/DPbhWWQu7LqE/8+/GqSodV+sywUR2gpJAdP/1JFf4XtN7Zpw==} + engines: {node: '>=14'} + dependencies: + compare-func: 2.0.0 + dev: true + + /conventional-commit-types@3.0.0: + resolution: {integrity: sha512-SmmCYnOniSsAa9GqWOeLqc179lfr5TRu5b4QFDkbsrJ5TZjPJx85wtOr3zn+1dbeNiXDKGPbZ72IKbPhLXh/Lg==} + dev: true + + /conventional-commits-parser@4.0.0: + resolution: {integrity: sha512-WRv5j1FsVM5FISJkoYMR6tPk07fkKT0UodruX4je86V4owk451yjXAKzKAPOs9l7y59E2viHUS9eQ+dfUA9NSg==} + engines: {node: '>=14'} + hasBin: true + dependencies: + JSONStream: 1.3.5 + is-text-path: 1.0.1 + meow: 8.1.2 + split2: 3.2.2 + dev: true + /convert-source-map@1.9.0: resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} @@ -4595,6 +5386,37 @@ packages: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} dev: true + /cosmiconfig-typescript-loader@4.4.0(@types/node@20.5.1)(cosmiconfig@8.3.6)(ts-node@10.9.1)(typescript@5.2.2): + resolution: {integrity: sha512-BabizFdC3wBHhbI4kJh0VkQP9GkBfoHPydD0COMce1nJ1kJAB3F2TmJ/I7diULBKtmEWSwEbuN/KDtgnmUUVmw==} + engines: {node: '>=v14.21.3'} + peerDependencies: + '@types/node': '*' + cosmiconfig: '>=7' + ts-node: '>=10' + typescript: '>=4' + dependencies: + '@types/node': 20.5.1 + cosmiconfig: 8.3.6(typescript@5.2.2) + ts-node: 10.9.1(@types/node@18.18.13)(typescript@4.9.5) + typescript: 5.2.2 + dev: true + + /cosmiconfig-typescript-loader@5.0.0(@types/node@18.18.13)(cosmiconfig@8.3.6)(typescript@4.9.5): + resolution: {integrity: sha512-+8cK7jRAReYkMwMiG+bxhcNKiHJDM6bR9FD/nGBXOWdMLuYawjF5cGrtLilJ+LGd3ZjCXnJjR5DkfWPoIVlqJA==} + engines: {node: '>=v16'} + requiresBuild: true + peerDependencies: + '@types/node': '*' + cosmiconfig: '>=8.2' + typescript: '>=4' + dependencies: + '@types/node': 18.18.13 + cosmiconfig: 8.3.6(typescript@4.9.5) + jiti: 1.21.0 + typescript: 4.9.5 + dev: true + optional: true + /cosmiconfig@7.1.0: resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} engines: {node: '>=10'} @@ -4606,6 +5428,39 @@ packages: yaml: 1.10.2 dev: false + /cosmiconfig@8.3.6(typescript@4.9.5): + resolution: {integrity: sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + import-fresh: 3.3.0 + js-yaml: 4.1.0 + parse-json: 5.2.0 + path-type: 4.0.0 + typescript: 4.9.5 + dev: true + optional: true + + /cosmiconfig@8.3.6(typescript@5.2.2): + resolution: {integrity: sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + import-fresh: 3.3.0 + js-yaml: 4.1.0 + parse-json: 5.2.0 + path-type: 4.0.0 + typescript: 5.2.2 + dev: true + /create-jest@29.7.0(@types/node@20.8.10)(ts-node@10.9.1): resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -4672,10 +5527,31 @@ packages: /csstype@3.1.1: resolution: {integrity: sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==} + /cz-conventional-changelog@3.3.0(typescript@4.9.5): + resolution: {integrity: sha512-U466fIzU5U22eES5lTNiNbZ+d8dfcHcssH4o7QsdWaCcRs/feIPCxKYSWkYBNs5mny7MvEfwpTLWjvbm94hecw==} + engines: {node: '>= 10'} + dependencies: + chalk: 2.4.2 + commitizen: 4.3.0(typescript@4.9.5) + conventional-commit-types: 3.0.0 + lodash.map: 4.6.0 + longest: 2.0.1 + word-wrap: 1.2.5 + optionalDependencies: + '@commitlint/load': 18.4.3(typescript@4.9.5) + transitivePeerDependencies: + - typescript + dev: true + /damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} dev: true + /dargs@7.0.0: + resolution: {integrity: sha512-2iy1EkLdlBzQGvbweYRFxmFath8+K7+AKB0TlhHWkNuH+TmovaMH/Wp7V7R4u7f4SnX3OgLsU9t1NI9ioDnUpg==} + engines: {node: '>=8'} + dev: true + /data-uri-to-buffer@3.0.1: resolution: {integrity: sha512-WboRycPNsVw3B3TL559F7kuBUM4d8CgMEvk6xEJlOp7OBPjt6G7z8WMWlD2rOFZLk6OYfFIUGsCOWzcQH9K2og==} engines: {node: '>= 6'} @@ -4724,6 +5600,19 @@ packages: dependencies: ms: 2.1.2 + /decamelize-keys@1.1.1: + resolution: {integrity: sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==} + engines: {node: '>=0.10.0'} + dependencies: + decamelize: 1.2.0 + map-obj: 1.0.1 + dev: true + + /decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + dev: true + /decimal.js@10.4.3: resolution: {integrity: sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==} dev: true @@ -4740,6 +5629,10 @@ packages: dependencies: mimic-response: 3.1.0 + /dedent@0.7.0: + resolution: {integrity: sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==} + dev: true + /dedent@1.5.1: resolution: {integrity: sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg==} peerDependencies: @@ -4795,6 +5688,24 @@ packages: engines: {node: '>=0.10.0'} dev: true + /default-browser-id@3.0.0: + resolution: {integrity: sha512-OZ1y3y0SqSICtE8DE4S8YOE9UZOJ8wO16fKWVP5J1Qz42kV9jcnMVFrEE/noXb/ss3Q4pZIH79kxofzyNNtUNA==} + engines: {node: '>=12'} + dependencies: + bplist-parser: 0.2.0 + untildify: 4.0.0 + dev: true + + /default-browser@4.0.0: + resolution: {integrity: sha512-wX5pXO1+BrhMkSbROFsyxUm0i/cJEScyNhA4PPxc41ICuv05ZZB/MX28s8aZx6xjmatvebIapF6hLEKEcpneUA==} + engines: {node: '>=14.16'} + dependencies: + bundle-name: 3.0.0 + default-browser-id: 3.0.0 + execa: 7.2.0 + titleize: 3.0.0 + dev: true + /defaults@1.0.4: resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} requiresBuild: true @@ -4811,6 +5722,11 @@ packages: has-property-descriptors: 1.0.0 dev: true + /define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + dev: true + /define-properties@1.2.1: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} @@ -4842,6 +5758,16 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + /detect-file@1.0.0: + resolution: {integrity: sha512-DtCOLG98P007x7wiiOmfI0fi3eIKyWiLTGJ2MDnVi/E04lWGbf+JzrRHMm0rgIIZJGtHpKpbVgLWHrv8xXpc3Q==} + engines: {node: '>=0.10.0'} + dev: true + + /detect-indent@6.1.0: + resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} + engines: {node: '>=8'} + dev: true + /detect-libc@2.0.2: resolution: {integrity: sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==} engines: {node: '>=8'} @@ -4861,6 +5787,11 @@ packages: dequal: 2.0.3 dev: false + /diff-sequences@28.1.1: + resolution: {integrity: sha512-FU0iFaH/E23a+a718l8Qa/19bF9p06kgE0KipMOMadwa3SjnaElKzPaUC0vnibs6/B/9ni97s61mcejk8W1fQw==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + dev: true + /diff-sequences@29.6.3: resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -4910,6 +5841,13 @@ packages: webidl-conversions: 7.0.0 dev: true + /dot-prop@5.3.0: + resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} + engines: {node: '>=8'} + dependencies: + is-obj: 2.0.0 + dev: true + /dotenv-cli@7.3.0: resolution: {integrity: sha512-314CA4TyK34YEJ6ntBf80eUY+t1XaFLyem1k9P0sX1gn30qThZ5qZr/ZwE318gEnzyYP9yj9HJk6SqwE0upkfw==} hasBin: true @@ -5015,6 +5953,11 @@ packages: /electron-to-chromium@1.4.549: resolution: {integrity: sha512-gpXfJslSi4hYDkA0mTLEpYKRv9siAgSUgZ+UWyk+J5Cttpd1ThCVwdclzIwQSclz3hYn049+M2fgrP1WpvF8xg==} + /emittery@0.10.2: + resolution: {integrity: sha512-aITqOwnLanpHLNXZJENbOgjUBeHocD+xsSJmNrjovKBW5HbSpW3d1pEls7GFQPUWXiwG9+0P4GtHfEqC/4M0Iw==} + engines: {node: '>=12'} + dev: true + /emittery@0.13.1: resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} engines: {node: '>=12'} @@ -5256,6 +6199,15 @@ packages: source-map: 0.6.1 dev: true + /eslint-compat-utils@0.1.2(eslint@8.52.0): + resolution: {integrity: sha512-Jia4JDldWnFNIru1Ehx1H5s9/yxiRHY/TimCuUc0jNexew3cF1gI6CYZil1ociakfWO3rRqFjl1mskBblB3RYg==} + engines: {node: '>=12'} + peerDependencies: + eslint: '>=6.0.0' + dependencies: + eslint: 8.52.0 + dev: true + /eslint-config-airbnb-base@15.0.0(eslint-plugin-import@2.29.0)(eslint@8.52.0): resolution: {integrity: sha512-xaX3z4ZZIcFLvh2oUNvcX5oEofXda7giYmuplVxoOg5A7EXJMrUyqRgR+mhDhPK8LZ4PttFOBvCYDbX3sUoUig==} engines: {node: ^10.12.0 || >=12.0.0} @@ -5476,6 +6428,41 @@ packages: - typescript dev: true + /eslint-plugin-json-schema-validator@4.7.3(eslint@8.52.0): + resolution: {integrity: sha512-odFpNM997t484eprsTEk7YTt7JXgZ5ewCIekcOPGJLe5OFGKoRkJWtQ5lUJdRqqaOOD5vE8kGmV8fDvs0h9iNg==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + eslint: '>=6.0.0' + dependencies: + '@eslint-community/eslint-utils': 4.4.0(eslint@8.52.0) + ajv: 8.12.0 + debug: 4.3.4 + eslint: 8.52.0 + eslint-compat-utils: 0.1.2(eslint@8.52.0) + json-schema-migrate: 2.0.0 + jsonc-eslint-parser: 2.4.0 + minimatch: 8.0.4 + synckit: 0.8.5 + toml-eslint-parser: 0.9.3 + tunnel-agent: 0.6.0 + yaml-eslint-parser: 1.2.2 + transitivePeerDependencies: + - supports-color + dev: true + + /eslint-plugin-jsonc@2.10.0(eslint@8.52.0): + resolution: {integrity: sha512-9d//o6Jyh4s1RxC9fNSt1+MMaFN2ruFdXPG9XZcb/mR2KkfjADYiNL/hbU6W0Cyxfg3tS/XSFuhl5LgtMD8hmw==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: '>=6.0.0' + dependencies: + '@eslint-community/eslint-utils': 4.4.0(eslint@8.52.0) + eslint: 8.52.0 + eslint-compat-utils: 0.1.2(eslint@8.52.0) + jsonc-eslint-parser: 2.4.0 + natural-compare: 1.4.0 + dev: true + /eslint-plugin-jsx-a11y@6.8.0(eslint@8.52.0): resolution: {integrity: sha512-Hdh937BS3KdwwbBaKd5+PLCOmYY6U4f2h9Z2ktwtNKvIdIEu137rjYbcb9ApSbVJfWxANNuiKTD/9tOKjK9qOA==} engines: {node: '>=4.0'} @@ -5680,6 +6667,21 @@ packages: strip-final-newline: 2.0.0 dev: true + /execa@7.2.0: + resolution: {integrity: sha512-UduyVP7TLB5IcAQl+OzLyLcS/l32W/GLg+AhHJ+ow40FOk2U3SAllPwR44v4vmdFwIWqpdwxxpQbF1n5ta9seA==} + engines: {node: ^14.18.0 || ^16.14.0 || >=18.0.0} + dependencies: + cross-spawn: 7.0.3 + get-stream: 6.0.1 + human-signals: 4.3.1 + is-stream: 3.0.0 + merge-stream: 2.0.0 + npm-run-path: 5.1.0 + onetime: 6.0.0 + signal-exit: 3.0.7 + strip-final-newline: 3.0.0 + dev: true + /exit@0.1.2: resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==} engines: {node: '>= 0.8.0'} @@ -5689,6 +6691,24 @@ packages: resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} engines: {node: '>=6'} + /expand-tilde@2.0.2: + resolution: {integrity: sha512-A5EmesHW6rfnZ9ysHQjPdJRni0SRar0tjtG5MNtm9n5TUvsYU8oozprtRD4AqHxcZWWlVuAmQo2nWKfN9oyjTw==} + engines: {node: '>=0.10.0'} + dependencies: + homedir-polyfill: 1.0.3 + dev: true + + /expect@28.1.3: + resolution: {integrity: sha512-eEh0xn8HlsuOBxFgIss+2mX85VAS4Qy3OSkjV7rlBWljtA4oWH37glVGyOZSZvErDT/yBywZdPGwCXuTvSG85g==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + dependencies: + '@jest/expect-utils': 28.1.3 + jest-get-type: 28.0.2 + jest-matcher-utils: 28.1.3 + jest-message-util: 28.1.3 + jest-util: 28.1.3 + dev: true + /expect@29.7.0: resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -5819,9 +6839,15 @@ packages: engines: {node: '>=0.10.0'} dev: true + /find-node-modules@2.1.3: + resolution: {integrity: sha512-UC2I2+nx1ZuOBclWVNdcnbDR5dlrOdVb7xNjmT/lHE+LsgztWks3dG7boJ37yTS/venXw84B/mAW9uHVoC5QRg==} + dependencies: + findup-sync: 4.0.0 + merge: 2.1.1 + dev: true + /find-root@1.1.0: resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==} - dev: false /find-up@4.1.0: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} @@ -5839,6 +6865,16 @@ packages: path-exists: 4.0.0 dev: true + /findup-sync@4.0.0: + resolution: {integrity: sha512-6jvvn/12IC4quLBL1KNokxC7wWTvYncaVUYSoxWw7YykPLuRrnv4qdHcSOywOI5RpkOVGeQRtWM8/q+G6W6qfQ==} + engines: {node: '>= 8'} + dependencies: + detect-file: 1.0.0 + is-glob: 4.0.3 + micromatch: 4.0.5 + resolve-dir: 1.0.1 + dev: true + /flat-cache@3.0.4: resolution: {integrity: sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==} engines: {node: ^10.12.0 || >=12.0.0} @@ -5913,7 +6949,6 @@ packages: graceful-fs: 4.2.10 jsonfile: 6.1.0 universalify: 2.0.0 - dev: false /fs-extra@9.1.0: resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} @@ -6042,6 +7077,18 @@ packages: resolve-pkg-maps: 1.0.0 dev: true + /git-raw-commits@2.0.11: + resolution: {integrity: sha512-VnctFhw+xfj8Va1xtfEqCUD2XDrbAPSJx+hSrE5K7fGdjZruW7XV+QOrN7LF/RJyvspRiD2I0asWsxFp0ya26A==} + engines: {node: '>=10'} + hasBin: true + dependencies: + dargs: 7.0.0 + lodash: 4.17.21 + meow: 8.1.2 + split2: 3.2.2 + through2: 4.0.2 + dev: true + /github-from-package@0.0.0: resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} @@ -6105,6 +7152,33 @@ packages: once: 1.4.0 dev: false + /global-dirs@0.1.1: + resolution: {integrity: sha512-NknMLn7F2J7aflwFOlGdNIuCDpN3VGoSoB+aap3KABFWbHVn1TCgFC+np23J8W2BiZbjfEw3BFBycSMv1AFblg==} + engines: {node: '>=4'} + dependencies: + ini: 1.3.8 + dev: true + + /global-modules@1.0.0: + resolution: {integrity: sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg==} + engines: {node: '>=0.10.0'} + dependencies: + global-prefix: 1.0.2 + is-windows: 1.0.2 + resolve-dir: 1.0.1 + dev: true + + /global-prefix@1.0.2: + resolution: {integrity: sha512-5lsx1NUDHtSjfg0eHlmYvZKv8/nVqX4ckFbM+FrGcQ+04KWcWFo9P5MxPZYSzUvyzmdTbI7Eix8Q4IbELDqzKg==} + engines: {node: '>=0.10.0'} + dependencies: + expand-tilde: 2.0.2 + homedir-polyfill: 1.0.3 + ini: 1.3.8 + is-windows: 1.0.2 + which: 1.3.1 + dev: true + /globals@11.12.0: resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} engines: {node: '>=4'} @@ -6166,7 +7240,6 @@ packages: /graceful-fs@4.2.10: resolution: {integrity: sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==} - dev: false /graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -6180,6 +7253,11 @@ packages: engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} dev: true + /hard-rejection@2.1.0: + resolution: {integrity: sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==} + engines: {node: '>=6'} + dev: true + /has-bigints@1.0.2: resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==} dev: true @@ -6326,6 +7404,17 @@ packages: react-is: 16.13.1 dev: false + /homedir-polyfill@1.0.3: + resolution: {integrity: sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==} + engines: {node: '>=0.10.0'} + dependencies: + parse-passwd: 1.0.0 + dev: true + + /hosted-git-info@2.8.9: + resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} + dev: true + /hosted-git-info@4.1.0: resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} engines: {node: '>=10'} @@ -6394,6 +7483,17 @@ packages: engines: {node: '>=10.17.0'} dev: true + /human-signals@4.3.1: + resolution: {integrity: sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ==} + engines: {node: '>=14.18.0'} + dev: true + + /husky@8.0.3: + resolution: {integrity: sha512-+dQSyqPh4x1hlO1swXBiNb2HzTDN1I2IGLQx1GrBuiqFJfoMrnZWwVmatvSiO+Iz8fBUnf+lekwNo4c2LlXItg==} + engines: {node: '>=14'} + hasBin: true + dev: true + /hyperdyperid@1.2.0: resolution: {integrity: sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==} engines: {node: '>=10.18'} @@ -6627,6 +7727,18 @@ packages: has-tostringtag: 1.0.0 dev: true + /is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + dev: true + + /is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + dev: true + /is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -6664,6 +7776,14 @@ packages: dependencies: is-extglob: 2.1.1 + /is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + dependencies: + is-docker: 3.0.0 + dev: true + /is-interactive@1.0.0: resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} engines: {node: '>=8'} @@ -6703,11 +7823,21 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + /is-obj@2.0.0: + resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==} + engines: {node: '>=8'} + dev: true + /is-path-inside@3.0.3: resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} engines: {node: '>=8'} dev: true + /is-plain-obj@1.1.0: + resolution: {integrity: sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==} + engines: {node: '>=0.10.0'} + dev: true + /is-plain-obj@4.1.0: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} @@ -6739,6 +7869,11 @@ packages: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} + /is-stream@3.0.0: + resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dev: true + /is-string@1.0.7: resolution: {integrity: sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==} engines: {node: '>= 0.4'} @@ -6753,6 +7888,13 @@ packages: has-symbols: 1.0.3 dev: true + /is-text-path@1.0.1: + resolution: {integrity: sha512-xFuJpne9oFz5qDaodwmmG08e3CawH/2ZV8Qqza1Ko7Sk8POWbkRdwIoAWVhqvq0XeUzANEhKo2n0IXUGBm7A/w==} + engines: {node: '>=0.10.0'} + dependencies: + text-extensions: 1.9.0 + dev: true + /is-typed-array@1.1.12: resolution: {integrity: sha512-Z14TF2JNG8Lss5/HMqt0//T9JeHXttXy5pH/DBU4vi98ozO2btxzq9MwYDZYnKwU8nRsz/+GVFVRDq3DkVuSPg==} engines: {node: '>= 0.4'} @@ -6765,6 +7907,10 @@ packages: engines: {node: '>=10'} dev: true + /is-utf8@0.2.1: + resolution: {integrity: sha512-rMYPYvCzsXywIsldgLaSoPlw5PfoB/ssr7hY4pLfcodrA5M/eArza1a9VmTiNIBNMjOGr1Ow9mTyU2o69U6U9Q==} + dev: true + /is-weakmap@2.0.1: resolution: {integrity: sha512-NSBR4kH5oVj1Uwvv970ruUkCV7O1mzgVFO4/rev2cLRda9Tm9HrL70ZPut4rOHgY0FNrUu9BCbXA2sdQ+x0chA==} dev: true @@ -6782,6 +7928,18 @@ packages: get-intrinsic: 1.2.1 dev: true + /is-windows@1.0.2: + resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} + engines: {node: '>=0.10.0'} + dev: true + + /is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + dependencies: + is-docker: 2.2.1 + dev: true + /isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} dev: true @@ -6877,6 +8035,14 @@ packages: '@pkgjs/parseargs': 0.11.0 dev: true + /jest-changed-files@28.1.3: + resolution: {integrity: sha512-esaOfUWJXk2nfZt9SPyC8gA1kNfdKLkQWyzsMlqq8msYSlNKfmZxfRgZn4Cd4MGVUF+7v6dBs0d5TOAKa7iIiA==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + dependencies: + execa: 5.1.1 + p-limit: 3.1.0 + dev: true + /jest-changed-files@29.7.0: resolution: {integrity: sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -6886,6 +8052,33 @@ packages: p-limit: 3.1.0 dev: true + /jest-circus@28.1.3: + resolution: {integrity: sha512-cZ+eS5zc79MBwt+IhQhiEp0OeBddpc1n8MBo1nMB8A7oPMKEO+Sre+wHaLJexQUj9Ya/8NOBY0RESUgYjB6fow==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + dependencies: + '@jest/environment': 28.1.3 + '@jest/expect': 28.1.3 + '@jest/test-result': 28.1.3 + '@jest/types': 28.1.3 + '@types/node': 18.18.13 + chalk: 4.1.2 + co: 4.6.0 + dedent: 0.7.0 + is-generator-fn: 2.1.0 + jest-each: 28.1.3 + jest-matcher-utils: 28.1.3 + jest-message-util: 28.1.3 + jest-runtime: 28.1.3 + jest-snapshot: 28.1.3 + jest-util: 28.1.3 + p-limit: 3.1.0 + pretty-format: 28.1.3 + slash: 3.0.0 + stack-utils: 2.0.6 + transitivePeerDependencies: + - supports-color + dev: true + /jest-circus@29.7.0: resolution: {integrity: sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -6915,6 +8108,34 @@ packages: - supports-color dev: true + /jest-cli@28.1.3(@types/node@18.18.13)(ts-node@10.9.1): + resolution: {integrity: sha512-roY3kvrv57Azn1yPgdTebPAXvdR2xfezaKKYzVxZ6It/5NCxzJym6tUI5P1zkdWhfUYkxEI9uZWcQdaFLo8mJQ==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + dependencies: + '@jest/core': 28.1.3(ts-node@10.9.1) + '@jest/test-result': 28.1.3 + '@jest/types': 28.1.3 + chalk: 4.1.2 + exit: 0.1.2 + graceful-fs: 4.2.11 + import-local: 3.1.0 + jest-config: 28.1.3(@types/node@18.18.13)(ts-node@10.9.1) + jest-util: 28.1.3 + jest-validate: 28.1.3 + prompts: 2.4.2 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - supports-color + - ts-node + dev: true + /jest-cli@29.7.0(@types/node@20.8.10)(ts-node@10.9.1): resolution: {integrity: sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -6943,6 +8164,46 @@ packages: - ts-node dev: true + /jest-config@28.1.3(@types/node@18.18.13)(ts-node@10.9.1): + resolution: {integrity: sha512-MG3INjByJ0J4AsNBm7T3hsuxKQqFIiRo/AUqb1q9LRKI5UU6Aar9JHbr9Ivn1TVwfUD9KirRoM/T6u8XlcQPHQ==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + peerDependencies: + '@types/node': '*' + ts-node: '>=9.0.0' + peerDependenciesMeta: + '@types/node': + optional: true + ts-node: + optional: true + dependencies: + '@babel/core': 7.23.2 + '@jest/test-sequencer': 28.1.3 + '@jest/types': 28.1.3 + '@types/node': 18.18.13 + babel-jest: 28.1.3(@babel/core@7.23.2) + chalk: 4.1.2 + ci-info: 3.8.0 + deepmerge: 4.3.0 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-circus: 28.1.3 + jest-environment-node: 28.1.3 + jest-get-type: 28.0.2 + jest-regex-util: 28.0.2 + jest-resolve: 28.1.3 + jest-runner: 28.1.3 + jest-util: 28.1.3 + jest-validate: 28.1.3 + micromatch: 4.0.5 + parse-json: 5.2.0 + pretty-format: 28.1.3 + slash: 3.0.0 + strip-json-comments: 3.1.1 + ts-node: 10.9.1(@types/node@18.18.13)(typescript@4.9.5) + transitivePeerDependencies: + - supports-color + dev: true + /jest-config@29.7.0(@types/node@20.8.10)(ts-node@10.9.1): resolution: {integrity: sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -6984,6 +8245,16 @@ packages: - supports-color dev: true + /jest-diff@28.1.3: + resolution: {integrity: sha512-8RqP1B/OXzjjTWkqMX67iqgwBVJRgCyKD3L9nq+6ZqJMdvjE8RgHktqZ6jNrkdMT+dJuYNI3rhQpxaz7drJHfw==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + dependencies: + chalk: 4.1.2 + diff-sequences: 28.1.1 + jest-get-type: 28.0.2 + pretty-format: 28.1.3 + dev: true + /jest-diff@29.7.0: resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -6994,6 +8265,13 @@ packages: pretty-format: 29.7.0 dev: true + /jest-docblock@28.1.1: + resolution: {integrity: sha512-3wayBVNiOYx0cwAbl9rwm5kKFP8yHH3d/fkEaL02NPTkDojPtheGB7HZSFY4wzX+DxyrvhXz0KSCVksmCknCuA==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + dependencies: + detect-newline: 3.1.0 + dev: true + /jest-docblock@29.7.0: resolution: {integrity: sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -7001,6 +8279,17 @@ packages: detect-newline: 3.1.0 dev: true + /jest-each@28.1.3: + resolution: {integrity: sha512-arT1z4sg2yABU5uogObVPvSlSMQlDA48owx07BDPAiasW0yYpYHYOo4HHLz9q0BVzDVU4hILFjzJw0So9aCL/g==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + dependencies: + '@jest/types': 28.1.3 + chalk: 4.1.2 + jest-get-type: 28.0.2 + jest-util: 28.1.3 + pretty-format: 28.1.3 + dev: true + /jest-each@29.7.0: resolution: {integrity: sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -7035,6 +8324,18 @@ packages: - utf-8-validate dev: true + /jest-environment-node@28.1.3: + resolution: {integrity: sha512-ugP6XOhEpjAEhGYvp5Xj989ns5cB1K6ZdjBYuS30umT4CQEETaxSiPcZ/E1kFktX4GkrcM4qu07IIlDYX1gp+A==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + dependencies: + '@jest/environment': 28.1.3 + '@jest/fake-timers': 28.1.3 + '@jest/types': 28.1.3 + '@types/node': 18.18.13 + jest-mock: 28.1.3 + jest-util: 28.1.3 + dev: true + /jest-environment-node@29.7.0: resolution: {integrity: sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -7047,11 +8348,35 @@ packages: jest-util: 29.7.0 dev: true + /jest-get-type@28.0.2: + resolution: {integrity: sha512-ioj2w9/DxSYHfOm5lJKCdcAmPJzQXmbM/Url3rhlghrPvT3tt+7a/+oXc9azkKmLvoiXjtV83bEWqi+vs5nlPA==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + dev: true + /jest-get-type@29.6.3: resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dev: true + /jest-haste-map@28.1.3: + resolution: {integrity: sha512-3S+RQWDXccXDKSWnkHa/dPwt+2qwA8CJzR61w3FoYCvoo3Pn8tvGcysmMF0Bj0EX5RYvAI2EIvC57OmotfdtKA==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + dependencies: + '@jest/types': 28.1.3 + '@types/graceful-fs': 4.1.6 + '@types/node': 18.18.13 + anymatch: 3.1.3 + fb-watchman: 2.0.2 + graceful-fs: 4.2.11 + jest-regex-util: 28.0.2 + jest-util: 28.1.3 + jest-worker: 28.1.3 + micromatch: 4.0.5 + walker: 1.0.8 + optionalDependencies: + fsevents: 2.3.3 + dev: true + /jest-haste-map@29.7.0: resolution: {integrity: sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -7071,6 +8396,14 @@ packages: fsevents: 2.3.3 dev: true + /jest-leak-detector@28.1.3: + resolution: {integrity: sha512-WFVJhnQsiKtDEo5lG2mM0v40QWnBM+zMdHHyJs8AWZ7J0QZJS59MsyKeJHWhpBZBH32S48FOVvGyOFT1h0DlqA==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + dependencies: + jest-get-type: 28.0.2 + pretty-format: 28.1.3 + dev: true + /jest-leak-detector@29.7.0: resolution: {integrity: sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -7079,6 +8412,16 @@ packages: pretty-format: 29.7.0 dev: true + /jest-matcher-utils@28.1.3: + resolution: {integrity: sha512-kQeJ7qHemKfbzKoGjHHrRKH6atgxMk8Enkk2iPQ3XwO6oE/KYD8lMYOziCkeSB9G4adPM4nR1DE8Tf5JeWH6Bw==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + dependencies: + chalk: 4.1.2 + jest-diff: 28.1.3 + jest-get-type: 28.0.2 + pretty-format: 28.1.3 + dev: true + /jest-matcher-utils@29.7.0: resolution: {integrity: sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -7089,6 +8432,21 @@ packages: pretty-format: 29.7.0 dev: true + /jest-message-util@28.1.3: + resolution: {integrity: sha512-PFdn9Iewbt575zKPf1286Ht9EPoJmYT7P0kY+RibeYZ2XtOr53pDLEFoTWXbd1h4JiGiWpTBC84fc8xMXQMb7g==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + dependencies: + '@babel/code-frame': 7.22.13 + '@jest/types': 28.1.3 + '@types/stack-utils': 2.0.1 + chalk: 4.1.2 + graceful-fs: 4.2.11 + micromatch: 4.0.5 + pretty-format: 28.1.3 + slash: 3.0.0 + stack-utils: 2.0.6 + dev: true + /jest-message-util@29.7.0: resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -7104,6 +8462,14 @@ packages: stack-utils: 2.0.6 dev: true + /jest-mock@28.1.3: + resolution: {integrity: sha512-o3J2jr6dMMWYVH4Lh/NKmDXdosrsJgi4AviS8oXLujcjpCMBb1FMsblDnOXKZKfSiHLxYub1eS0IHuRXsio9eA==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + dependencies: + '@jest/types': 28.1.3 + '@types/node': 18.18.13 + dev: true + /jest-mock@29.7.0: resolution: {integrity: sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -7113,6 +8479,18 @@ packages: jest-util: 29.7.0 dev: true + /jest-pnp-resolver@1.2.3(jest-resolve@28.1.3): + resolution: {integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==} + engines: {node: '>=6'} + peerDependencies: + jest-resolve: '*' + peerDependenciesMeta: + jest-resolve: + optional: true + dependencies: + jest-resolve: 28.1.3 + dev: true + /jest-pnp-resolver@1.2.3(jest-resolve@29.7.0): resolution: {integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==} engines: {node: '>=6'} @@ -7125,11 +8503,26 @@ packages: jest-resolve: 29.7.0 dev: true + /jest-regex-util@28.0.2: + resolution: {integrity: sha512-4s0IgyNIy0y9FK+cjoVYoxamT7Zeo7MhzqRGx7YDYmaQn1wucY9rotiGkBzzcMXTtjrCAP/f7f+E0F7+fxPNdw==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + dev: true + /jest-regex-util@29.6.3: resolution: {integrity: sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dev: true + /jest-resolve-dependencies@28.1.3: + resolution: {integrity: sha512-qa0QO2Q0XzQoNPouMbCc7Bvtsem8eQgVPNkwn9LnS+R2n8DaVDPL/U1gngC0LTl1RYXJU0uJa2BMC2DbTfFrHA==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + dependencies: + jest-regex-util: 28.0.2 + jest-snapshot: 28.1.3 + transitivePeerDependencies: + - supports-color + dev: true + /jest-resolve-dependencies@29.7.0: resolution: {integrity: sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -7140,6 +8533,21 @@ packages: - supports-color dev: true + /jest-resolve@28.1.3: + resolution: {integrity: sha512-Z1W3tTjE6QaNI90qo/BJpfnvpxtaFTFw5CDgwpyE/Kz8U/06N1Hjf4ia9quUhCh39qIGWF1ZuxFiBiJQwSEYKQ==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + dependencies: + chalk: 4.1.2 + graceful-fs: 4.2.11 + jest-haste-map: 28.1.3 + jest-pnp-resolver: 1.2.3(jest-resolve@28.1.3) + jest-util: 28.1.3 + jest-validate: 28.1.3 + resolve: 1.22.8 + resolve.exports: 1.1.1 + slash: 3.0.0 + dev: true + /jest-resolve@29.7.0: resolution: {integrity: sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -7155,6 +8563,35 @@ packages: slash: 3.0.0 dev: true + /jest-runner@28.1.3: + resolution: {integrity: sha512-GkMw4D/0USd62OVO0oEgjn23TM+YJa2U2Wu5zz9xsQB1MxWKDOlrnykPxnMsN0tnJllfLPinHTka61u0QhaxBA==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + dependencies: + '@jest/console': 28.1.3 + '@jest/environment': 28.1.3 + '@jest/test-result': 28.1.3 + '@jest/transform': 28.1.3 + '@jest/types': 28.1.3 + '@types/node': 18.18.13 + chalk: 4.1.2 + emittery: 0.10.2 + graceful-fs: 4.2.11 + jest-docblock: 28.1.1 + jest-environment-node: 28.1.3 + jest-haste-map: 28.1.3 + jest-leak-detector: 28.1.3 + jest-message-util: 28.1.3 + jest-resolve: 28.1.3 + jest-runtime: 28.1.3 + jest-util: 28.1.3 + jest-watcher: 28.1.3 + jest-worker: 28.1.3 + p-limit: 3.1.0 + source-map-support: 0.5.13 + transitivePeerDependencies: + - supports-color + dev: true + /jest-runner@29.7.0: resolution: {integrity: sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -7184,6 +8621,36 @@ packages: - supports-color dev: true + /jest-runtime@28.1.3: + resolution: {integrity: sha512-NU+881ScBQQLc1JHG5eJGU7Ui3kLKrmwCPPtYsJtBykixrM2OhVQlpMmFWJjMyDfdkGgBMNjXCGB/ebzsgNGQw==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + dependencies: + '@jest/environment': 28.1.3 + '@jest/fake-timers': 28.1.3 + '@jest/globals': 28.1.3 + '@jest/source-map': 28.1.2 + '@jest/test-result': 28.1.3 + '@jest/transform': 28.1.3 + '@jest/types': 28.1.3 + chalk: 4.1.2 + cjs-module-lexer: 1.2.2 + collect-v8-coverage: 1.0.1 + execa: 5.1.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-haste-map: 28.1.3 + jest-message-util: 28.1.3 + jest-mock: 28.1.3 + jest-regex-util: 28.0.2 + jest-resolve: 28.1.3 + jest-snapshot: 28.1.3 + jest-util: 28.1.3 + slash: 3.0.0 + strip-bom: 4.0.0 + transitivePeerDependencies: + - supports-color + dev: true + /jest-runtime@29.7.0: resolution: {integrity: sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -7214,6 +8681,37 @@ packages: - supports-color dev: true + /jest-snapshot@28.1.3: + resolution: {integrity: sha512-4lzMgtiNlc3DU/8lZfmqxN3AYD6GGLbl+72rdBpXvcV+whX7mDrREzkPdp2RnmfIiWBg1YbuFSkXduF2JcafJg==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + dependencies: + '@babel/core': 7.23.2 + '@babel/generator': 7.23.0 + '@babel/plugin-syntax-typescript': 7.20.0(@babel/core@7.23.2) + '@babel/traverse': 7.23.2 + '@babel/types': 7.23.0 + '@jest/expect-utils': 28.1.3 + '@jest/transform': 28.1.3 + '@jest/types': 28.1.3 + '@types/babel__traverse': 7.18.3 + '@types/prettier': 2.7.3 + babel-preset-current-node-syntax: 1.0.1(@babel/core@7.23.2) + chalk: 4.1.2 + expect: 28.1.3 + graceful-fs: 4.2.11 + jest-diff: 28.1.3 + jest-get-type: 28.0.2 + jest-haste-map: 28.1.3 + jest-matcher-utils: 28.1.3 + jest-message-util: 28.1.3 + jest-util: 28.1.3 + natural-compare: 1.4.0 + pretty-format: 28.1.3 + semver: 7.5.4 + transitivePeerDependencies: + - supports-color + dev: true + /jest-snapshot@29.7.0: resolution: {integrity: sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -7242,6 +8740,18 @@ packages: - supports-color dev: true + /jest-util@28.1.3: + resolution: {integrity: sha512-XdqfpHwpcSRko/C35uLYFM2emRAltIIKZiJ9eAmhjsj0CqZMa0p1ib0R5fWIqGhn1a103DebTbpqIaP1qCQ6tQ==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + dependencies: + '@jest/types': 28.1.3 + '@types/node': 18.18.13 + chalk: 4.1.2 + ci-info: 3.8.0 + graceful-fs: 4.2.11 + picomatch: 2.3.1 + dev: true + /jest-util@29.7.0: resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -7254,6 +8764,18 @@ packages: picomatch: 2.3.1 dev: true + /jest-validate@28.1.3: + resolution: {integrity: sha512-SZbOGBWEsaTxBGCOpsRWlXlvNkvTkY0XxRfh7zYmvd8uL5Qzyg0CHAXiXKROflh801quA6+/DsT4ODDthOC/OA==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + dependencies: + '@jest/types': 28.1.3 + camelcase: 6.3.0 + chalk: 4.1.2 + jest-get-type: 28.0.2 + leven: 3.1.0 + pretty-format: 28.1.3 + dev: true + /jest-validate@29.7.0: resolution: {integrity: sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -7266,6 +8788,20 @@ packages: pretty-format: 29.7.0 dev: true + /jest-watcher@28.1.3: + resolution: {integrity: sha512-t4qcqj9hze+jviFPUN3YAtAEeFnr/azITXQEMARf5cMwKY2SMBRnCQTXLixTl20OR6mLh9KLMrgVJgJISym+1g==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + dependencies: + '@jest/test-result': 28.1.3 + '@jest/types': 28.1.3 + '@types/node': 18.18.13 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + emittery: 0.10.2 + jest-util: 28.1.3 + string-length: 4.0.2 + dev: true + /jest-watcher@29.7.0: resolution: {integrity: sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -7280,6 +8816,15 @@ packages: string-length: 4.0.2 dev: true + /jest-worker@28.1.3: + resolution: {integrity: sha512-CqRA220YV/6jCo8VWvAt1KKx6eek1VIHMPeLEbpcfSfkEeWyBNppynM/o6q+Wmw+sOhos2ml34wZbSX3G13//g==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + dependencies: + '@types/node': 18.18.13 + merge-stream: 2.0.0 + supports-color: 8.1.1 + dev: true + /jest-worker@29.7.0: resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -7290,6 +8835,26 @@ packages: supports-color: 8.1.1 dev: true + /jest@28.1.3(@types/node@18.18.13)(ts-node@10.9.1): + resolution: {integrity: sha512-N4GT5on8UkZgH0O5LUavMRV1EDEhNTL0KEfRmDIeZHSV7p2XgLoY9t9VDUgL6o+yfdgYHVxuz81G8oB9VG5uyA==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + dependencies: + '@jest/core': 28.1.3(ts-node@10.9.1) + '@jest/types': 28.1.3 + import-local: 3.1.0 + jest-cli: 28.1.3(@types/node@18.18.13)(ts-node@10.9.1) + transitivePeerDependencies: + - '@types/node' + - supports-color + - ts-node + dev: true + /jest@29.7.0(@types/node@20.8.10)(ts-node@10.9.1): resolution: {integrity: sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -7414,10 +8979,20 @@ packages: engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} dev: true + /json-schema-migrate@2.0.0: + resolution: {integrity: sha512-r38SVTtojDRp4eD6WsCqiE0eNDt4v1WalBXb9cyZYw9ai5cGtBwzRNWjHzJl38w6TxFkXAIA7h+fyX3tnrAFhQ==} + dependencies: + ajv: 8.12.0 + dev: true + /json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} dev: true + /json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + dev: true + /json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} dev: true @@ -7438,6 +9013,16 @@ packages: engines: {node: '>=6'} hasBin: true + /jsonc-eslint-parser@2.4.0: + resolution: {integrity: sha512-WYDyuc/uFcGp6YtM2H0uKmUwieOuzeE/5YocFJLnLfclZ4inf3mRn8ZVy1s7Hxji7Jxm6Ss8gqpexD/GlKoGgg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dependencies: + acorn: 8.10.0 + eslint-visitor-keys: 3.4.3 + espree: 9.6.1 + semver: 7.5.4 + dev: true + /jsonc-parser@3.2.0: resolution: {integrity: sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==} dev: true @@ -7449,6 +9034,11 @@ packages: optionalDependencies: graceful-fs: 4.2.11 + /jsonparse@1.3.1: + resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==} + engines: {'0': node >= 0.2.0} + dev: true + /jsx-ast-utils@3.3.3: resolution: {integrity: sha512-fYQHZTZ8jSfmWZ0iyzfwiU4WDX4HpHbMCZ3gPlWYiCl3BoeOTsqKBqnTVfH2rYT7eP5c3sVbeSPHnnJOaTrWiw==} engines: {node: '>=4.0'} @@ -7482,13 +9072,18 @@ packages: safe-buffer: 5.2.1 dev: false + /kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + dev: true + /kleur@3.0.3: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} dev: true - /knip@2.39.0: - resolution: {integrity: sha512-2piCiCtazV+EfshVmblfaVRTsWI+mZ/ipmBJw2V6BorN0nX+t0BcmMX7s3ozrjpS/ZWANUHOcfWyzA5dbjMM/w==} + /knip@2.41.3: + resolution: {integrity: sha512-ooHaOfiieytMFSYnhhwk+TKFD3kGPNXIxpoLimEFf4nUpmthBOVKyawDjHvl23uJmPkqI6OOQqyQnK6dCUX+xQ==} engines: {node: '>=16.17.0 <17 || >=18.6.0'} hasBin: true dependencies: @@ -7531,6 +9126,10 @@ packages: language-subtag-registry: 0.3.22 dev: true + /let-it-go@1.0.0: + resolution: {integrity: sha512-yzn7EFjddUpsSzhCsJSh8aWGNcTelVaPOUfOvHuR/4VZjpRQNZFznkiTT5dw4txP0aUQ4/B4lLsY37PwBHw0kA==} + dev: false + /leven@3.1.0: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} @@ -7584,6 +9183,10 @@ packages: p-locate: 5.0.0 dev: true + /lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + dev: true + /lodash.clonedeep@4.5.0: resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==} dev: true @@ -7604,6 +9207,22 @@ packages: resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} dev: true + /lodash.isfunction@3.0.9: + resolution: {integrity: sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==} + dev: true + + /lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + dev: true + + /lodash.kebabcase@4.1.1: + resolution: {integrity: sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==} + dev: true + + /lodash.map@4.6.0: + resolution: {integrity: sha512-worNHGKLDetmcEYDvh2stPCrrQRkP20E4l0iIS7F8EvzMqBBi7ltvFN5m1HvTf1P7Jk1txKhvFcmYsCr8O2F1Q==} + dev: true + /lodash.memoize@4.1.2: resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} dev: true @@ -7611,6 +9230,26 @@ packages: /lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + /lodash.mergewith@4.6.2: + resolution: {integrity: sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==} + dev: true + + /lodash.snakecase@4.1.1: + resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==} + dev: true + + /lodash.startcase@4.4.0: + resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} + dev: true + + /lodash.uniq@4.5.0: + resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} + dev: true + + /lodash.upperfirst@4.3.1: + resolution: {integrity: sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg==} + dev: true + /lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} @@ -7648,6 +9287,11 @@ packages: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} dev: false + /longest@2.0.1: + resolution: {integrity: sha512-Ajzxb8CM6WAnFjgiloPsI3bF+WCxcvhdIG3KNA2KN962+tdBsHcuQ4k4qX/EcS/2CRkcc0iAkR956Nib6aXU/Q==} + engines: {node: '>=0.10.0'} + dev: true + /loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -7730,11 +9374,21 @@ packages: p-defer: 1.0.0 dev: true + /map-obj@1.0.1: + resolution: {integrity: sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==} + engines: {node: '>=0.10.0'} + dev: true + /map-obj@2.0.0: resolution: {integrity: sha512-TzQSV2DiMYgoF5RycneKVUzIa9bQsj/B3tTgsE3dOGqlzHnGIDaC7XBE7grnA+8kZPnfqSGFe95VHc2oc0VFUQ==} engines: {node: '>=4'} dev: true + /map-obj@4.3.0: + resolution: {integrity: sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==} + engines: {node: '>=8'} + dev: true + /markdown-table@3.0.3: resolution: {integrity: sha512-Z1NL3Tb1M9wH4XESsCDEksWoKTdlUafKc4pt0GRwjUyXaCFZ+dc3g2erqB6zm3szA2IUSi7VnPI+o/9jnxh9hw==} dev: false @@ -7916,6 +9570,23 @@ packages: resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==} dev: false + /meow@8.1.2: + resolution: {integrity: sha512-r85E3NdZ+mpYk1C6RjPFEMSE+s1iZMuHtsHAqY0DT3jZczl0diWUZ8g6oU7h0M9cD2EL+PzaYghhCLzR0ZNn5Q==} + engines: {node: '>=10'} + dependencies: + '@types/minimist': 1.2.5 + camelcase-keys: 6.2.2 + decamelize-keys: 1.1.1 + hard-rejection: 2.1.0 + minimist-options: 4.1.0 + normalize-package-data: 3.0.3 + read-pkg-up: 7.0.1 + redent: 3.0.0 + trim-newlines: 3.0.1 + type-fest: 0.18.1 + yargs-parser: 20.2.9 + dev: true + /merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} dev: true @@ -7925,6 +9596,10 @@ packages: engines: {node: '>= 8'} dev: true + /merge@2.1.1: + resolution: {integrity: sha512-jz+Cfrg9GWOZbQAnDQ4hlVnQky+341Yk5ru8bZSe6sIDTCIg8n9i/u7hSQGSVOF3C7lH6mGtqjkiT9G4wFLL0w==} + dev: true + /micromark-core-commonmark@2.0.0: resolution: {integrity: sha512-jThOz/pVmAYUtkroV3D5c1osFXAMv9e0ypGDOIZuCeAe91/sD6BoE2Sjzt30yuXtwOYUmySOhMas/PVyh02itA==} dependencies: @@ -8199,6 +9874,11 @@ packages: engines: {node: '>=8'} dev: true + /mimic-fn@4.0.0: + resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} + engines: {node: '>=12'} + dev: true + /mimic-response@3.1.0: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} @@ -8224,6 +9904,13 @@ packages: brace-expansion: 2.0.1 dev: false + /minimatch@8.0.4: + resolution: {integrity: sha512-W0Wvr9HyFXZRGIDgCicunpQ299OKXs9RgZfaukz4qAW/pJhcpUfupc9c+OObPOFueNy8VSrZgEmDtk6Kh4WzDA==} + engines: {node: '>=16 || 14 >=14.17'} + dependencies: + brace-expansion: 2.0.1 + dev: true + /minimatch@9.0.3: resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==} engines: {node: '>=16 || 14 >=14.17'} @@ -8231,6 +9918,19 @@ packages: brace-expansion: 2.0.1 dev: true + /minimist-options@4.1.0: + resolution: {integrity: sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==} + engines: {node: '>= 6'} + dependencies: + arrify: 1.0.1 + is-plain-obj: 1.1.0 + kind-of: 6.0.3 + dev: true + + /minimist@1.2.7: + resolution: {integrity: sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g==} + dev: true + /minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} @@ -8557,6 +10257,25 @@ packages: abbrev: 1.1.1 dev: false + /normalize-package-data@2.5.0: + resolution: {integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==} + dependencies: + hosted-git-info: 2.8.9 + resolve: 1.22.8 + semver: 5.7.2 + validate-npm-package-license: 3.0.4 + dev: true + + /normalize-package-data@3.0.3: + resolution: {integrity: sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==} + engines: {node: '>=10'} + dependencies: + hosted-git-info: 4.1.0 + is-core-module: 2.13.1 + semver: 7.5.4 + validate-npm-package-license: 3.0.4 + dev: true + /normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} @@ -8573,6 +10292,13 @@ packages: path-key: 3.1.1 dev: true + /npm-run-path@5.1.0: + resolution: {integrity: sha512-sJOdmRGrY2sjNTRMbSvluQqg+8X7ZK61yvzBEIDhz4f8z1TZFYABsqjjCBd/0PUNE9M6QDgHJXQkGUEm7Q+l9Q==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dependencies: + path-key: 4.0.0 + dev: true + /npmlog@5.0.1: resolution: {integrity: sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==} dependencies: @@ -8721,6 +10447,23 @@ packages: dependencies: mimic-fn: 2.1.0 + /onetime@6.0.0: + resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} + engines: {node: '>=12'} + dependencies: + mimic-fn: 4.0.0 + dev: true + + /open@9.1.0: + resolution: {integrity: sha512-OS+QTnw1/4vrf+9hh1jc1jnYjzSG4ttTBB8UxOwAnInG3Uo4ssetzC1ihqaIHjLJnA5GGlRl6QlZXOTQhRBUvg==} + engines: {node: '>=14.16'} + dependencies: + default-browser: 4.0.0 + define-lazy-prop: 3.0.0 + is-inside-container: 1.0.0 + is-wsl: 2.2.0 + dev: true + /optionator@0.8.3: resolution: {integrity: sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==} engines: {node: '>= 0.8.0'} @@ -8865,6 +10608,11 @@ packages: semver: 6.3.1 dev: true + /parse-passwd@1.0.0: + resolution: {integrity: sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==} + engines: {node: '>=0.10.0'} + dev: true + /parse5@7.1.2: resolution: {integrity: sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==} dependencies: @@ -8889,6 +10637,11 @@ packages: engines: {node: '>=8'} dev: true + /path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + dev: true + /path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} @@ -9168,6 +10921,12 @@ packages: engines: {node: '>= 0.8.0'} dev: true + /prettier@2.8.8: + resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} + engines: {node: '>=10.13.0'} + hasBin: true + dev: true + /prettier@3.0.3: resolution: {integrity: sha512-L/4pUDMxcNa8R/EthV08Zt42WBO4h1rarVtK0K+QJG0X187OLo7l699jWw0GKuwzkPQ//jMFA/8Xm6Fh3J/DAg==} engines: {node: '>=14'} @@ -9183,6 +10942,16 @@ packages: react-is: 17.0.2 dev: true + /pretty-format@28.1.3: + resolution: {integrity: sha512-8gFb/To0OmxHR9+ZTb14Df2vNxdGCX8g1xWGUTqUw5TiZvcQf5sHKObd5UcPyLLyowNwDAMTF3XWOG1B6mxl1Q==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + dependencies: + '@jest/schemas': 28.1.3 + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 18.2.0 + dev: true + /pretty-format@29.7.0: resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -9274,6 +11043,11 @@ packages: resolution: {integrity: sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==} dev: false + /quick-lru@4.0.1: + resolution: {integrity: sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==} + engines: {node: '>=8'} + dev: true + /quill-delta@5.1.0: resolution: {integrity: sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==} engines: {node: '>= 12.0.0'} @@ -9481,6 +11255,25 @@ packages: npm-normalize-package-bin: 3.0.1 dev: true + /read-pkg-up@7.0.1: + resolution: {integrity: sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==} + engines: {node: '>=8'} + dependencies: + find-up: 4.1.0 + read-pkg: 5.2.0 + type-fest: 0.8.1 + dev: true + + /read-pkg@5.2.0: + resolution: {integrity: sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==} + engines: {node: '>=8'} + dependencies: + '@types/normalize-package-data': 2.4.4 + normalize-package-data: 2.5.0 + parse-json: 5.2.0 + type-fest: 0.6.0 + dev: true + /readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} dependencies: @@ -9640,6 +11433,11 @@ packages: engines: {node: '>=0.10.0'} dev: true + /require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + dev: true + /requireindex@1.2.0: resolution: {integrity: sha512-L9jEkOi3ASd9PYit2cwRfyppc9NoABujTP8/5gFcbERmo5jUoAKovIC3fsF17pkTnGsrByysqX+Kxd2OTNI1ww==} engines: {node: '>=0.10.5'} @@ -9656,6 +11454,14 @@ packages: resolve-from: 5.0.0 dev: true + /resolve-dir@1.0.1: + resolution: {integrity: sha512-R7uiTjECzvOsWSfdM0QKFNBVFcK27aHOUwdvK53BcW8zqnGdYp0Fbj82cy54+2A4P2tFM22J5kRfe1R+lM/1yg==} + engines: {node: '>=0.10.0'} + dependencies: + expand-tilde: 2.0.2 + global-modules: 1.0.0 + dev: true + /resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -9665,10 +11471,22 @@ packages: engines: {node: '>=8'} dev: true + /resolve-global@1.0.0: + resolution: {integrity: sha512-zFa12V4OLtT5XUX/Q4VLvTfBf+Ok0SPc1FNGM/z9ctUdiU618qwKpWnd0CHs3+RqROfyEg/DhuHbMWYqcgljEw==} + engines: {node: '>=8'} + dependencies: + global-dirs: 0.1.1 + dev: true + /resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} dev: true + /resolve.exports@1.1.1: + resolution: {integrity: sha512-/NtpHNDN7jWhAaQ9BvBUYZ6YTXsRBgfqWFWP7BZBaoMJO/I3G5OFzvTuWNlZC3aPjins1F+TNrLKsGbH4rfsRQ==} + engines: {node: '>=10'} + dev: true + /resolve.exports@2.0.0: resolution: {integrity: sha512-6K/gDlqgQscOlg9fSRpWstA8sYe8rbELsSTNpx+3kTrsVCzvSl0zIvRErM7fdl9ERWDsKnrLnwB+Ne89918XOg==} engines: {node: '>=10'} @@ -9739,6 +11557,13 @@ packages: fsevents: 2.3.3 dev: true + /run-applescript@5.0.0: + resolution: {integrity: sha512-XcT5rBksx1QdIhlFOCtgZkB99ZEouFZ1E2Kc2LHqNW13U3/74YGdkQRmThTwxy4QIyookibDKYZOPqX//6BlAg==} + engines: {node: '>=12'} + dependencies: + execa: 5.1.1 + dev: true + /run-async@2.4.1: resolution: {integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==} engines: {node: '>=0.12.0'} @@ -9810,6 +11635,11 @@ packages: dependencies: loose-envify: 1.4.0 + /semver@5.7.2: + resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} + hasBin: true + dev: true + /semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -9966,6 +11796,28 @@ packages: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} dev: false + /spdx-correct@3.2.0: + resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==} + dependencies: + spdx-expression-parse: 3.0.1 + spdx-license-ids: 3.0.16 + dev: true + + /spdx-exceptions@2.3.0: + resolution: {integrity: sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==} + dev: true + + /spdx-expression-parse@3.0.1: + resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==} + dependencies: + spdx-exceptions: 2.3.0 + spdx-license-ids: 3.0.16 + dev: true + + /spdx-license-ids@3.0.16: + resolution: {integrity: sha512-eWN+LnM3GR6gPu35WxNgbGl8rmY1AEmoMDvL/QD6zYmPWgywxWqJWNdLGT+ke8dKNWrcYgYjPpG5gbTfghP8rw==} + dev: true + /split2@3.2.2: resolution: {integrity: sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg==} dependencies: @@ -10154,6 +12006,11 @@ packages: engines: {node: '>=6'} dev: true + /strip-final-newline@3.0.0: + resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} + engines: {node: '>=12'} + dev: true + /strip-indent@3.0.0: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} @@ -10232,6 +12089,14 @@ packages: has-flag: 4.0.0 dev: true + /supports-hyperlinks@2.3.0: + resolution: {integrity: sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==} + engines: {node: '>=8'} + dependencies: + has-flag: 4.0.0 + supports-color: 7.2.0 + dev: true + /supports-preserve-symlinks-flag@1.0.0: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} @@ -10250,6 +12115,14 @@ packages: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} dev: true + /synckit@0.8.5: + resolution: {integrity: sha512-L1dapNV6vu2s/4Sputv8xGsCdAVlb5nRDMFU/E27D44l5U6cw1g0dGd45uLc+OXjNMmF4ntiMdCimzcjFKQI8Q==} + engines: {node: ^14.18.0 || >=16.0.0} + dependencies: + '@pkgr/utils': 2.4.2 + tslib: 2.6.2 + dev: true + /systeminformation@5.21.15: resolution: {integrity: sha512-vMLwsGgJZW6GvoBXVWNZuRQG0MPxlfQnIIIY9ZxoogWftUpJ9C33qD+32e1meFlXuWpN0moNApPFLpbsSi4OaQ==} engines: {node: '>=8.0.0'} @@ -10308,6 +12181,14 @@ packages: yallist: 4.0.0 dev: false + /terminal-link@2.1.1: + resolution: {integrity: sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==} + engines: {node: '>=8'} + dependencies: + ansi-escapes: 4.3.2 + supports-hyperlinks: 2.3.0 + dev: true + /test-exclude@6.0.0: resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} engines: {node: '>=8'} @@ -10317,6 +12198,11 @@ packages: minimatch: 3.1.2 dev: true + /text-extensions@1.9.0: + resolution: {integrity: sha512-wiBrwC1EhBelW12Zy26JeOUkQ5mRu+5o8rpsJk5+2t+Y5vE7e842qtZDQ2g1NpX/29HdyFeJ4nSIhI47ENSxlQ==} + engines: {node: '>=0.10'} + dev: true + /text-hex@1.0.0: resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==} dev: false @@ -10363,6 +12249,11 @@ packages: engines: {node: '>=14.0.0'} dev: true + /titleize@3.0.0: + resolution: {integrity: sha512-KxVu8EYHDPBdUYdKZdKtU2aj2XfEx9AfjXxE/Aj0vT06w2icA09Vus1rh6eSu1y01akYg6BjIK/hxyLJINoMLQ==} + engines: {node: '>=12'} + dev: true + /tmp@0.0.33: resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} engines: {node: '>=0.6.0'} @@ -10400,6 +12291,13 @@ packages: to-no-case: 1.0.2 dev: true + /toml-eslint-parser@0.9.3: + resolution: {integrity: sha512-moYoCvkNUAPCxSW9jmHmRElhm4tVJpHL8ItC/+uYD0EpPSFXbck7yREz9tNdJVTSpHVod8+HoipcpbQ0oE6gsw==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dependencies: + eslint-visitor-keys: 3.4.3 + dev: true + /touch@3.1.0: resolution: {integrity: sha512-WBx8Uy5TLtOSRtIq+M03/sKDrXCLHxwDcquSP2c43Le03/9serjQBIztjRz6FkJez9D/hleyAXTBGLwwZUw9lA==} hasBin: true @@ -10431,6 +12329,11 @@ packages: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} dev: false + /trim-newlines@3.0.1: + resolution: {integrity: sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==} + engines: {node: '>=8'} + dev: true + /triple-beam@1.3.0: resolution: {integrity: sha512-XrHUvV5HpdLmIj4uVMxHggLbFSZYIn7HEWsqePZcI50pco+MPqJ50wMGY794X7AOOhxOBAjbkqfAbEe/QMp2Lw==} dev: false @@ -10448,6 +12351,40 @@ packages: typescript: 5.2.2 dev: true + /ts-jest@28.0.8(@babel/core@7.23.2)(jest@28.1.3)(typescript@4.9.5): + resolution: {integrity: sha512-5FaG0lXmRPzApix8oFG8RKjAz4ehtm8yMKOTy5HX3fY6W8kmvOrmcY0hKDElW52FJov+clhUbrKAqofnj4mXTg==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + hasBin: true + peerDependencies: + '@babel/core': '>=7.0.0-beta.0 <8' + '@jest/types': ^28.0.0 + babel-jest: ^28.0.0 + esbuild: '*' + jest: ^28.0.0 + typescript: '>=4.3' + peerDependenciesMeta: + '@babel/core': + optional: true + '@jest/types': + optional: true + babel-jest: + optional: true + esbuild: + optional: true + dependencies: + '@babel/core': 7.23.2 + bs-logger: 0.2.6 + fast-json-stable-stringify: 2.1.0 + jest: 28.1.3(@types/node@18.18.13)(ts-node@10.9.1) + jest-util: 28.1.3 + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.5.4 + typescript: 4.9.5 + yargs-parser: 21.1.1 + dev: true + /ts-jest@29.1.1(@babel/core@7.23.2)(jest@29.7.0)(typescript@5.2.2): resolution: {integrity: sha512-D6xjnnbP17cC85nliwGiL+tpoKN0StpgE0TeOjXQTU6MVCfsB4v7aW05CgQ/1OywGb0x/oy9hHFnN+sczTiRaA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -10482,6 +12419,37 @@ packages: yargs-parser: 21.1.1 dev: true + /ts-node@10.9.1(@types/node@18.18.13)(typescript@4.9.5): + resolution: {integrity: sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==} + hasBin: true + peerDependencies: + '@swc/core': '>=1.2.50' + '@swc/wasm': '>=1.2.50' + '@types/node': '*' + typescript: '>=2.7' + peerDependenciesMeta: + '@swc/core': + optional: true + '@swc/wasm': + optional: true + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.9 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.3 + '@types/node': 18.18.13 + acorn: 8.8.2 + acorn-walk: 8.2.0 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.2 + make-error: 1.3.6 + typescript: 4.9.5 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + dev: true + /ts-node@10.9.1(@types/node@20.8.10)(typescript@5.2.2): resolution: {integrity: sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==} hasBin: true @@ -10587,6 +12555,11 @@ packages: engines: {node: '>=4'} dev: true + /type-fest@0.18.1: + resolution: {integrity: sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw==} + engines: {node: '>=10'} + dev: true + /type-fest@0.20.2: resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} engines: {node: '>=10'} @@ -10602,6 +12575,11 @@ packages: engines: {node: '>=8'} dev: true + /type-fest@0.8.1: + resolution: {integrity: sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==} + engines: {node: '>=8'} + dev: true + /type-fest@1.4.0: resolution: {integrity: sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==} engines: {node: '>=10'} @@ -10649,6 +12627,12 @@ packages: is-typed-array: 1.1.12 dev: true + /typescript@4.9.5: + resolution: {integrity: sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==} + engines: {node: '>=4.2.0'} + hasBin: true + dev: true + /typescript@5.2.2: resolution: {integrity: sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==} engines: {node: '>=14.17'} @@ -10749,6 +12733,11 @@ packages: resolution: {integrity: sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==} engines: {node: '>= 10.0.0'} + /untildify@4.0.0: + resolution: {integrity: sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==} + engines: {node: '>=8'} + dev: true + /update-browserslist-db@1.0.13(browserslist@4.22.1): resolution: {integrity: sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==} hasBin: true @@ -10870,6 +12859,13 @@ packages: convert-source-map: 1.9.0 dev: true + /validate-npm-package-license@3.0.4: + resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} + dependencies: + spdx-correct: 3.2.0 + spdx-expression-parse: 3.0.1 + dev: true + /validate-npm-package-name@4.0.0: resolution: {integrity: sha512-mzR0L8ZDktZjpX4OB46KT+56MAhl4EIazWP/+G/HPGuvfdaqg4YsCdtOm6U9+LOFyYDoh4dpnpxZRB9MQQns5Q==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} @@ -11194,6 +13190,13 @@ packages: has-tostringtag: 1.0.0 dev: true + /which@1.3.1: + resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} + hasBin: true + dependencies: + isexe: 2.0.0 + dev: true + /which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -11321,11 +13324,25 @@ packages: /yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + /yaml-eslint-parser@1.2.2: + resolution: {integrity: sha512-pEwzfsKbTrB8G3xc/sN7aw1v6A6c/pKxLAkjclnAyo5g5qOh6eL9WGu0o3cSDQZKrTNk4KL4lQSwZW+nBkANEg==} + engines: {node: ^14.17.0 || >=16.0.0} + dependencies: + eslint-visitor-keys: 3.4.3 + lodash: 4.17.21 + yaml: 2.3.4 + dev: true + /yaml@1.10.2: resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} engines: {node: '>= 6'} dev: false + /yaml@2.3.4: + resolution: {integrity: sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==} + engines: {node: '>= 14'} + dev: true + /yargs-parser@20.2.9: resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} engines: {node: '>=10'} diff --git a/public/tipi-christmas.png b/public/tipi-christmas.png new file mode 100644 index 0000000000000000000000000000000000000000..34a36c8afabce09caa93a566f24c72e4fed7e653 Binary files /dev/null and b/public/tipi-christmas.png differ diff --git a/scripts/install.sh b/scripts/install.sh index bdfd97063c50055853b9c83c20e0960ee31c0e7d..eb8d887ed7ca417220aeeb66f3d498e16b2a16a7 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -74,6 +74,7 @@ function install_generic() { function install_docker() { local os="${1}" echo "Installing docker for os ${os}" + echo "Your sudo password might be asked to install docker" if [[ "${os}" == "debian" ]]; then sudo DEBIAN_FRONTEND=noninteractive apt-get install -y ca-certificates curl gnupg lsb-release @@ -134,6 +135,14 @@ if ! command -v docker >/dev/null; then exit 1 fi fi + + # Make sure user is in docker group + if ! groups | grep -q '\bdocker\b'; then + sudo usermod -aG docker "$USER" + fi + + # Reload user groups + newgrp docker fi function check_dependency_and_install() { @@ -185,10 +194,4 @@ fi curl --location "$URL" -o ./runtipi-cli chmod +x ./runtipi-cli -# Check if git is installed -if ! command -v git >/dev/null; then - echo "Git is not installed. Please install git and restart the script." - exit 1 -fi - -sudo ./runtipi-cli start +./runtipi-cli start diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx index be75a43f5cf17f2f6609c09094d7d339c8ed8b83..cac59e81aedc30f133b4941b5409361aee232897 100644 --- a/src/app/(auth)/layout.tsx +++ b/src/app/(auth)/layout.tsx @@ -1,10 +1,13 @@ import React from 'react'; import Image from 'next/image'; import { getCurrentLocale } from 'src/utils/getCurrentLocale'; +import { getLogo } from '@/lib/themes'; +import { getConfig } from '@/server/core/TipiConfig'; import { LanguageSelector } from '../components/LanguageSelector'; export default async function AuthLayout({ children }: { children: React.ReactNode }) { const locale = getCurrentLocale(); + const { allowAutoThemes } = getConfig(); return (
@@ -15,7 +18,7 @@ export default async function AuthLayout({ children }: { children: React.ReactNo
Tipi logo>; @@ -40,6 +42,7 @@ export const AppDetailsContainer: React.FC = ({ app, localDomain }) => { const stopDisclosure = useDisclosure(); const updateDisclosure = useDisclosure(); const updateSettingsDisclosure = useDisclosure(); + const resetAppDisclosure = useDisclosure(); const installMutation = useAction(installAppAction, { onSuccess: (data) => { @@ -91,11 +94,11 @@ export const AppDetailsContainer: React.FC = ({ app, localDomain }) => { const updateMutation = useAction(updateAppAction, { onSuccess: (data) => { + setCustomStatus(app.status); + if (!data.success) { - setCustomStatus(app.status); toast.error(data.failure.reason); } else { - setCustomStatus('stopped'); toast.success(t('apps.app-details.update-success')); } }, @@ -111,6 +114,19 @@ export const AppDetailsContainer: React.FC = ({ app, localDomain }) => { }, }); + const resetMutation = useAction(resetAppAction, { + onSuccess: (data) => { + if (!data.success) { + toast.error(data.failure.reason); + resetAppDisclosure.close(); + } else { + resetAppDisclosure.close(); + toast.success(t('apps.app-details.app-reset-success')); + setCustomStatus('running'); + } + }, + }); + const updateAvailable = Number(app.version || 0) < Number(app?.latestVersion || 0); const handleInstallSubmit = async (values: FormValues) => { @@ -147,6 +163,17 @@ export const AppDetailsContainer: React.FC = ({ app, localDomain }) => { updateMutation.execute({ id: app.id }); }; + const handleResetSubmit = () => { + setCustomStatus('stopping'); + resetMutation.execute({ id: app.id }); + resetAppDisclosure.open(); + }; + + const openResetAppModal = () => { + updateSettingsDisclosure.close(); + resetAppDisclosure.open(); + }; + const handleOpen = (type: OpenType) => { let url = ''; const { https } = app.info; @@ -177,12 +204,15 @@ export const AppDetailsContainer: React.FC = ({ app, localDomain }) => { +
diff --git a/src/app/(dashboard)/app-store/[id]/components/InstallForm/InstallForm.tsx b/src/app/(dashboard)/app-store/[id]/components/InstallForm/InstallForm.tsx index c0e7d1e1e4cfc95d33dfcdb81dd2cdeecd515b78..cebe2eb61a05b4810606cb866dddad3e3229779d 100644 --- a/src/app/(dashboard)/app-store/[id]/components/InstallForm/InstallForm.tsx +++ b/src/app/(dashboard)/app-store/[id]/components/InstallForm/InstallForm.tsx @@ -9,6 +9,7 @@ import { type FormField, type AppInfo } from '@runtipi/shared'; import { Switch } from '@/components/ui/Switch'; import { Input } from '@/components/ui/Input'; import { Button } from '@/components/ui/Button'; +import { AppStatus } from '@/server/db/schema'; import { validateAppConfig } from '../../utils/validators'; interface IProps { @@ -17,6 +18,8 @@ interface IProps { initalValues?: { [key: string]: unknown }; info: AppInfo; loading?: boolean; + onReset?: () => void; + status?: AppStatus; } export type FormValues = { @@ -29,7 +32,7 @@ export type FormValues = { const hiddenTypes = ['random']; const typeFilter = (field: FormField) => !hiddenTypes.includes(field.type); -export const InstallForm: React.FC = ({ formFields, info, onSubmit, initalValues, loading }) => { +export const InstallForm: React.FC = ({ formFields, info, onSubmit, initalValues, loading, onReset, status }) => { const t = useTranslations('apps.app-details.install-form'); const { register, @@ -56,6 +59,11 @@ export const InstallForm: React.FC = ({ formFields, info, onSubmit, init } }, [initalValues, isDirty, setValue]); + const onClickReset = (e: React.MouseEvent) => { + e.preventDefault(); + if (onReset) onReset(); + }; + const renderField = (field: FormField) => { const label = ( <> @@ -166,6 +174,11 @@ export const InstallForm: React.FC = ({ formFields, info, onSubmit, init + {initalValues && onReset && ( + + )} ); }; diff --git a/src/app/(dashboard)/app-store/[id]/components/ResetAppModal/ResetAppModal.tsx b/src/app/(dashboard)/app-store/[id]/components/ResetAppModal/ResetAppModal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..61bff7230c74be0a6c7f02f34a32f36839965c01 --- /dev/null +++ b/src/app/(dashboard)/app-store/[id]/components/ResetAppModal/ResetAppModal.tsx @@ -0,0 +1,38 @@ +import { IconAlertTriangle } from '@tabler/icons-react'; +import React from 'react'; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader } from '@/components/ui/Dialog'; +import { useTranslations } from 'next-intl'; +import { AppInfo } from '@runtipi/shared'; +import { Button } from '@/components/ui/Button'; + +interface IProps { + info: AppInfo; + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + isLoading?: boolean; +} + +export const ResetAppModal: React.FC = ({ info, isOpen, onClose, onConfirm, isLoading }) => { + const t = useTranslations('apps.app-details.reset-app-form'); + + return ( + + + +
{t('title', { name: info.name })}
+
+ + +

{t('warning')}

+
{t('subtitle')}
+
+ + + +
+
+ ); +}; diff --git a/src/app/(dashboard)/app-store/[id]/components/ResetAppModal/index.ts b/src/app/(dashboard)/app-store/[id]/components/ResetAppModal/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..8d60293f7ac43413e9f49a2937cb5010425f8630 --- /dev/null +++ b/src/app/(dashboard)/app-store/[id]/components/ResetAppModal/index.ts @@ -0,0 +1 @@ +export { ResetAppModal } from './ResetAppModal'; diff --git a/src/app/(dashboard)/app-store/[id]/components/UpdateSettingsModal/UpdateSettingsModal.tsx b/src/app/(dashboard)/app-store/[id]/components/UpdateSettingsModal/UpdateSettingsModal.tsx index a70930a009afae12d61a114a2434d80cf62785b4..3b039a69cd1bf70f0eb1254a21df55b2999058a3 100644 --- a/src/app/(dashboard)/app-store/[id]/components/UpdateSettingsModal/UpdateSettingsModal.tsx +++ b/src/app/(dashboard)/app-store/[id]/components/UpdateSettingsModal/UpdateSettingsModal.tsx @@ -3,6 +3,7 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader } from '@/compon import { useTranslations } from 'next-intl'; import { AppInfo } from '@runtipi/shared'; import { ScrollArea } from '@/components/ui/ScrollArea'; +import { AppStatus } from '@/server/db/schema'; import { InstallForm, type FormValues } from '../InstallForm'; interface IProps { @@ -11,9 +12,11 @@ interface IProps { isOpen: boolean; onClose: () => void; onSubmit: (values: FormValues) => void; + onReset: () => void; + status?: AppStatus; } -export const UpdateSettingsModal: React.FC = ({ info, config, isOpen, onClose, onSubmit }) => { +export const UpdateSettingsModal: React.FC = ({ info, config, isOpen, onClose, onSubmit, onReset, status }) => { const t = useTranslations('apps.app-details.update-settings-form'); return ( @@ -24,7 +27,7 @@ export const UpdateSettingsModal: React.FC = ({ info, config, isOpen, on - + diff --git a/src/app/(dashboard)/apps/page.tsx b/src/app/(dashboard)/apps/page.tsx index 02a1de629f13907ba7258e31aec11739caebb8bf..e5337c01c515a7448b42d3595ac4a4a155dd02fb 100644 --- a/src/app/(dashboard)/apps/page.tsx +++ b/src/app/(dashboard)/apps/page.tsx @@ -26,7 +26,7 @@ export default async function Page() { if (app.info?.available) return ( - + ); diff --git a/src/app/(dashboard)/components/Header/Header.tsx b/src/app/(dashboard)/components/Header/Header.tsx index 0e1ad03d47ce5ee0254bd12ec32b10481b7f77ce..8f7713b4915d6130af23aaf09fe9656d206c0c33 100644 --- a/src/app/(dashboard)/components/Header/Header.tsx +++ b/src/app/(dashboard)/components/Header/Header.tsx @@ -13,14 +13,16 @@ import { useAction } from 'next-safe-action/hook'; import { logoutAction } from '@/actions/logout/logout-action'; import Script from 'next/script'; import { useRouter } from 'next/navigation'; +import { getLogo } from '@/lib/themes'; import { NavBar } from '../NavBar'; interface IProps { isUpdateAvailable?: boolean; authenticated?: boolean; + autoTheme: boolean; } -export const Header: React.FC = ({ isUpdateAvailable, authenticated = true }) => { +export const Header: React.FC = ({ isUpdateAvailable, authenticated = true, autoTheme }) => { const { setDarkMode } = useUIStore(); const t = useTranslations('header'); @@ -55,7 +57,7 @@ export const Header: React.FC = ({ isUpdateAvailable, authenticated = tr className={clsx('navbar-brand-image me-3')} width={100} height={100} - src="/tipi.png" + src={getLogo(autoTheme)} style={{ width: '30px', maxWidth: '30px', diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index 24f08a37e5b4d14828ffcd9a4eeae184a957e57f..b8ea25add99b60e72b51c53036109f15ea5a1b90 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -5,6 +5,7 @@ import { SystemServiceClass } from '@/server/services/system'; import semver from 'semver'; import clsx from 'clsx'; import { AppServiceClass } from '@/server/services/apps/apps.service'; +import { getConfig } from '@/server/core/TipiConfig'; import { Header } from './components/Header'; import { PageTitle } from './components/PageTitle'; import styles from './layout.module.scss'; @@ -12,6 +13,7 @@ import { LayoutActions } from './components/LayoutActions/LayoutActions'; export default async function DashboardLayout({ children }: { children: React.ReactNode }) { const user = await getUserFromCookie(); + const { allowAutoThemes } = getConfig(); const { apps } = await AppServiceClass.listApps(); @@ -25,7 +27,7 @@ export default async function DashboardLayout({ children }: { children: React.Re return (
-
+
diff --git a/src/app/(dashboard)/settings/components/GeneralActions/GeneralActions.tsx b/src/app/(dashboard)/settings/components/GeneralActions/GeneralActions.tsx index 1f580f2b43eadef74ca72704e74476403ee03895..14e5f67ed9aaac6c2be7224759dc61f7f0a19cb0 100644 --- a/src/app/(dashboard)/settings/components/GeneralActions/GeneralActions.tsx +++ b/src/app/(dashboard)/settings/components/GeneralActions/GeneralActions.tsx @@ -2,16 +2,10 @@ import React from 'react'; import semver from 'semver'; -import { toast } from 'react-hot-toast'; import { Markdown } from '@/components/Markdown'; import { IconStar } from '@tabler/icons-react'; import { useTranslations } from 'next-intl'; -import { useDisclosure } from '@/client/hooks/useDisclosure'; import { Button } from '@/components/ui/Button'; -import { useSystemStore } from '@/client/state/systemStore'; -import { useAction } from 'next-safe-action/hook'; -import { restartAction } from '@/actions/settings/restart'; -import { RestartModal } from '../RestartModal'; type Props = { version: { current: string; latest: string; body?: string | null } }; @@ -19,29 +13,9 @@ export const GeneralActions = (props: Props) => { const t = useTranslations(); const { version } = props; - const [loading, setLoading] = React.useState(false); - const { setPollStatus, setStatus } = useSystemStore(); - const restartDisclosure = useDisclosure(); - const defaultVersion = '0.0.0'; const isLatest = semver.gte(version.current || defaultVersion, version.latest || defaultVersion); - const restartMutation = useAction(restartAction, { - onSuccess: (data) => { - if (data.success) { - setPollStatus(true); - setStatus('RESTARTING'); - } else { - restartDisclosure.close(); - setLoading(false); - toast.error(data.failure.reason); - } - }, - onExecute: () => { - setLoading(true); - }, - }); - const renderUpdate = () => { if (isLatest) { return ; @@ -66,19 +40,11 @@ export const GeneralActions = (props: Props) => { }; return ( - <> -
-

{t('settings.actions.title')}

-

{t('settings.actions.current-version', { version: version.current })}

-

{isLatest ? t('settings.actions.stay-up-to-date') : t('settings.actions.new-version', { version: version.latest })}

- {renderUpdate()} -

{t('settings.actions.maintenance-title')}

-

{t('settings.actions.maintenance-subtitle')}

-
- -
-
- restartMutation.execute()} loading={loading} /> - +
+

{t('settings.actions.title')}

+

{t('settings.actions.current-version', { version: version.current })}

+

{isLatest ? t('settings.actions.stay-up-to-date') : t('settings.actions.new-version', { version: version.latest })}

+ {renderUpdate()} +
); }; diff --git a/src/app/(dashboard)/settings/components/RestartModal/RestartModal.tsx b/src/app/(dashboard)/settings/components/RestartModal/RestartModal.tsx deleted file mode 100644 index 5c7f5adf615d0edba20ffa3975522c01bf3ef04c..0000000000000000000000000000000000000000 --- a/src/app/(dashboard)/settings/components/RestartModal/RestartModal.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import React from 'react'; -import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader } from '@/components/ui/Dialog'; -import { Button } from '@/components/ui/Button'; - -interface IProps { - isOpen: boolean; - onClose: () => void; - onConfirm: () => void; - loading: boolean; -} - -export const RestartModal: React.FC = ({ isOpen, onClose, onConfirm, loading }) => ( - - - -
Restart Tipi
-
- -
Would you like to restart your Tipi server?
-
- - - -
-
-); diff --git a/src/app/(dashboard)/settings/components/RestartModal/index.ts b/src/app/(dashboard)/settings/components/RestartModal/index.ts deleted file mode 100644 index 9c226b46ef67fe9f900367e31edea8cab8d5dec7..0000000000000000000000000000000000000000 --- a/src/app/(dashboard)/settings/components/RestartModal/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { RestartModal } from './RestartModal'; diff --git a/src/app/(dashboard)/settings/components/SettingsContainer/SettingsContainer.tsx b/src/app/(dashboard)/settings/components/SettingsContainer/SettingsContainer.tsx index f9458e26c37220036de67c98d8823fbb1c28d688..b7bc96b7865a31152941966c7f3b40e209abe867 100644 --- a/src/app/(dashboard)/settings/components/SettingsContainer/SettingsContainer.tsx +++ b/src/app/(dashboard)/settings/components/SettingsContainer/SettingsContainer.tsx @@ -6,6 +6,7 @@ import { useTranslations } from 'next-intl'; import { useAction } from 'next-safe-action/hook'; import { updateSettingsAction } from '@/actions/settings/update-settings'; import { Locale } from '@/shared/internationalization/locales'; +import { useRouter } from 'next/navigation'; import { SettingsForm, SettingsFormValues } from '../SettingsForm'; type Props = { @@ -16,12 +17,15 @@ type Props = { export const SettingsContainer = ({ initialValues, currentLocale }: Props) => { const t = useTranslations(); + const router = useRouter(); + const updateSettingsMutation = useAction(updateSettingsAction, { onSuccess: (data) => { if (!data.success) { toast.error(data.failure.reason); } else { toast.success(t('settings.settings.settings-updated')); + router.refresh(); } }, }); diff --git a/src/app/(dashboard)/settings/components/SettingsForm/SettingsForm.tsx b/src/app/(dashboard)/settings/components/SettingsForm/SettingsForm.tsx index 1d70017819f52167e6446e53c03f3454d60a1fd0..b9adefe2032cfd84e4cf744a5c48ca851549a199 100644 --- a/src/app/(dashboard)/settings/components/SettingsForm/SettingsForm.tsx +++ b/src/app/(dashboard)/settings/components/SettingsForm/SettingsForm.tsx @@ -19,6 +19,7 @@ export type SettingsFormValues = { storagePath?: string; localDomain?: string; guestDashboard?: boolean; + allowAutoThemes?: boolean; }; interface IProps { @@ -139,6 +140,29 @@ export const SettingsForm = (props: IProps) => { )} />
+
+ ( + + {t('allow-auto-themes')} + {t('allow-auto-themes-hint')} + ? + + } + /> + )} + /> +
{ + try { + const appsService = new AppServiceClass(db); + + await appsService.resetApp(id); + + revalidatePath('/apps'); + revalidatePath(`/app/${id}`); + revalidatePath(`/app-store/${id}`); + + return { success: true }; + } catch (e) { + return handleActionError(e); + } +}); diff --git a/src/app/actions/settings/restart.ts b/src/app/actions/settings/restart.ts deleted file mode 100644 index f844efe9c62b26752e9b3fe1183c6ad3993cb3ac..0000000000000000000000000000000000000000 --- a/src/app/actions/settings/restart.ts +++ /dev/null @@ -1,32 +0,0 @@ -'use server'; - -import { z } from 'zod'; -import { action } from '@/lib/safe-action'; -import { getUserFromCookie } from '@/server/common/session.helpers'; -import { SystemServiceClass } from '@/server/services/system'; -import { revalidatePath } from 'next/cache'; -import { handleActionError } from '../utils/handle-action-error'; - -const input = z.void(); - -/** - * Restarts the system - */ -export const restartAction = action(input, async () => { - try { - const user = await getUserFromCookie(); - - if (!user?.operator) { - throw new Error('Not authorized'); - } - - const systemService = new SystemServiceClass(); - await systemService.restart(); - - revalidatePath('/'); - - return { success: true }; - } catch (e) { - return handleActionError(e); - } -}); diff --git a/src/app/api/get-status/route.ts b/src/app/api/get-status/route.ts deleted file mode 100644 index eaac47f5d1ed6c9c3d800b98da8876d8161c75b8..0000000000000000000000000000000000000000 --- a/src/app/api/get-status/route.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { TipiCache } from '@/server/core/TipiCache'; - -export const dynamic = 'force-dynamic'; - -export async function GET() { - try { - const cache = new TipiCache('getStatus'); - const status = await cache.get('status'); - await cache.close(); - - return Response.json({ success: true, status: status || 'RUNNING' }); - } catch (error) { - return Response.json({ success: false, status: 'ERROR', error }); - } -} diff --git a/src/app/components/ClientProviders/ClientProviders.tsx b/src/app/components/ClientProviders/ClientProviders.tsx index 8cec881be09349617e664f60a3d0fcb34560cdea..7c7b7768f00ba7991a827308dc2b48aea06ee0e0 100644 --- a/src/app/components/ClientProviders/ClientProviders.tsx +++ b/src/app/components/ClientProviders/ClientProviders.tsx @@ -8,12 +8,15 @@ type Props = { children: React.ReactNode; cookies: ComponentProps['value']; initialTheme?: string; + allowAutoThemes: boolean; }; -export const ClientProviders = ({ children, initialTheme, cookies }: Props) => { +export const ClientProviders = ({ children, initialTheme, cookies, allowAutoThemes }: Props) => { return ( - {children} + + {children} + ); }; diff --git a/src/app/components/ClientProviders/ThemeProvider/ThemeProvider.tsx b/src/app/components/ClientProviders/ThemeProvider/ThemeProvider.tsx index 878a377e5acba1abb92819900b909e6fea04c2bd..98f3d90922939a0913d1473ba6666fbb4cbce5a9 100644 --- a/src/app/components/ClientProviders/ThemeProvider/ThemeProvider.tsx +++ b/src/app/components/ClientProviders/ThemeProvider/ThemeProvider.tsx @@ -3,14 +3,22 @@ import { useUIStore } from '@/client/state/uiStore'; import React, { useEffect } from 'react'; import { useCookies } from 'next-client-cookies'; +import { getAutoTheme } from '@/lib/themes'; type Props = { children: React.ReactNode; + allowAutoThemes: boolean; initialTheme?: string; }; +const loadChristmasTheme = async () => { + const { default: LetItGo } = await import('let-it-go'); + const snow = new LetItGo({ number: 50 }); + snow.letItGoAgain(); +}; + export const ThemeProvider = (props: Props) => { - const { children, initialTheme } = props; + const { children, initialTheme, allowAutoThemes } = props; const cookies = useCookies(); const { theme, setDarkMode } = useUIStore(); @@ -30,5 +38,12 @@ export const ThemeProvider = (props: Props) => { setDarkMode(cookieTheme === 'dark'); }, [cookies, initialTheme, setDarkMode, theme]); + useEffect(() => { + const autoTheme = getAutoTheme(); + if (autoTheme === 'christmas' && allowAutoThemes && typeof window !== 'undefined') { + loadChristmasTheme(); + } + }, [allowAutoThemes]); + return children; }; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 2909f63902b67122dafa93195aa7401800be90ff..c68b9e50b1fbf3ee8cef3ecb103c9c3ae886c62a 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -5,11 +5,11 @@ import { cookies } from 'next/headers'; import { Inter } from 'next/font/google'; import merge from 'lodash.merge'; import { NextIntlClientProvider } from 'next-intl'; +import { getConfig } from '@/server/core/TipiConfig'; import './global.css'; import clsx from 'clsx'; import { Toaster } from 'react-hot-toast'; -import { StatusProvider } from '@/components/hoc/StatusProvider'; import { getCurrentLocale } from '../utils/getCurrentLocale'; import { ClientProviders } from './components/ClientProviders'; @@ -32,12 +32,14 @@ export default async function RootLayout({ children }: { children: React.ReactNo const theme = cookies().get('theme'); + const { allowAutoThemes } = getConfig(); + return ( - + - {children} + {children} diff --git a/src/app/page.tsx b/src/app/page.tsx index eb70ef3accad3444e6756883944a7238b8f4ea7a..be54275dfdabba99dd1ab0ae340fa0ae742471be 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -14,7 +14,7 @@ export const dynamic = 'force-dynamic'; export default async function RootPage() { const appService = new AppServiceClass(db); - const { guestDashboard } = getConfig(); + const { guestDashboard, allowAutoThemes } = getConfig(); const headersList = headers(); const host = headersList.get('host'); @@ -24,7 +24,7 @@ export default async function RootPage() { const apps = await appService.getGuestDashboardApps(); return ( - + {apps.length === 0 ? ( ) : ( diff --git a/src/client/components/StatusScreen/StatusScreen.tsx b/src/client/components/StatusScreen/StatusScreen.tsx index dabd025af39eec6e7e4f745747670263ec350114..b41c05deb7dda91116d1a936b3bf737cc0833180 100644 --- a/src/client/components/StatusScreen/StatusScreen.tsx +++ b/src/client/components/StatusScreen/StatusScreen.tsx @@ -1,5 +1,6 @@ import Image from 'next/image'; import React from 'react'; +import { getLogo } from '@/lib/themes'; import { Button } from '../ui/Button'; interface IProps { @@ -16,7 +17,7 @@ export const StatusScreen: React.FC = ({ title, subtitle, onAction, acti Tipi log { - const { children, title, subtitle } = props; + const { children, title, subtitle, autoTheme } = props; const t = useTranslations(); return (
-
+
diff --git a/src/client/components/hoc/StatusProvider/StatusProvider.tsx b/src/client/components/hoc/StatusProvider/StatusProvider.tsx deleted file mode 100644 index 35f5a4cf19b59c03444ee01266465ed123f975ab..0000000000000000000000000000000000000000 --- a/src/client/components/hoc/StatusProvider/StatusProvider.tsx +++ /dev/null @@ -1,52 +0,0 @@ -'use client'; - -import React, { useRef, useEffect } from 'react'; -import { SystemStatus, useSystemStore } from '@/client/state/systemStore'; -import { useRouter } from 'next/navigation'; -import useSWR, { Fetcher } from 'swr'; -import { StatusScreen } from '../../StatusScreen'; - -interface IProps { - children: React.ReactNode; -} - -const fetcher: Fetcher<{ status?: SystemStatus; success?: boolean }> = () => - fetch('/api/get-status', { cache: 'no-store', next: { revalidate: 0 } }).then((res) => res.json() as Promise<{ status: SystemStatus }>); - -export const StatusProvider: React.FC = ({ children }) => { - const { status, setStatus, pollStatus, setPollStatus } = useSystemStore(); - const s = useRef(status); - - const router = useRouter(); - - useSWR('/api/get-status', fetcher, { - refreshInterval: pollStatus ? 2000 : 0, - isPaused: () => !pollStatus, - onSuccess: (res) => { - if (res.success && res.status) { - setStatus(res.status); - } - }, - }); - - useEffect(() => { - // If previous was not running and current is running, we need to refresh the page - if (status === 'RUNNING' && s.current !== 'RUNNING') { - setPollStatus(false); - router.push('/'); - router.refresh(); - } - if (status === 'RUNNING') { - s.current = 'RUNNING'; - } - if (status === 'RESTARTING') { - s.current = 'RESTARTING'; - } - }, [status, s, router, setPollStatus]); - - if (status === 'RESTARTING') { - return ; - } - - return children; -}; diff --git a/src/client/components/hoc/StatusProvider/index.ts b/src/client/components/hoc/StatusProvider/index.ts deleted file mode 100644 index 8fbca663fc7963f0ab47c88cd652376e024f1061..0000000000000000000000000000000000000000 --- a/src/client/components/hoc/StatusProvider/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { StatusProvider } from './StatusProvider'; diff --git a/src/client/messages/en-US.json b/src/client/messages/en-US.json index 0b3d48293570c9fd44d8d68733eba3c24ed6d63a..295669fe97346c430be015a7f9207101eec74623 100644 --- a/src/client/messages/en-US.json +++ b/src/client/messages/en-US.json @@ -107,6 +107,7 @@ }, "apps": { "status-running": "Running", + "status-reseting": "Resetting", "status-stopped": "Stopped", "status-starting": "Starting", "status-stopping": "Stopping", @@ -135,6 +136,7 @@ "update-success": "App updated successfully", "start-success": "App started successfully", "update-config-success": "App config updated successfully. Restart the app to apply the changes", + "app-reset-success": "App reset successfully", "version": "Version", "description": "Description", "base-info": "Base info", @@ -182,6 +184,7 @@ "choose-option": "Choose an option...", "sumbit-install": "Install", "submit-update": "Update", + "reset": "Reset app", "errors": { "required": "{label} is required", "regex": "{label} does not match the pattern {pattern}", diff --git a/src/client/messages/en.json b/src/client/messages/en.json index e5c9bb681c5504fe41ac3e6e4f358474f3e1b6e9..786e8f404389b191040b5954fef314f442d3fdd6 100644 --- a/src/client/messages/en.json +++ b/src/client/messages/en.json @@ -108,6 +108,7 @@ "apps": { "status-running": "Running", "status-stopped": "Stopped", + "status-resetting": "Resetting", "status-starting": "Starting", "status-stopping": "Stopping", "status-updating": "Updating", @@ -136,6 +137,7 @@ "start-success": "App started successfully", "update-config-success": "App config updated successfully. Restart the app to apply the changes", "version": "Version", + "app-reset-success": "App reset successfully", "description": "Description", "base-info": "Base info", "source-code": "Source code", @@ -183,6 +185,7 @@ "choose-option": "Choose an option...", "sumbit-install": "Install", "submit-update": "Update", + "reset": "Reset app", "errors": { "required": "{label} is required", "regex": "{label} must match the pattern {pattern}", @@ -208,10 +211,16 @@ "warning": "Are you sure? This action cannot be undone.", "submit": "Uninstall" }, + "reset-app-form": { + "title": "Reset {name} ?", + "subtitle": "All data for this app will be lost.", + "warning": "Are you sure? This action cannot be undone.", + "submit": "Reset" + }, "update-form": { "title": "Update {name} ?", "subtitle1": "Update app to latest verion :", - "subtitle2": "This will reset your custom configuration (e.g. changes in docker-compose.yml)", + "subtitle2": "Make sure you've read the release notes of the app and you've backed up your app data.", "submit": "Update" }, "update-settings-form": { @@ -242,6 +251,8 @@ "invalid-domain": "Invalid domain", "guest-dashboard": "Enable guest dashboard", "guest-dashboard-hint": "This will allow non-authenticated users to see a limited dashboard and easily access the running apps on your instance.", + "allow-auto-themes": "Allow auto themes", + "allow-auto-themes-hint": "Be surprised by themes that change automatically based on the time of the year.", "domain-name": "Domain name", "domain-name-hint": "Make sure this exact domain contains an A record pointing to your IP.", "dns-ip": "DNS IP", diff --git a/src/client/state/systemStore.ts b/src/client/state/systemStore.ts index c330b5f9d8dff88e9a4b95498b4bafd3fd08ca8a..5a84329ea2534f84149efcd5b1e9de7c278defce 100644 --- a/src/client/state/systemStore.ts +++ b/src/client/state/systemStore.ts @@ -1,17 +1,8 @@ import { create } from 'zustand'; -const SYSTEM_STATUS = { - RUNNING: 'RUNNING', - RESTARTING: 'RESTARTING', - LOADING: 'UPDATING', -} as const; -export type SystemStatus = (typeof SYSTEM_STATUS)[keyof typeof SYSTEM_STATUS]; - type Store = { - status: SystemStatus; pollStatus: boolean; version: { current: string; latest?: string }; - setStatus: (status: SystemStatus) => void; setVersion: (version: { current: string; latest?: string }) => void; setPollStatus: (pollStatus: boolean) => void; }; @@ -20,7 +11,6 @@ export const useSystemStore = create((set) => ({ status: 'RUNNING', version: { current: '0.0.0', latest: '0.0.0' }, pollStatus: false, - setStatus: (status: SystemStatus) => set((state) => ({ ...state, status })), setVersion: (version: { current: string; latest?: string }) => set((state) => ({ ...state, version })), setPollStatus: (pollStatus: boolean) => set((state) => ({ ...state, pollStatus })), })); diff --git a/src/lib/themes.ts b/src/lib/themes.ts new file mode 100644 index 0000000000000000000000000000000000000000..5ffe37a3e4919a847e9c2c67b8bff21144908832 --- /dev/null +++ b/src/lib/themes.ts @@ -0,0 +1,38 @@ +export const THEMES = { + christmas: { + name: 'christmas', + month: 11, + day: 1, + durationInDays: 26, + }, +}; + +export type Theme = keyof typeof THEMES | 'default'; + +export const getAutoTheme = (): Theme => { + const date = new Date(); + + const theme = Object.entries(THEMES).find(([, { month, day, durationInDays }]) => { + const startDate = new Date(date.getFullYear(), month, day); + const endDate = new Date(date.getFullYear(), month, day + durationInDays); + + return startDate <= date && date <= endDate; + }); + + return theme ? (theme[0] as Theme) : 'default'; +}; + +export const getLogo = (autoTheme: boolean) => { + if (!autoTheme) { + return '/tipi.png'; + } + + const theme = getAutoTheme(); + + switch (theme) { + case 'christmas': + return '/tipi-christmas.png'; + default: + return '/tipi.png'; + } +}; diff --git a/src/server/core/TipiConfig/TipiConfig.ts b/src/server/core/TipiConfig/TipiConfig.ts index 792385e48054b860a0d2311c65603d5e3af9494b..d6a5fbd152e78fb4ff43a1e1f13b23f819db2248 100644 --- a/src/server/core/TipiConfig/TipiConfig.ts +++ b/src/server/core/TipiConfig/TipiConfig.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { envSchema, settingsSchema } from '@runtipi/shared'; +import { envSchema, envStringToMap, settingsSchema } from '@runtipi/shared'; import fs from 'fs-extra'; import nextConfig from 'next/config'; import { readJsonFile } from '../../common/fs.helpers'; @@ -19,13 +19,22 @@ export class TipiConfig { private config: z.infer = {} as z.infer; constructor() { - const conf = { ...process.env, ...nextConfig()?.serverRuntimeConfig }; - const envConfig: z.infer = { + let envFile = ''; + try { + envFile = fs.readFileSync('/runtipi/.env').toString(); + } catch (e) { + Logger.error('❌ .env file not found'); + } + + const envMap = envStringToMap(envFile.toString()); + + const conf = { ...process.env, ...Object.fromEntries(envMap), ...nextConfig().serverRuntimeConfig }; + const envConfig: z.input = { postgresHost: conf.POSTGRES_HOST, postgresDatabase: conf.POSTGRES_DBNAME, postgresUsername: conf.POSTGRES_USERNAME, postgresPassword: conf.POSTGRES_PASSWORD, - postgresPort: Number(conf.POSTGRES_PORT || 5432), + postgresPort: Number(conf.POSTGRES_PORT), REDIS_HOST: conf.REDIS_HOST, redisPassword: conf.REDIS_PASSWORD, NODE_ENV: conf.NODE_ENV, @@ -39,11 +48,11 @@ export class TipiConfig { domain: conf.DOMAIN, localDomain: conf.LOCAL_DOMAIN, dnsIp: conf.DNS_IP || '9.9.9.9', - status: 'RUNNING', storagePath: conf.STORAGE_PATH, demoMode: conf.DEMO_MODE, guestDashboard: conf.GUEST_DASHBOARD, seePreReleaseVersions: false, + allowAutoThemes: true, }; const parsedConfig = envSchema.safeParse({ ...envConfig, ...this.getFileConfig() }); @@ -76,7 +85,14 @@ export class TipiConfig { } public getConfig() { - return { ...this.config, ...this.getFileConfig() }; + let conf = { ...this.config, ...this.getFileConfig() }; + + // If we are not in test mode, we need to set the postgres port to 5432 (internal port) + if (conf.NODE_ENV !== 'test') { + conf = { ...conf, postgresPort: 5432 }; + } + + return conf; } public getSettings() { diff --git a/src/server/run-migrations-dev.ts b/src/server/run-migrations-dev.ts index ae6646dc55387548d60043810e1be5572e3155d4..6567221ae4084ea0e466c589e15d6b81b9e19414 100644 --- a/src/server/run-migrations-dev.ts +++ b/src/server/run-migrations-dev.ts @@ -17,7 +17,7 @@ export const runPostgresMigrations = async (dbName?: string) => { host: postgresHost, database: dbName || postgresDatabase, password: postgresPassword, - port: Number(postgresPort), + port: Number(process.env.POSTGRES_PORT), }); await client.connect(); @@ -36,11 +36,11 @@ export const runPostgresMigrations = async (dbName?: string) => { Logger.info('Running migrations'); try { - await migrate({ client }, path.join(__dirname, '../../packages/cli/assets/migrations'), { skipCreateMigrationTable: true }); + await migrate({ client }, path.join(__dirname, '../../packages/worker/assets/migrations'), { skipCreateMigrationTable: true }); } catch (e) { Logger.error('Error running migrations. Dropping table migrations and trying again'); await client.query('DROP TABLE migrations'); - await migrate({ client }, path.join(__dirname, '../../packages/cli/assets/migrations'), { skipCreateMigrationTable: true }); + await migrate({ client }, path.join(__dirname, '../../packages/worker/assets/migrations'), { skipCreateMigrationTable: true }); } Logger.info('Migration complete'); diff --git a/src/server/services/apps/apps.service.test.ts b/src/server/services/apps/apps.service.test.ts index 5e86d4854b9b57b0696c1dedac3eca152896c19e..87d4da4a8b4d32d5016366bda568de5397b08c54 100644 --- a/src/server/services/apps/apps.service.test.ts +++ b/src/server/services/apps/apps.service.test.ts @@ -351,6 +351,21 @@ describe('Update app config', () => { }); }); +describe('Reset app', () => { + it('Should correctly reset app', async () => { + // arrange + const appConfig = createAppConfig({}); + await insertApp({ status: 'running' }, appConfig, db); + + // act + await AppsService.resetApp(appConfig.id); + const app = await getAppById(appConfig.id, db); + + // assert + expect(app?.status).toBe('running'); + }); +}); + describe('Get app config', () => { it('Should correctly get app config', async () => { // arrange @@ -462,7 +477,7 @@ describe('Update app', () => { it('Should correctly update app', async () => { // arrange const appConfig = createAppConfig({}); - await insertApp({ version: 12, config: { TEST_FIELD: 'test' } }, appConfig, db); + await insertApp({ version: 12, status: 'running', config: { TEST_FIELD: 'test' } }, appConfig, db); // act await updateApp(appConfig.id, { version: 0 }, db); @@ -472,7 +487,7 @@ describe('Update app', () => { expect(app).toBeDefined(); expect(app?.config).toStrictEqual({ TEST_FIELD: 'test' }); expect(app?.version).toBe(appConfig.tipi_version); - expect(app?.status).toBe('stopped'); + expect(app?.status).toBe('running'); }); it("Should throw if app doesn't exist", async () => { @@ -490,6 +505,16 @@ describe('Update app', () => { const app = await getAppById(appConfig.id, db); expect(app?.status).toBe('stopped'); }); + it('Should comme back to the previous status before the update of the app', async () => { + // arrange + const appConfig = createAppConfig({}); + await insertApp({ status: 'stopped' }, appConfig, db); + + // act & assert + await updateApp(appConfig.id, { version: 0 }, db); + const app = await getAppById(appConfig.id, db); + expect(app?.status).toBe('stopped'); + }); }); describe('installedApps', () => { diff --git a/src/server/services/apps/apps.service.ts b/src/server/services/apps/apps.service.ts index 62e741b8400a0379ea981a77f9ded3f530ddee21..be0da68ed7f666978ab7ff89e421c525192a32cf 100644 --- a/src/server/services/apps/apps.service.ts +++ b/src/server/services/apps/apps.service.ts @@ -341,6 +341,7 @@ export class AppServiceClass { */ public updateApp = async (id: string) => { const app = await this.queries.getApp(id); + const appStatusBeforeUpdate = app?.status; if (!app) { throw new TranslatedError('server-messages.errors.app-not-found', { id }); @@ -355,17 +356,36 @@ export class AppServiceClass { if (success) { const appInfo = getAppInfo(app.id, app.status); - await this.queries.updateApp(id, { status: 'running', version: appInfo?.tipi_version }); + await this.queries.updateApp(id, { version: appInfo?.tipi_version }); + if (appStatusBeforeUpdate === 'running') { + await this.startApp(id); + } else { + await this.queries.updateApp(id, { status: appStatusBeforeUpdate }); + } } else { await this.queries.updateApp(id, { status: 'stopped' }); Logger.error(`Failed to update app ${id}: ${stdout}`); throw new TranslatedError('server-messages.errors.app-failed-to-update', { id }); } - const updatedApp = await this.queries.updateApp(id, { status: 'stopped' }); + const updatedApp = await this.getApp(id); return updatedApp; }; + /** + * Reset App with the specified ID + * + * @param {string} id - ID of the app to reset + * @throws {Error} - If the app is not found or if the update process fails. + */ + public resetApp = async (id: string) => { + const appInfo = await this.getApp(id); + + await this.stopApp(id); + await this.uninstallApp(id); + await this.installApp(id, castAppConfig(appInfo.config)); + }; + /** * Returns a list of all installed apps */ diff --git a/src/server/services/system/system.service.test.ts b/src/server/services/system/system.service.test.ts index 0cef577a88c1b6093ee7601ff30c1faf77f14ce7..ea030ede140884d1c9e54dbd7f637ce8c3168dab 100644 --- a/src/server/services/system/system.service.test.ts +++ b/src/server/services/system/system.service.test.ts @@ -106,21 +106,3 @@ describe('Test: getVersion', () => { expect(version2.current).toBeDefined(); }); }); - -describe('Test: restart', () => { - it('Should return true', async () => { - // Act - const restart = await SystemService.restart(); - - // Assert - expect(restart).toBeTruthy(); - }); - - it('should throw an error in demo mode', async () => { - // Arrange - await setConfig('demoMode', true); - - // Act & Assert - await expect(SystemService.restart()).rejects.toThrow('server-messages.errors.not-allowed-in-demo'); - }); -}); diff --git a/src/server/services/system/system.service.ts b/src/server/services/system/system.service.ts index 813876fdcd65cefd8c1477fa4e98cd57c8be1d8e..e125f25bddbec24c63643914351d81706fab1a5e 100644 --- a/src/server/services/system/system.service.ts +++ b/src/server/services/system/system.service.ts @@ -1,15 +1,10 @@ import { z } from 'zod'; import axios from 'redaxios'; -import { TranslatedError } from '@/server/utils/errors'; -import { EventDispatcher } from '@/server/core/EventDispatcher'; import { TipiCache } from '@/server/core/TipiCache'; import { readJsonFile } from '../../common/fs.helpers'; import { Logger } from '../../core/Logger'; import { getConfig } from '../../core/TipiConfig'; -const SYSTEM_STATUS = ['UPDATING', 'RESTARTING', 'RUNNING'] as const; -type SystemStatus = (typeof SYSTEM_STATUS)[keyof typeof SYSTEM_STATUS]; - const systemInfoSchema = z.object({ cpu: z.object({ load: z.number().default(0), @@ -74,28 +69,4 @@ export class SystemServiceClass { return info.data; }; - - public restart = async (): Promise => { - if (getConfig().NODE_ENV === 'development') { - throw new TranslatedError('server-messages.errors.not-allowed-in-dev'); - } - - if (getConfig().demoMode) { - throw new TranslatedError('server-messages.errors.not-allowed-in-demo'); - } - - const cache = new TipiCache('restart'); - await cache.set('status', 'RESTARTING', 360); - await cache.close(); - - const dispatcher = new EventDispatcher('restart'); - dispatcher.dispatchEvent({ type: 'system', command: 'restart' }); - await dispatcher.close(); - - return true; - }; - - public static status = (): { status: SystemStatus } => ({ - status: getConfig().status, - }); }