diff --git a/.dockerignore b/.dockerignore
index d1f60fa477..dfb29c07b3 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,5 +1,5 @@
 .vscode/
-cli/
+
 design/
 docker/
 docs/
@@ -18,3 +18,8 @@ web/node_modules/
 web/coverage/
 web/.svelte-kit
 web/build/
+
+cli/node_modules
+cli/.reverse-geocoding-dump/
+cli/upload/
+cli/dist/
\ No newline at end of file
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index b2ca57e13a..1fd935ea3d 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -21,7 +21,7 @@ jobs:
           submodules: "recursive"
 
       - name: Run e2e tests
-        run: docker compose -f ./docker/docker-compose.test.yml up --renew-anon-volumes --abort-on-container-exit --exit-code-from immich-server --remove-orphans --build
+        run: make test-server-e2e
 
   doc-tests:
     name: Docs
@@ -90,9 +90,13 @@ jobs:
       - name: Checkout code
         uses: actions/checkout@v4
 
-      - name: Run npm install
+      - name: Run npm install in cli
         run: npm ci
 
+      - name: Run npm install in server
+        run: npm ci
+        working-directory: ./server
+
       - name: Run linter
         run: npm run lint
         if: ${{ !cancelled() }}
@@ -109,6 +113,29 @@ jobs:
         run: npm run test:cov
         if: ${{ !cancelled() }}
 
+  cli-e2e-tests:
+    name: CLI (e2e)
+    runs-on: ubuntu-latest
+    defaults:
+      run:
+        working-directory: ./cli
+
+    steps:
+      - name: Checkout code
+        uses: actions/checkout@v4
+        with:
+          submodules: "recursive"
+
+      - name: Run npm install in cli
+        run: npm ci
+
+      - name: Run npm install in server
+        run: npm ci
+        working-directory: ./server
+
+      - name: Run e2e tests
+        run: npm run test:e2e
+
   web-unit-tests:
     name: Web
     runs-on: ubuntu-latest
diff --git a/Makefile b/Makefile
index 73b922ee80..699a0f7f1a 100644
--- a/Makefile
+++ b/Makefile
@@ -16,8 +16,8 @@ stage:
 pull-stage:
 	docker compose -f ./docker/docker-compose.staging.yml pull
 
-test-e2e:
-	docker compose -f ./docker/docker-compose.test.yml up --renew-anon-volumes --abort-on-container-exit --exit-code-from immich-server --remove-orphans --build
+test-server-e2e:
+	docker compose -f ./server/test/docker-compose.server-e2e.yml up --renew-anon-volumes --abort-on-container-exit --exit-code-from immich-server --remove-orphans --build
 
 prod:
 	docker compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans
diff --git a/cli/.gitignore b/cli/.gitignore
index f1265e47f3..a26b03fe6f 100644
--- a/cli/.gitignore
+++ b/cli/.gitignore
@@ -10,4 +10,6 @@ oclif.manifest.json
 
 .vscode
 .idea
-/coverage/
\ No newline at end of file
+/coverage/
+.reverse-geocoding-dump/
+upload/
\ No newline at end of file
diff --git a/cli/.npmignore b/cli/.npmignore
index e001747ce0..1d0d005a94 100644
--- a/cli/.npmignore
+++ b/cli/.npmignore
@@ -1,4 +1,6 @@
 **/*.spec.js
+test/**
+upload/**
 .editorconfig
 .eslintignore
 .eslintrc.js
diff --git a/cli/Dockerfile b/cli/Dockerfile
new file mode 100644
index 0000000000..a18d7ded70
--- /dev/null
+++ b/cli/Dockerfile
@@ -0,0 +1,19 @@
+FROM ghcr.io/immich-app/base-server-dev:20231109 as test
+
+WORKDIR /usr/src/app/server
+COPY server/package.json server/package-lock.json ./
+RUN npm ci
+COPY ./server/ .
+
+WORKDIR /usr/src/app/cli
+COPY cli/package.json cli/package-lock.json ./
+RUN npm ci
+COPY ./cli/ .
+
+FROM ghcr.io/immich-app/base-server-prod:20231109
+
+VOLUME /usr/src/app/upload
+
+EXPOSE 3001
+
+ENTRYPOINT ["tini", "--", "/bin/sh"]
diff --git a/cli/package-lock.json b/cli/package-lock.json
index a855a2d8ea..df0cd9eeb3 100644
--- a/cli/package-lock.json
+++ b/cli/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "@immich/cli",
-  "version": "2.0.4",
+  "version": "2.0.5",
   "lockfileVersion": 2,
   "requires": true,
   "packages": {
     "": {
       "name": "@immich/cli",
-      "version": "2.0.4",
+      "version": "2.0.5",
       "license": "MIT",
       "dependencies": {
         "axios": "^1.6.2",
@@ -21,6 +21,7 @@
         "immich": "dist/src/index.js"
       },
       "devDependencies": {
+        "@testcontainers/postgresql": "^10.4.0",
         "@types/byte-size": "^8.1.0",
         "@types/chai": "^4.3.5",
         "@types/cli-progress": "^3.11.0",
@@ -37,6 +38,7 @@
         "eslint-plugin-jest": "^27.2.2",
         "eslint-plugin-prettier": "^5.0.0",
         "eslint-plugin-unicorn": "^49.0.0",
+        "immich": "file:../server",
         "jest": "^29.5.0",
         "jest-extended": "^4.0.0",
         "jest-message-util": "^29.5.0",
@@ -49,6 +51,104 @@
         "typescript": "^5.0.0"
       }
     },
+    "../server": {
+      "name": "immich",
+      "version": "1.91.3",
+      "dev": true,
+      "license": "UNLICENSED",
+      "dependencies": {
+        "@babel/runtime": "^7.22.11",
+        "@immich/cli": "^2.0.3",
+        "@nestjs/bullmq": "^10.0.1",
+        "@nestjs/common": "^10.2.2",
+        "@nestjs/config": "^3.0.0",
+        "@nestjs/core": "^10.2.2",
+        "@nestjs/platform-express": "^10.2.2",
+        "@nestjs/platform-socket.io": "^10.2.2",
+        "@nestjs/schedule": "^3.0.3",
+        "@nestjs/swagger": "^7.1.8",
+        "@nestjs/typeorm": "^10.0.0",
+        "@nestjs/websockets": "^10.2.2",
+        "@socket.io/postgres-adapter": "^0.3.1",
+        "archiver": "^6.0.0",
+        "async-lock": "^1.4.0",
+        "axios": "^1.5.0",
+        "bcrypt": "^5.1.1",
+        "bullmq": "^4.8.0",
+        "class-transformer": "^0.5.1",
+        "class-validator": "^0.14.0",
+        "cookie-parser": "^1.4.6",
+        "exiftool-vendored": "~23.5.0",
+        "exiftool-vendored.pl": "12.70",
+        "fluent-ffmpeg": "^2.1.2",
+        "geo-tz": "^7.0.7",
+        "glob": "^10.3.3",
+        "handlebars": "^4.7.8",
+        "i18n-iso-countries": "^7.6.0",
+        "ioredis": "^5.3.2",
+        "joi": "^17.10.0",
+        "lodash": "^4.17.21",
+        "luxon": "^3.4.2",
+        "mv": "^2.1.1",
+        "nest-commander": "^3.11.1",
+        "node-addon-api": "^7.0.0",
+        "openid-client": "^5.4.3",
+        "pg": "^8.11.3",
+        "reflect-metadata": "^0.1.13",
+        "rxjs": "^7.8.1",
+        "sanitize-filename": "^1.6.3",
+        "sharp": "^0.33.0",
+        "thumbhash": "^0.1.1",
+        "typeorm": "^0.3.17",
+        "ua-parser-js": "^1.0.35"
+      },
+      "devDependencies": {
+        "@nestjs/cli": "^10.1.16",
+        "@nestjs/schematics": "^10.0.2",
+        "@nestjs/testing": "^10.2.2",
+        "@openapitools/openapi-generator-cli": "2.7.0",
+        "@testcontainers/postgresql": "^10.2.1",
+        "@types/archiver": "^6.0.0",
+        "@types/async-lock": "^1.4.2",
+        "@types/bcrypt": "^5.0.0",
+        "@types/cookie-parser": "^1.4.3",
+        "@types/express": "^4.17.17",
+        "@types/fluent-ffmpeg": "^2.1.21",
+        "@types/imagemin": "^8.0.1",
+        "@types/jest": "29.5.10",
+        "@types/jest-when": "^3.5.2",
+        "@types/lodash": "^4.14.197",
+        "@types/mock-fs": "^4.13.1",
+        "@types/multer": "^1.4.7",
+        "@types/mv": "^2.1.2",
+        "@types/node": "^20.5.7",
+        "@types/sharp": "^0.31.1",
+        "@types/supertest": "^2.0.12",
+        "@types/ua-parser-js": "^0.7.36",
+        "@typescript-eslint/eslint-plugin": "^6.4.1",
+        "@typescript-eslint/parser": "^6.4.1",
+        "dotenv": "^16.3.1",
+        "eslint": "^8.48.0",
+        "eslint-config-prettier": "^9.0.0",
+        "eslint-plugin-prettier": "^5.0.0",
+        "jest": "^29.6.4",
+        "jest-when": "^3.6.0",
+        "mock-fs": "^5.2.0",
+        "prettier": "^3.0.2",
+        "prettier-plugin-organize-imports": "^3.2.3",
+        "rimraf": "^5.0.1",
+        "source-map-support": "^0.5.21",
+        "sql-formatter": "^15.0.0",
+        "supertest": "^6.3.3",
+        "testcontainers": "^10.2.1",
+        "ts-jest": "^29.1.1",
+        "ts-loader": "^9.4.4",
+        "ts-node": "^10.9.1",
+        "tsconfig-paths": "^4.2.0",
+        "typescript": "^5.2.2",
+        "utimes": "^5.2.1"
+      }
+    },
     "node_modules/@aashutoshrathi/word-wrap": {
       "version": "1.2.6",
       "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz",
@@ -726,6 +826,12 @@
         "node": ">=6.9.0"
       }
     },
+    "node_modules/@balena/dockerignore": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/@balena/dockerignore/-/dockerignore-1.0.2.tgz",
+      "integrity": "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==",
+      "dev": true
+    },
     "node_modules/@bcoe/v8-coverage": {
       "version": "0.2.3",
       "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz",
@@ -1447,6 +1553,15 @@
         "@sinonjs/commons": "^3.0.0"
       }
     },
+    "node_modules/@testcontainers/postgresql": {
+      "version": "10.4.0",
+      "resolved": "https://registry.npmjs.org/@testcontainers/postgresql/-/postgresql-10.4.0.tgz",
+      "integrity": "sha512-d+eJBDQlQE6+PVoWlpX4YpUoDdogoNkW0ag33qt/oOknmqNjQj6c3/mCzThsS59qtS0vPXydwbHiOkpNuCXFFg==",
+      "dev": true,
+      "dependencies": {
+        "testcontainers": "^10.4.0"
+      }
+    },
     "node_modules/@tsconfig/node10": {
       "version": "1.0.9",
       "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz",
@@ -1519,9 +1634,9 @@
       "dev": true
     },
     "node_modules/@types/chai": {
-      "version": "4.3.11",
-      "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.11.tgz",
-      "integrity": "sha512-qQR1dr2rGIHYlJulmr8Ioq3De0Le9E4MJ5AiaeAETJJpndT1uUNHsGFK3L/UIu+rbkQSdj8J/w2bCsBZc/Y5fQ==",
+      "version": "4.3.10",
+      "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.10.tgz",
+      "integrity": "sha512-of+ICnbqjmFCiixUnqRulbylyXQrPqIGf/B3Jax1wIF3DvSheysQxAWvqHhZiW3IQrycvokcLcFQlveGp+vyNg==",
       "dev": true
     },
     "node_modules/@types/cli-progress": {
@@ -1533,6 +1648,26 @@
         "@types/node": "*"
       }
     },
+    "node_modules/@types/docker-modem": {
+      "version": "3.0.6",
+      "resolved": "https://registry.npmjs.org/@types/docker-modem/-/docker-modem-3.0.6.tgz",
+      "integrity": "sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg==",
+      "dev": true,
+      "dependencies": {
+        "@types/node": "*",
+        "@types/ssh2": "*"
+      }
+    },
+    "node_modules/@types/dockerode": {
+      "version": "3.3.23",
+      "resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-3.3.23.tgz",
+      "integrity": "sha512-Lz5J+NFgZS4cEVhquwjIGH4oQwlVn2h7LXD3boitujBnzOE5o7s9H8hchEjoDK2SlRsJTogdKnQeiJgPPKLIEw==",
+      "dev": true,
+      "dependencies": {
+        "@types/docker-modem": "*",
+        "@types/node": "*"
+      }
+    },
     "node_modules/@types/graceful-fs": {
       "version": "4.1.7",
       "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.7.tgz",
@@ -1567,9 +1702,9 @@
       }
     },
     "node_modules/@types/jest": {
-      "version": "29.5.10",
-      "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.10.tgz",
-      "integrity": "sha512-tE4yxKEphEyxj9s4inideLHktW/x6DwesIwWZ9NN1FKf9zbJYsnhBoA9vrHA/IuIOKwPa5PcFBNV4lpMIOEzyQ==",
+      "version": "29.5.8",
+      "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.8.tgz",
+      "integrity": "sha512-fXEFTxMV2Co8ZF5aYFJv+YeA08RTYJfhtN5c9JSv/mFEMe+xxjufCb+PHL+bJcMs/ebPUsBu+UNTEz+ydXrR6g==",
       "dev": true,
       "dependencies": {
         "expect": "^29.0.0",
@@ -1624,6 +1759,33 @@
       "integrity": "sha512-OxepLK9EuNEIPxWNME+C6WwbRAOOI2o2BaQEGzz5Lu2e4Z5eDnEo+/aVEDMIXywoJitJ7xWd641wrGLZdtwRyw==",
       "dev": true
     },
+    "node_modules/@types/ssh2": {
+      "version": "1.11.18",
+      "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.11.18.tgz",
+      "integrity": "sha512-7eH4ppQMFlzvn//zhwD54MWaITR1aSc1oFBye9vb76GZ2Y9PSFYdwVIwOlxRXWs5+1hifntXyt+8a6SUbOD7Hg==",
+      "dev": true,
+      "dependencies": {
+        "@types/node": "^18.11.18"
+      }
+    },
+    "node_modules/@types/ssh2-streams": {
+      "version": "0.1.12",
+      "resolved": "https://registry.npmjs.org/@types/ssh2-streams/-/ssh2-streams-0.1.12.tgz",
+      "integrity": "sha512-Sy8tpEmCce4Tq0oSOYdfqaBpA3hDM8SoxoFh5vzFsu2oL+znzGz8oVWW7xb4K920yYMUY+PIG31qZnFMfPWNCg==",
+      "dev": true,
+      "dependencies": {
+        "@types/node": "*"
+      }
+    },
+    "node_modules/@types/ssh2/node_modules/@types/node": {
+      "version": "18.19.3",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.3.tgz",
+      "integrity": "sha512-k5fggr14DwAytoA/t8rPrIz++lXK7/DqckthCmoZOKNsEbJkId4Z//BqgApXBUGrGddrigYa1oqheo/7YmW4rg==",
+      "dev": true,
+      "dependencies": {
+        "undici-types": "~5.26.4"
+      }
+    },
     "node_modules/@types/stack-utils": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz",
@@ -2221,6 +2383,95 @@
         "node": ">= 8"
       }
     },
+    "node_modules/archiver": {
+      "version": "5.3.2",
+      "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz",
+      "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==",
+      "dev": true,
+      "dependencies": {
+        "archiver-utils": "^2.1.0",
+        "async": "^3.2.4",
+        "buffer-crc32": "^0.2.1",
+        "readable-stream": "^3.6.0",
+        "readdir-glob": "^1.1.2",
+        "tar-stream": "^2.2.0",
+        "zip-stream": "^4.1.0"
+      },
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/archiver-utils": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz",
+      "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==",
+      "dev": true,
+      "dependencies": {
+        "glob": "^7.1.4",
+        "graceful-fs": "^4.2.0",
+        "lazystream": "^1.0.0",
+        "lodash.defaults": "^4.2.0",
+        "lodash.difference": "^4.5.0",
+        "lodash.flatten": "^4.4.0",
+        "lodash.isplainobject": "^4.0.6",
+        "lodash.union": "^4.6.0",
+        "normalize-path": "^3.0.0",
+        "readable-stream": "^2.0.0"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/archiver-utils/node_modules/glob": {
+      "version": "7.2.3",
+      "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
+      "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
+      "dev": true,
+      "dependencies": {
+        "fs.realpath": "^1.0.0",
+        "inflight": "^1.0.4",
+        "inherits": "2",
+        "minimatch": "^3.1.1",
+        "once": "^1.3.0",
+        "path-is-absolute": "^1.0.0"
+      },
+      "engines": {
+        "node": "*"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/archiver-utils/node_modules/readable-stream": {
+      "version": "2.3.8",
+      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
+      "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+      "dev": true,
+      "dependencies": {
+        "core-util-is": "~1.0.0",
+        "inherits": "~2.0.3",
+        "isarray": "~1.0.0",
+        "process-nextick-args": "~2.0.0",
+        "safe-buffer": "~5.1.1",
+        "string_decoder": "~1.1.1",
+        "util-deprecate": "~1.0.1"
+      }
+    },
+    "node_modules/archiver-utils/node_modules/safe-buffer": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+      "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+      "dev": true
+    },
+    "node_modules/archiver-utils/node_modules/string_decoder": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+      "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+      "dev": true,
+      "dependencies": {
+        "safe-buffer": "~5.1.0"
+      }
+    },
     "node_modules/arg": {
       "version": "4.1.3",
       "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
@@ -2242,6 +2493,15 @@
         "node": ">=8"
       }
     },
+    "node_modules/asn1": {
+      "version": "0.2.6",
+      "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
+      "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
+      "dev": true,
+      "dependencies": {
+        "safer-buffer": "~2.1.0"
+      }
+    },
     "node_modules/assertion-error": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz",
@@ -2251,6 +2511,18 @@
         "node": "*"
       }
     },
+    "node_modules/async": {
+      "version": "3.2.5",
+      "resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz",
+      "integrity": "sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==",
+      "dev": true
+    },
+    "node_modules/async-lock": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.0.tgz",
+      "integrity": "sha512-coglx5yIWuetakm3/1dsX9hxCNox22h7+V80RQOu2XUUMidtArxKoZoOtHUPuR84SycKTXzgGzAUR5hJxujyJQ==",
+      "dev": true
+    },
     "node_modules/asynckit": {
       "version": "0.4.0",
       "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -2266,6 +2538,12 @@
         "proxy-from-env": "^1.1.0"
       }
     },
+    "node_modules/b4a": {
+      "version": "1.6.4",
+      "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.4.tgz",
+      "integrity": "sha512-fpWrvyVHEKyeEvbKZTVOeZF3VSKKWtJxFIxX/jaVPf+cLbGUSitjb49pHLqPV2BUNNZ0LcoeEGfE/YCpyDYHIw==",
+      "dev": true
+    },
     "node_modules/babel-jest": {
       "version": "29.7.0",
       "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
@@ -2387,6 +2665,35 @@
       "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
       "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
     },
+    "node_modules/base64-js": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+      "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ]
+    },
+    "node_modules/bcrypt-pbkdf": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
+      "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
+      "dev": true,
+      "dependencies": {
+        "tweetnacl": "^0.14.3"
+      }
+    },
     "node_modules/big-integer": {
       "version": "1.6.52",
       "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz",
@@ -2396,6 +2703,17 @@
         "node": ">=0.6"
       }
     },
+    "node_modules/bl": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
+      "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
+      "dev": true,
+      "dependencies": {
+        "buffer": "^5.5.0",
+        "inherits": "^2.0.4",
+        "readable-stream": "^3.4.0"
+      }
+    },
     "node_modules/bplist-parser": {
       "version": "0.2.0",
       "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.2.0.tgz",
@@ -2483,12 +2801,55 @@
         "node-int64": "^0.4.0"
       }
     },
+    "node_modules/buffer": {
+      "version": "5.7.1",
+      "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
+      "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "dependencies": {
+        "base64-js": "^1.3.1",
+        "ieee754": "^1.1.13"
+      }
+    },
+    "node_modules/buffer-crc32": {
+      "version": "0.2.13",
+      "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
+      "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
+      "dev": true,
+      "engines": {
+        "node": "*"
+      }
+    },
     "node_modules/buffer-from": {
       "version": "1.1.2",
       "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
       "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
       "dev": true
     },
+    "node_modules/buildcheck": {
+      "version": "0.0.6",
+      "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.6.tgz",
+      "integrity": "sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A==",
+      "dev": true,
+      "optional": true,
+      "engines": {
+        "node": ">=10.0.0"
+      }
+    },
     "node_modules/builtin-modules": {
       "version": "3.3.0",
       "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz",
@@ -2516,6 +2877,15 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/byline": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/byline/-/byline-5.0.0.tgz",
+      "integrity": "sha512-s6webAy+R4SR8XVuJWt2V2rGvhnrhxN+9S15GNuTK3wKPOXFF6RNc+8ug2XhH+2s4f+uudG4kUVYmYOQWL2g0Q==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
     "node_modules/byte-size": {
       "version": "8.1.1",
       "resolved": "https://registry.npmjs.org/byte-size/-/byte-size-8.1.1.tgz",
@@ -2617,6 +2987,12 @@
         "node": "*"
       }
     },
+    "node_modules/chownr": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+      "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
+      "dev": true
+    },
     "node_modules/ci-info": {
       "version": "3.8.0",
       "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.8.0.tgz",
@@ -2752,6 +3128,21 @@
         "node": ">=16"
       }
     },
+    "node_modules/compress-commons": {
+      "version": "4.1.2",
+      "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz",
+      "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==",
+      "dev": true,
+      "dependencies": {
+        "buffer-crc32": "^0.2.13",
+        "crc32-stream": "^4.0.2",
+        "normalize-path": "^3.0.0",
+        "readable-stream": "^3.6.0"
+      },
+      "engines": {
+        "node": ">= 10"
+      }
+    },
     "node_modules/concat-map": {
       "version": "0.0.1",
       "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -2764,6 +3155,52 @@
       "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
       "dev": true
     },
+    "node_modules/core-util-is": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
+      "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
+      "dev": true
+    },
+    "node_modules/cpu-features": {
+      "version": "0.0.9",
+      "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.9.tgz",
+      "integrity": "sha512-AKjgn2rP2yJyfbepsmLfiYcmtNn/2eUvocUyM/09yB0YDiz39HteK/5/T4Onf0pmdYDMgkBoGvRLvEguzyL7wQ==",
+      "dev": true,
+      "hasInstallScript": true,
+      "optional": true,
+      "dependencies": {
+        "buildcheck": "~0.0.6",
+        "nan": "^2.17.0"
+      },
+      "engines": {
+        "node": ">=10.0.0"
+      }
+    },
+    "node_modules/crc-32": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
+      "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
+      "dev": true,
+      "bin": {
+        "crc32": "bin/crc32.njs"
+      },
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
+    "node_modules/crc32-stream": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz",
+      "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==",
+      "dev": true,
+      "dependencies": {
+        "crc-32": "^1.2.0",
+        "readable-stream": "^3.4.0"
+      },
+      "engines": {
+        "node": ">= 10"
+      }
+    },
     "node_modules/create-jest": {
       "version": "29.7.0",
       "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz",
@@ -3071,6 +3508,59 @@
         "node": ">=8"
       }
     },
+    "node_modules/docker-compose": {
+      "version": "0.24.3",
+      "resolved": "https://registry.npmjs.org/docker-compose/-/docker-compose-0.24.3.tgz",
+      "integrity": "sha512-x3/QN3AIOMe7j2c8f/jcycizMft7dl8MluoB9OGPAYCyKHHiPUFqI9GjCcsU0kYy24vYKMCcfR6+5ZaEyQlrxg==",
+      "dev": true,
+      "dependencies": {
+        "yaml": "^2.2.2"
+      },
+      "engines": {
+        "node": ">= 6.0.0"
+      }
+    },
+    "node_modules/docker-modem": {
+      "version": "3.0.8",
+      "resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-3.0.8.tgz",
+      "integrity": "sha512-f0ReSURdM3pcKPNS30mxOHSbaFLcknGmQjwSfmbcdOw1XWKXVhukM3NJHhr7NpY9BIyyWQb0EBo3KQvvuU5egQ==",
+      "dev": true,
+      "dependencies": {
+        "debug": "^4.1.1",
+        "readable-stream": "^3.5.0",
+        "split-ca": "^1.0.1",
+        "ssh2": "^1.11.0"
+      },
+      "engines": {
+        "node": ">= 8.0"
+      }
+    },
+    "node_modules/dockerode": {
+      "version": "3.3.5",
+      "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-3.3.5.tgz",
+      "integrity": "sha512-/0YNa3ZDNeLr/tSckmD69+Gq+qVNhvKfAHNeZJBnp7EOP6RGKV8ORrJHkUn20So5wU+xxT7+1n5u8PjHbfjbSA==",
+      "dev": true,
+      "dependencies": {
+        "@balena/dockerignore": "^1.0.2",
+        "docker-modem": "^3.0.0",
+        "tar-fs": "~2.0.1"
+      },
+      "engines": {
+        "node": ">= 8.0"
+      }
+    },
+    "node_modules/dockerode/node_modules/tar-fs": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.0.1.tgz",
+      "integrity": "sha512-6tzWDMeroL87uF/+lin46k+Q+46rAJ0SyPGz7OW7wTgblI273hsBqk2C1j0/xNadNLKDTUL9BukSjB7cwgmlPA==",
+      "dev": true,
+      "dependencies": {
+        "chownr": "^1.1.1",
+        "mkdirp-classic": "^0.5.2",
+        "pump": "^3.0.0",
+        "tar-stream": "^2.0.0"
+      }
+    },
     "node_modules/doctrine": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
@@ -3111,6 +3601,15 @@
       "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
       "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
     },
+    "node_modules/end-of-stream": {
+      "version": "1.4.4",
+      "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
+      "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==",
+      "dev": true,
+      "dependencies": {
+        "once": "^1.4.0"
+      }
+    },
     "node_modules/error-ex": {
       "version": "1.3.2",
       "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
@@ -3499,6 +3998,12 @@
       "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
       "dev": true
     },
+    "node_modules/fast-fifo": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
+      "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
+      "dev": true
+    },
     "node_modules/fast-glob": {
       "version": "3.3.1",
       "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
@@ -3664,6 +4169,12 @@
         "node": ">= 6"
       }
     },
+    "node_modules/fs-constants": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
+      "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
+      "dev": true
+    },
     "node_modules/fs.realpath": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@@ -3726,6 +4237,18 @@
         "node": ">=8.0.0"
       }
     },
+    "node_modules/get-port": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz",
+      "integrity": "sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
     "node_modules/get-stream": {
       "version": "6.0.1",
       "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
@@ -3882,6 +4405,26 @@
         "node": ">=10.17.0"
       }
     },
+    "node_modules/ieee754": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+      "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ]
+    },
     "node_modules/ignore": {
       "version": "5.2.4",
       "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz",
@@ -3891,6 +4434,10 @@
         "node": ">= 4"
       }
     },
+    "node_modules/immich": {
+      "resolved": "../server",
+      "link": true
+    },
     "node_modules/import-fresh": {
       "version": "3.3.0",
       "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
@@ -4121,6 +4668,12 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/isarray": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+      "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+      "dev": true
+    },
     "node_modules/isexe": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -4929,6 +5482,48 @@
         "node": ">=6"
       }
     },
+    "node_modules/lazystream": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz",
+      "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==",
+      "dev": true,
+      "dependencies": {
+        "readable-stream": "^2.0.5"
+      },
+      "engines": {
+        "node": ">= 0.6.3"
+      }
+    },
+    "node_modules/lazystream/node_modules/readable-stream": {
+      "version": "2.3.8",
+      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
+      "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+      "dev": true,
+      "dependencies": {
+        "core-util-is": "~1.0.0",
+        "inherits": "~2.0.3",
+        "isarray": "~1.0.0",
+        "process-nextick-args": "~2.0.0",
+        "safe-buffer": "~5.1.1",
+        "string_decoder": "~1.1.1",
+        "util-deprecate": "~1.0.1"
+      }
+    },
+    "node_modules/lazystream/node_modules/safe-buffer": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+      "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+      "dev": true
+    },
+    "node_modules/lazystream/node_modules/string_decoder": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+      "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+      "dev": true,
+      "dependencies": {
+        "safe-buffer": "~5.1.0"
+      }
+    },
     "node_modules/leven": {
       "version": "3.1.0",
       "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
@@ -4972,6 +5567,30 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/lodash.defaults": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
+      "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
+      "dev": true
+    },
+    "node_modules/lodash.difference": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz",
+      "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==",
+      "dev": true
+    },
+    "node_modules/lodash.flatten": {
+      "version": "4.4.0",
+      "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz",
+      "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==",
+      "dev": true
+    },
+    "node_modules/lodash.isplainobject": {
+      "version": "4.0.6",
+      "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
+      "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
+      "dev": true
+    },
     "node_modules/lodash.memoize": {
       "version": "4.1.2",
       "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
@@ -4984,6 +5603,12 @@
       "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
       "dev": true
     },
+    "node_modules/lodash.union": {
+      "version": "4.6.0",
+      "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz",
+      "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==",
+      "dev": true
+    },
     "node_modules/loupe": {
       "version": "2.3.6",
       "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.6.tgz",
@@ -5117,6 +5742,24 @@
         "node": ">=16 || 14 >=14.17"
       }
     },
+    "node_modules/mkdirp": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
+      "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
+      "dev": true,
+      "bin": {
+        "mkdirp": "bin/cmd.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/mkdirp-classic": {
+      "version": "0.5.3",
+      "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
+      "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
+      "dev": true
+    },
     "node_modules/mock-fs": {
       "version": "5.2.0",
       "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.2.0.tgz",
@@ -5132,12 +5775,39 @@
       "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
       "dev": true
     },
+    "node_modules/nan": {
+      "version": "2.18.0",
+      "resolved": "https://registry.npmjs.org/nan/-/nan-2.18.0.tgz",
+      "integrity": "sha512-W7tfG7vMOGtD30sHoZSSc/JVYiyDPEyQVso/Zz+/uQd0B0L46gtC+pHha5FFMRpil6fm/AoEcRWyOVi4+E/f8w==",
+      "dev": true,
+      "optional": true
+    },
     "node_modules/natural-compare": {
       "version": "1.4.0",
       "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
       "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
       "dev": true
     },
+    "node_modules/node-fetch": {
+      "version": "2.7.0",
+      "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
+      "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
+      "dev": true,
+      "dependencies": {
+        "whatwg-url": "^5.0.0"
+      },
+      "engines": {
+        "node": "4.x || >=6.0.0"
+      },
+      "peerDependencies": {
+        "encoding": "^0.1.0"
+      },
+      "peerDependenciesMeta": {
+        "encoding": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/node-int64": {
       "version": "0.4.0",
       "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
@@ -5556,6 +6226,12 @@
         "url": "https://github.com/chalk/ansi-styles?sponsor=1"
       }
     },
+    "node_modules/process-nextick-args": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+      "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
+      "dev": true
+    },
     "node_modules/prompts": {
       "version": "2.4.2",
       "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
@@ -5569,11 +6245,54 @@
         "node": ">= 6"
       }
     },
+    "node_modules/proper-lockfile": {
+      "version": "4.1.2",
+      "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz",
+      "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==",
+      "dev": true,
+      "dependencies": {
+        "graceful-fs": "^4.2.4",
+        "retry": "^0.12.0",
+        "signal-exit": "^3.0.2"
+      }
+    },
+    "node_modules/proper-lockfile/node_modules/signal-exit": {
+      "version": "3.0.7",
+      "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
+      "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
+      "dev": true
+    },
+    "node_modules/properties-reader": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/properties-reader/-/properties-reader-2.3.0.tgz",
+      "integrity": "sha512-z597WicA7nDZxK12kZqHr2TcvwNU1GCfA5UwfDY/HDp3hXPoPlb5rlEx9bwGTiJnc0OqbBTkU975jDToth8Gxw==",
+      "dev": true,
+      "dependencies": {
+        "mkdirp": "^1.0.4"
+      },
+      "engines": {
+        "node": ">=14"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/steveukx/properties?sponsor=1"
+      }
+    },
     "node_modules/proxy-from-env": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
       "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
     },
+    "node_modules/pump": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz",
+      "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==",
+      "dev": true,
+      "dependencies": {
+        "end-of-stream": "^1.1.0",
+        "once": "^1.3.1"
+      }
+    },
     "node_modules/punycode": {
       "version": "2.3.0",
       "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz",
@@ -5619,6 +6338,12 @@
         }
       ]
     },
+    "node_modules/queue-tick": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/queue-tick/-/queue-tick-1.0.1.tgz",
+      "integrity": "sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==",
+      "dev": true
+    },
     "node_modules/react-is": {
       "version": "18.2.0",
       "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz",
@@ -5727,6 +6452,50 @@
         "node": ">=8"
       }
     },
+    "node_modules/readable-stream": {
+      "version": "3.6.2",
+      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+      "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+      "dev": true,
+      "dependencies": {
+        "inherits": "^2.0.3",
+        "string_decoder": "^1.1.1",
+        "util-deprecate": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/readdir-glob": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz",
+      "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==",
+      "dev": true,
+      "dependencies": {
+        "minimatch": "^5.1.0"
+      }
+    },
+    "node_modules/readdir-glob/node_modules/brace-expansion": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+      "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+      "dev": true,
+      "dependencies": {
+        "balanced-match": "^1.0.0"
+      }
+    },
+    "node_modules/readdir-glob/node_modules/minimatch": {
+      "version": "5.1.6",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
+      "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
+      "dev": true,
+      "dependencies": {
+        "brace-expansion": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
     "node_modules/regexp-tree": {
       "version": "0.1.27",
       "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz",
@@ -5822,6 +6591,15 @@
         "node": ">=10"
       }
     },
+    "node_modules/retry": {
+      "version": "0.12.0",
+      "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
+      "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==",
+      "dev": true,
+      "engines": {
+        "node": ">= 4"
+      }
+    },
     "node_modules/reusify": {
       "version": "1.0.4",
       "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
@@ -5905,6 +6683,32 @@
         "queue-microtask": "^1.2.2"
       }
     },
+    "node_modules/safe-buffer": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+      "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ]
+    },
+    "node_modules/safer-buffer": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+      "dev": true
+    },
     "node_modules/semver": {
       "version": "7.5.4",
       "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
@@ -6034,12 +6838,56 @@
       "integrity": "sha512-lpT8hSQp9jAKp9mhtBU4Xjon8LPGBvLIuBiSVhMEtmLecTh2mO0tlqrAMp47tBXzMr13NJMQ2lf7RpQGLJ3HsQ==",
       "dev": true
     },
+    "node_modules/split-ca": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/split-ca/-/split-ca-1.0.1.tgz",
+      "integrity": "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==",
+      "dev": true
+    },
     "node_modules/sprintf-js": {
       "version": "1.0.3",
       "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
       "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
       "dev": true
     },
+    "node_modules/ssh-remote-port-forward": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/ssh-remote-port-forward/-/ssh-remote-port-forward-1.0.4.tgz",
+      "integrity": "sha512-x0LV1eVDwjf1gmG7TTnfqIzf+3VPRz7vrNIjX6oYLbeCrf/PeVY6hkT68Mg+q02qXxQhrLjB0jfgvhevoCRmLQ==",
+      "dev": true,
+      "dependencies": {
+        "@types/ssh2": "^0.5.48",
+        "ssh2": "^1.4.0"
+      }
+    },
+    "node_modules/ssh-remote-port-forward/node_modules/@types/ssh2": {
+      "version": "0.5.52",
+      "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-0.5.52.tgz",
+      "integrity": "sha512-lbLLlXxdCZOSJMCInKH2+9V/77ET2J6NPQHpFI0kda61Dd1KglJs+fPQBchizmzYSOJBgdTajhPqBO1xxLywvg==",
+      "dev": true,
+      "dependencies": {
+        "@types/node": "*",
+        "@types/ssh2-streams": "*"
+      }
+    },
+    "node_modules/ssh2": {
+      "version": "1.14.0",
+      "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.14.0.tgz",
+      "integrity": "sha512-AqzD1UCqit8tbOKoj6ztDDi1ffJZ2rV2SwlgrVVrHPkV5vWqGJOVp5pmtj18PunkPJAuKQsnInyKV+/Nb2bUnA==",
+      "dev": true,
+      "hasInstallScript": true,
+      "dependencies": {
+        "asn1": "^0.2.6",
+        "bcrypt-pbkdf": "^1.0.2"
+      },
+      "engines": {
+        "node": ">=10.16.0"
+      },
+      "optionalDependencies": {
+        "cpu-features": "~0.0.8",
+        "nan": "^2.17.0"
+      }
+    },
     "node_modules/stack-utils": {
       "version": "2.0.6",
       "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
@@ -6061,6 +6909,25 @@
         "node": ">=8"
       }
     },
+    "node_modules/streamx": {
+      "version": "2.15.6",
+      "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.15.6.tgz",
+      "integrity": "sha512-q+vQL4AAz+FdfT137VF69Cc/APqUbxy+MDOImRrMvchJpigHj9GksgDU2LYbO9rx7RX6osWgxJB2WxhYv4SZAw==",
+      "dev": true,
+      "dependencies": {
+        "fast-fifo": "^1.1.0",
+        "queue-tick": "^1.0.1"
+      }
+    },
+    "node_modules/string_decoder": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+      "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+      "dev": true,
+      "dependencies": {
+        "safe-buffer": "~5.2.0"
+      }
+    },
     "node_modules/string-length": {
       "version": "4.0.2",
       "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz",
@@ -6212,6 +7079,44 @@
         "url": "https://opencollective.com/unts"
       }
     },
+    "node_modules/tar-fs": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.4.tgz",
+      "integrity": "sha512-5AFQU8b9qLfZCX9zp2duONhPmZv0hGYiBPJsyUdqMjzq/mqVpy/rEUSeHk1+YitmxugaptgBh5oDGU3VsAJq4w==",
+      "dev": true,
+      "dependencies": {
+        "mkdirp-classic": "^0.5.2",
+        "pump": "^3.0.0",
+        "tar-stream": "^3.1.5"
+      }
+    },
+    "node_modules/tar-fs/node_modules/tar-stream": {
+      "version": "3.1.6",
+      "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.6.tgz",
+      "integrity": "sha512-B/UyjYwPpMBv+PaFSWAmtYjwdrlEaZQEhMIBFNC5oEG8lpiW8XjcSdmEaClj28ArfKScKHs2nshz3k2le6crsg==",
+      "dev": true,
+      "dependencies": {
+        "b4a": "^1.6.4",
+        "fast-fifo": "^1.2.0",
+        "streamx": "^2.15.0"
+      }
+    },
+    "node_modules/tar-stream": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
+      "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
+      "dev": true,
+      "dependencies": {
+        "bl": "^4.0.3",
+        "end-of-stream": "^1.4.1",
+        "fs-constants": "^1.0.0",
+        "inherits": "^2.0.3",
+        "readable-stream": "^3.1.1"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
     "node_modules/test-exclude": {
       "version": "6.0.0",
       "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
@@ -6246,6 +7151,29 @@
         "url": "https://github.com/sponsors/isaacs"
       }
     },
+    "node_modules/testcontainers": {
+      "version": "10.4.0",
+      "resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-10.4.0.tgz",
+      "integrity": "sha512-kMmJXOAuJeQTRbGSrIEBaAzTzGzmY4+DU5xW5CxgzxgywCoy53ubeiTh3eZ1rzT54YR3zf0nijlb/l7OT4E+/g==",
+      "dev": true,
+      "dependencies": {
+        "@balena/dockerignore": "^1.0.2",
+        "@types/dockerode": "^3.3.21",
+        "archiver": "^5.3.2",
+        "async-lock": "^1.4.0",
+        "byline": "^5.0.0",
+        "debug": "^4.3.4",
+        "docker-compose": "^0.24.2",
+        "dockerode": "^3.3.5",
+        "get-port": "^5.1.1",
+        "node-fetch": "^2.7.0",
+        "proper-lockfile": "^4.1.2",
+        "properties-reader": "^2.3.0",
+        "ssh-remote-port-forward": "^1.0.4",
+        "tar-fs": "^3.0.4",
+        "tmp": "^0.2.1"
+      }
+    },
     "node_modules/text-table": {
       "version": "0.2.0",
       "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
@@ -6264,6 +7192,18 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/tmp": {
+      "version": "0.2.1",
+      "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz",
+      "integrity": "sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==",
+      "dev": true,
+      "dependencies": {
+        "rimraf": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=8.17.0"
+      }
+    },
     "node_modules/tmpl": {
       "version": "1.0.5",
       "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
@@ -6291,6 +7231,12 @@
         "node": ">=8.0"
       }
     },
+    "node_modules/tr46": {
+      "version": "0.0.3",
+      "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
+      "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
+      "dev": true
+    },
     "node_modules/ts-api-utils": {
       "version": "1.0.3",
       "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.0.3.tgz",
@@ -6416,6 +7362,12 @@
       "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
       "dev": true
     },
+    "node_modules/tweetnacl": {
+      "version": "0.14.5",
+      "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
+      "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
+      "dev": true
+    },
     "node_modules/type-check": {
       "version": "0.4.0",
       "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -6516,6 +7468,12 @@
         "punycode": "^2.1.0"
       }
     },
+    "node_modules/util-deprecate": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+      "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+      "dev": true
+    },
     "node_modules/v8-compile-cache-lib": {
       "version": "3.0.1",
       "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
@@ -6561,6 +7519,22 @@
         "makeerror": "1.0.12"
       }
     },
+    "node_modules/webidl-conversions": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
+      "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
+      "dev": true
+    },
+    "node_modules/whatwg-url": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
+      "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
+      "dev": true,
+      "dependencies": {
+        "tr46": "~0.0.3",
+        "webidl-conversions": "^3.0.0"
+      }
+    },
     "node_modules/which": {
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -6760,6 +7734,61 @@
       "funding": {
         "url": "https://github.com/sponsors/sindresorhus"
       }
+    },
+    "node_modules/zip-stream": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz",
+      "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==",
+      "dev": true,
+      "dependencies": {
+        "archiver-utils": "^3.0.4",
+        "compress-commons": "^4.1.2",
+        "readable-stream": "^3.6.0"
+      },
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/zip-stream/node_modules/archiver-utils": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz",
+      "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==",
+      "dev": true,
+      "dependencies": {
+        "glob": "^7.2.3",
+        "graceful-fs": "^4.2.0",
+        "lazystream": "^1.0.0",
+        "lodash.defaults": "^4.2.0",
+        "lodash.difference": "^4.5.0",
+        "lodash.flatten": "^4.4.0",
+        "lodash.isplainobject": "^4.0.6",
+        "lodash.union": "^4.6.0",
+        "normalize-path": "^3.0.0",
+        "readable-stream": "^3.6.0"
+      },
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/zip-stream/node_modules/glob": {
+      "version": "7.2.3",
+      "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
+      "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
+      "dev": true,
+      "dependencies": {
+        "fs.realpath": "^1.0.0",
+        "inflight": "^1.0.4",
+        "inherits": "2",
+        "minimatch": "^3.1.1",
+        "once": "^1.3.0",
+        "path-is-absolute": "^1.0.0"
+      },
+      "engines": {
+        "node": "*"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
     }
   },
   "dependencies": {
@@ -7274,6 +8303,12 @@
         "to-fast-properties": "^2.0.0"
       }
     },
+    "@balena/dockerignore": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/@balena/dockerignore/-/dockerignore-1.0.2.tgz",
+      "integrity": "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==",
+      "dev": true
+    },
     "@bcoe/v8-coverage": {
       "version": "0.2.3",
       "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz",
@@ -7833,6 +8868,15 @@
         "@sinonjs/commons": "^3.0.0"
       }
     },
+    "@testcontainers/postgresql": {
+      "version": "10.4.0",
+      "resolved": "https://registry.npmjs.org/@testcontainers/postgresql/-/postgresql-10.4.0.tgz",
+      "integrity": "sha512-d+eJBDQlQE6+PVoWlpX4YpUoDdogoNkW0ag33qt/oOknmqNjQj6c3/mCzThsS59qtS0vPXydwbHiOkpNuCXFFg==",
+      "dev": true,
+      "requires": {
+        "testcontainers": "^10.4.0"
+      }
+    },
     "@tsconfig/node10": {
       "version": "1.0.9",
       "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz",
@@ -7905,9 +8949,9 @@
       "dev": true
     },
     "@types/chai": {
-      "version": "4.3.11",
-      "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.11.tgz",
-      "integrity": "sha512-qQR1dr2rGIHYlJulmr8Ioq3De0Le9E4MJ5AiaeAETJJpndT1uUNHsGFK3L/UIu+rbkQSdj8J/w2bCsBZc/Y5fQ==",
+      "version": "4.3.10",
+      "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.10.tgz",
+      "integrity": "sha512-of+ICnbqjmFCiixUnqRulbylyXQrPqIGf/B3Jax1wIF3DvSheysQxAWvqHhZiW3IQrycvokcLcFQlveGp+vyNg==",
       "dev": true
     },
     "@types/cli-progress": {
@@ -7919,6 +8963,26 @@
         "@types/node": "*"
       }
     },
+    "@types/docker-modem": {
+      "version": "3.0.6",
+      "resolved": "https://registry.npmjs.org/@types/docker-modem/-/docker-modem-3.0.6.tgz",
+      "integrity": "sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg==",
+      "dev": true,
+      "requires": {
+        "@types/node": "*",
+        "@types/ssh2": "*"
+      }
+    },
+    "@types/dockerode": {
+      "version": "3.3.23",
+      "resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-3.3.23.tgz",
+      "integrity": "sha512-Lz5J+NFgZS4cEVhquwjIGH4oQwlVn2h7LXD3boitujBnzOE5o7s9H8hchEjoDK2SlRsJTogdKnQeiJgPPKLIEw==",
+      "dev": true,
+      "requires": {
+        "@types/docker-modem": "*",
+        "@types/node": "*"
+      }
+    },
     "@types/graceful-fs": {
       "version": "4.1.7",
       "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.7.tgz",
@@ -7953,9 +9017,9 @@
       }
     },
     "@types/jest": {
-      "version": "29.5.10",
-      "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.10.tgz",
-      "integrity": "sha512-tE4yxKEphEyxj9s4inideLHktW/x6DwesIwWZ9NN1FKf9zbJYsnhBoA9vrHA/IuIOKwPa5PcFBNV4lpMIOEzyQ==",
+      "version": "29.5.8",
+      "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.8.tgz",
+      "integrity": "sha512-fXEFTxMV2Co8ZF5aYFJv+YeA08RTYJfhtN5c9JSv/mFEMe+xxjufCb+PHL+bJcMs/ebPUsBu+UNTEz+ydXrR6g==",
       "dev": true,
       "requires": {
         "expect": "^29.0.0",
@@ -8010,6 +9074,35 @@
       "integrity": "sha512-OxepLK9EuNEIPxWNME+C6WwbRAOOI2o2BaQEGzz5Lu2e4Z5eDnEo+/aVEDMIXywoJitJ7xWd641wrGLZdtwRyw==",
       "dev": true
     },
+    "@types/ssh2": {
+      "version": "1.11.18",
+      "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.11.18.tgz",
+      "integrity": "sha512-7eH4ppQMFlzvn//zhwD54MWaITR1aSc1oFBye9vb76GZ2Y9PSFYdwVIwOlxRXWs5+1hifntXyt+8a6SUbOD7Hg==",
+      "dev": true,
+      "requires": {
+        "@types/node": "^18.11.18"
+      },
+      "dependencies": {
+        "@types/node": {
+          "version": "18.19.3",
+          "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.3.tgz",
+          "integrity": "sha512-k5fggr14DwAytoA/t8rPrIz++lXK7/DqckthCmoZOKNsEbJkId4Z//BqgApXBUGrGddrigYa1oqheo/7YmW4rg==",
+          "dev": true,
+          "requires": {
+            "undici-types": "~5.26.4"
+          }
+        }
+      }
+    },
+    "@types/ssh2-streams": {
+      "version": "0.1.12",
+      "resolved": "https://registry.npmjs.org/@types/ssh2-streams/-/ssh2-streams-0.1.12.tgz",
+      "integrity": "sha512-Sy8tpEmCce4Tq0oSOYdfqaBpA3hDM8SoxoFh5vzFsu2oL+znzGz8oVWW7xb4K920yYMUY+PIG31qZnFMfPWNCg==",
+      "dev": true,
+      "requires": {
+        "@types/node": "*"
+      }
+    },
     "@types/stack-utils": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz",
@@ -8368,6 +9461,85 @@
         "picomatch": "^2.0.4"
       }
     },
+    "archiver": {
+      "version": "5.3.2",
+      "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz",
+      "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==",
+      "dev": true,
+      "requires": {
+        "archiver-utils": "^2.1.0",
+        "async": "^3.2.4",
+        "buffer-crc32": "^0.2.1",
+        "readable-stream": "^3.6.0",
+        "readdir-glob": "^1.1.2",
+        "tar-stream": "^2.2.0",
+        "zip-stream": "^4.1.0"
+      }
+    },
+    "archiver-utils": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz",
+      "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==",
+      "dev": true,
+      "requires": {
+        "glob": "^7.1.4",
+        "graceful-fs": "^4.2.0",
+        "lazystream": "^1.0.0",
+        "lodash.defaults": "^4.2.0",
+        "lodash.difference": "^4.5.0",
+        "lodash.flatten": "^4.4.0",
+        "lodash.isplainobject": "^4.0.6",
+        "lodash.union": "^4.6.0",
+        "normalize-path": "^3.0.0",
+        "readable-stream": "^2.0.0"
+      },
+      "dependencies": {
+        "glob": {
+          "version": "7.2.3",
+          "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
+          "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
+          "dev": true,
+          "requires": {
+            "fs.realpath": "^1.0.0",
+            "inflight": "^1.0.4",
+            "inherits": "2",
+            "minimatch": "^3.1.1",
+            "once": "^1.3.0",
+            "path-is-absolute": "^1.0.0"
+          }
+        },
+        "readable-stream": {
+          "version": "2.3.8",
+          "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
+          "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+          "dev": true,
+          "requires": {
+            "core-util-is": "~1.0.0",
+            "inherits": "~2.0.3",
+            "isarray": "~1.0.0",
+            "process-nextick-args": "~2.0.0",
+            "safe-buffer": "~5.1.1",
+            "string_decoder": "~1.1.1",
+            "util-deprecate": "~1.0.1"
+          }
+        },
+        "safe-buffer": {
+          "version": "5.1.2",
+          "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+          "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+          "dev": true
+        },
+        "string_decoder": {
+          "version": "1.1.1",
+          "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+          "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+          "dev": true,
+          "requires": {
+            "safe-buffer": "~5.1.0"
+          }
+        }
+      }
+    },
     "arg": {
       "version": "4.1.3",
       "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
@@ -8386,12 +9558,33 @@
       "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==",
       "dev": true
     },
+    "asn1": {
+      "version": "0.2.6",
+      "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
+      "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
+      "dev": true,
+      "requires": {
+        "safer-buffer": "~2.1.0"
+      }
+    },
     "assertion-error": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz",
       "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==",
       "dev": true
     },
+    "async": {
+      "version": "3.2.5",
+      "resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz",
+      "integrity": "sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==",
+      "dev": true
+    },
+    "async-lock": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.0.tgz",
+      "integrity": "sha512-coglx5yIWuetakm3/1dsX9hxCNox22h7+V80RQOu2XUUMidtArxKoZoOtHUPuR84SycKTXzgGzAUR5hJxujyJQ==",
+      "dev": true
+    },
     "asynckit": {
       "version": "0.4.0",
       "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -8407,6 +9600,12 @@
         "proxy-from-env": "^1.1.0"
       }
     },
+    "b4a": {
+      "version": "1.6.4",
+      "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.4.tgz",
+      "integrity": "sha512-fpWrvyVHEKyeEvbKZTVOeZF3VSKKWtJxFIxX/jaVPf+cLbGUSitjb49pHLqPV2BUNNZ0LcoeEGfE/YCpyDYHIw==",
+      "dev": true
+    },
     "babel-jest": {
       "version": "29.7.0",
       "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
@@ -8503,12 +9702,38 @@
       "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
       "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
     },
+    "base64-js": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+      "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+      "dev": true
+    },
+    "bcrypt-pbkdf": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
+      "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
+      "dev": true,
+      "requires": {
+        "tweetnacl": "^0.14.3"
+      }
+    },
     "big-integer": {
       "version": "1.6.52",
       "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz",
       "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==",
       "dev": true
     },
+    "bl": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
+      "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
+      "dev": true,
+      "requires": {
+        "buffer": "^5.5.0",
+        "inherits": "^2.0.4",
+        "readable-stream": "^3.4.0"
+      }
+    },
     "bplist-parser": {
       "version": "0.2.0",
       "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.2.0.tgz",
@@ -8567,12 +9792,35 @@
         "node-int64": "^0.4.0"
       }
     },
+    "buffer": {
+      "version": "5.7.1",
+      "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
+      "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
+      "dev": true,
+      "requires": {
+        "base64-js": "^1.3.1",
+        "ieee754": "^1.1.13"
+      }
+    },
+    "buffer-crc32": {
+      "version": "0.2.13",
+      "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
+      "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
+      "dev": true
+    },
     "buffer-from": {
       "version": "1.1.2",
       "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
       "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
       "dev": true
     },
+    "buildcheck": {
+      "version": "0.0.6",
+      "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.6.tgz",
+      "integrity": "sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A==",
+      "dev": true,
+      "optional": true
+    },
     "builtin-modules": {
       "version": "3.3.0",
       "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz",
@@ -8588,6 +9836,12 @@
         "run-applescript": "^5.0.0"
       }
     },
+    "byline": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/byline/-/byline-5.0.0.tgz",
+      "integrity": "sha512-s6webAy+R4SR8XVuJWt2V2rGvhnrhxN+9S15GNuTK3wKPOXFF6RNc+8ug2XhH+2s4f+uudG4kUVYmYOQWL2g0Q==",
+      "dev": true
+    },
     "byte-size": {
       "version": "8.1.1",
       "resolved": "https://registry.npmjs.org/byte-size/-/byte-size-8.1.1.tgz",
@@ -8651,6 +9905,12 @@
         "get-func-name": "^2.0.2"
       }
     },
+    "chownr": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+      "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
+      "dev": true
+    },
     "ci-info": {
       "version": "3.8.0",
       "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.8.0.tgz",
@@ -8750,6 +10010,18 @@
       "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz",
       "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="
     },
+    "compress-commons": {
+      "version": "4.1.2",
+      "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz",
+      "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==",
+      "dev": true,
+      "requires": {
+        "buffer-crc32": "^0.2.13",
+        "crc32-stream": "^4.0.2",
+        "normalize-path": "^3.0.0",
+        "readable-stream": "^3.6.0"
+      }
+    },
     "concat-map": {
       "version": "0.0.1",
       "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -8762,6 +10034,39 @@
       "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
       "dev": true
     },
+    "core-util-is": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
+      "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
+      "dev": true
+    },
+    "cpu-features": {
+      "version": "0.0.9",
+      "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.9.tgz",
+      "integrity": "sha512-AKjgn2rP2yJyfbepsmLfiYcmtNn/2eUvocUyM/09yB0YDiz39HteK/5/T4Onf0pmdYDMgkBoGvRLvEguzyL7wQ==",
+      "dev": true,
+      "optional": true,
+      "requires": {
+        "buildcheck": "~0.0.6",
+        "nan": "^2.17.0"
+      }
+    },
+    "crc-32": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
+      "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
+      "dev": true
+    },
+    "crc32-stream": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz",
+      "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==",
+      "dev": true,
+      "requires": {
+        "crc-32": "^1.2.0",
+        "readable-stream": "^3.4.0"
+      }
+    },
     "create-jest": {
       "version": "29.7.0",
       "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz",
@@ -8963,6 +10268,52 @@
         "path-type": "^4.0.0"
       }
     },
+    "docker-compose": {
+      "version": "0.24.3",
+      "resolved": "https://registry.npmjs.org/docker-compose/-/docker-compose-0.24.3.tgz",
+      "integrity": "sha512-x3/QN3AIOMe7j2c8f/jcycizMft7dl8MluoB9OGPAYCyKHHiPUFqI9GjCcsU0kYy24vYKMCcfR6+5ZaEyQlrxg==",
+      "dev": true,
+      "requires": {
+        "yaml": "^2.2.2"
+      }
+    },
+    "docker-modem": {
+      "version": "3.0.8",
+      "resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-3.0.8.tgz",
+      "integrity": "sha512-f0ReSURdM3pcKPNS30mxOHSbaFLcknGmQjwSfmbcdOw1XWKXVhukM3NJHhr7NpY9BIyyWQb0EBo3KQvvuU5egQ==",
+      "dev": true,
+      "requires": {
+        "debug": "^4.1.1",
+        "readable-stream": "^3.5.0",
+        "split-ca": "^1.0.1",
+        "ssh2": "^1.11.0"
+      }
+    },
+    "dockerode": {
+      "version": "3.3.5",
+      "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-3.3.5.tgz",
+      "integrity": "sha512-/0YNa3ZDNeLr/tSckmD69+Gq+qVNhvKfAHNeZJBnp7EOP6RGKV8ORrJHkUn20So5wU+xxT7+1n5u8PjHbfjbSA==",
+      "dev": true,
+      "requires": {
+        "@balena/dockerignore": "^1.0.2",
+        "docker-modem": "^3.0.0",
+        "tar-fs": "~2.0.1"
+      },
+      "dependencies": {
+        "tar-fs": {
+          "version": "2.0.1",
+          "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.0.1.tgz",
+          "integrity": "sha512-6tzWDMeroL87uF/+lin46k+Q+46rAJ0SyPGz7OW7wTgblI273hsBqk2C1j0/xNadNLKDTUL9BukSjB7cwgmlPA==",
+          "dev": true,
+          "requires": {
+            "chownr": "^1.1.1",
+            "mkdirp-classic": "^0.5.2",
+            "pump": "^3.0.0",
+            "tar-stream": "^2.0.0"
+          }
+        }
+      }
+    },
     "doctrine": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
@@ -8994,6 +10345,15 @@
       "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
       "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
     },
+    "end-of-stream": {
+      "version": "1.4.4",
+      "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
+      "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==",
+      "dev": true,
+      "requires": {
+        "once": "^1.4.0"
+      }
+    },
     "error-ex": {
       "version": "1.3.2",
       "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
@@ -9262,6 +10622,12 @@
       "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
       "dev": true
     },
+    "fast-fifo": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
+      "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
+      "dev": true
+    },
     "fast-glob": {
       "version": "3.3.1",
       "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
@@ -9385,6 +10751,12 @@
         "mime-types": "^2.1.12"
       }
     },
+    "fs-constants": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
+      "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
+      "dev": true
+    },
     "fs.realpath": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@@ -9428,6 +10800,12 @@
       "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==",
       "dev": true
     },
+    "get-port": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz",
+      "integrity": "sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==",
+      "dev": true
+    },
     "get-stream": {
       "version": "6.0.1",
       "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
@@ -9541,12 +10919,111 @@
       "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==",
       "dev": true
     },
+    "ieee754": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+      "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+      "dev": true
+    },
     "ignore": {
       "version": "5.2.4",
       "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz",
       "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==",
       "dev": true
     },
+    "immich": {
+      "version": "file:../server",
+      "requires": {
+        "@babel/runtime": "^7.22.11",
+        "@immich/cli": "^2.0.3",
+        "@nestjs/bullmq": "^10.0.1",
+        "@nestjs/cli": "^10.1.16",
+        "@nestjs/common": "^10.2.2",
+        "@nestjs/config": "^3.0.0",
+        "@nestjs/core": "^10.2.2",
+        "@nestjs/platform-express": "^10.2.2",
+        "@nestjs/platform-socket.io": "^10.2.2",
+        "@nestjs/schedule": "^3.0.3",
+        "@nestjs/schematics": "^10.0.2",
+        "@nestjs/swagger": "^7.1.8",
+        "@nestjs/testing": "^10.2.2",
+        "@nestjs/typeorm": "^10.0.0",
+        "@nestjs/websockets": "^10.2.2",
+        "@openapitools/openapi-generator-cli": "2.7.0",
+        "@socket.io/postgres-adapter": "^0.3.1",
+        "@testcontainers/postgresql": "^10.2.1",
+        "@types/archiver": "^6.0.0",
+        "@types/async-lock": "^1.4.2",
+        "@types/bcrypt": "^5.0.0",
+        "@types/cookie-parser": "^1.4.3",
+        "@types/express": "^4.17.17",
+        "@types/fluent-ffmpeg": "^2.1.21",
+        "@types/imagemin": "^8.0.1",
+        "@types/jest": "29.5.10",
+        "@types/jest-when": "^3.5.2",
+        "@types/lodash": "^4.14.197",
+        "@types/mock-fs": "^4.13.1",
+        "@types/multer": "^1.4.7",
+        "@types/mv": "^2.1.2",
+        "@types/node": "^20.5.7",
+        "@types/sharp": "^0.31.1",
+        "@types/supertest": "^2.0.12",
+        "@types/ua-parser-js": "^0.7.36",
+        "@typescript-eslint/eslint-plugin": "^6.4.1",
+        "@typescript-eslint/parser": "^6.4.1",
+        "archiver": "^6.0.0",
+        "async-lock": "^1.4.0",
+        "axios": "^1.5.0",
+        "bcrypt": "^5.1.1",
+        "bullmq": "^4.8.0",
+        "class-transformer": "^0.5.1",
+        "class-validator": "^0.14.0",
+        "cookie-parser": "^1.4.6",
+        "dotenv": "^16.3.1",
+        "eslint": "^8.48.0",
+        "eslint-config-prettier": "^9.0.0",
+        "eslint-plugin-prettier": "^5.0.0",
+        "exiftool-vendored": "~23.5.0",
+        "exiftool-vendored.pl": "12.70",
+        "fluent-ffmpeg": "^2.1.2",
+        "geo-tz": "^7.0.7",
+        "glob": "^10.3.3",
+        "handlebars": "^4.7.8",
+        "i18n-iso-countries": "^7.6.0",
+        "ioredis": "^5.3.2",
+        "jest": "^29.6.4",
+        "jest-when": "^3.6.0",
+        "joi": "^17.10.0",
+        "lodash": "^4.17.21",
+        "luxon": "^3.4.2",
+        "mock-fs": "^5.2.0",
+        "mv": "^2.1.1",
+        "nest-commander": "^3.11.1",
+        "node-addon-api": "^7.0.0",
+        "openid-client": "^5.4.3",
+        "pg": "^8.11.3",
+        "prettier": "^3.0.2",
+        "prettier-plugin-organize-imports": "^3.2.3",
+        "reflect-metadata": "^0.1.13",
+        "rimraf": "^5.0.1",
+        "rxjs": "^7.8.1",
+        "sanitize-filename": "^1.6.3",
+        "sharp": "^0.33.0",
+        "source-map-support": "^0.5.21",
+        "sql-formatter": "^15.0.0",
+        "supertest": "^6.3.3",
+        "testcontainers": "^10.2.1",
+        "thumbhash": "^0.1.1",
+        "ts-jest": "^29.1.1",
+        "ts-loader": "^9.4.4",
+        "ts-node": "^10.9.1",
+        "tsconfig-paths": "^4.2.0",
+        "typeorm": "^0.3.17",
+        "typescript": "^5.2.2",
+        "ua-parser-js": "^1.0.35",
+        "utimes": "^5.2.1"
+      }
+    },
     "import-fresh": {
       "version": "3.3.0",
       "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
@@ -9695,6 +11172,12 @@
         }
       }
     },
+    "isarray": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+      "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+      "dev": true
+    },
     "isexe": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -10312,6 +11795,47 @@
       "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==",
       "dev": true
     },
+    "lazystream": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz",
+      "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==",
+      "dev": true,
+      "requires": {
+        "readable-stream": "^2.0.5"
+      },
+      "dependencies": {
+        "readable-stream": {
+          "version": "2.3.8",
+          "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
+          "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+          "dev": true,
+          "requires": {
+            "core-util-is": "~1.0.0",
+            "inherits": "~2.0.3",
+            "isarray": "~1.0.0",
+            "process-nextick-args": "~2.0.0",
+            "safe-buffer": "~5.1.1",
+            "string_decoder": "~1.1.1",
+            "util-deprecate": "~1.0.1"
+          }
+        },
+        "safe-buffer": {
+          "version": "5.1.2",
+          "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+          "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+          "dev": true
+        },
+        "string_decoder": {
+          "version": "1.1.1",
+          "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+          "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+          "dev": true,
+          "requires": {
+            "safe-buffer": "~5.1.0"
+          }
+        }
+      }
+    },
     "leven": {
       "version": "3.1.0",
       "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
@@ -10343,6 +11867,30 @@
         "p-locate": "^5.0.0"
       }
     },
+    "lodash.defaults": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
+      "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
+      "dev": true
+    },
+    "lodash.difference": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz",
+      "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==",
+      "dev": true
+    },
+    "lodash.flatten": {
+      "version": "4.4.0",
+      "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz",
+      "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==",
+      "dev": true
+    },
+    "lodash.isplainobject": {
+      "version": "4.0.6",
+      "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
+      "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
+      "dev": true
+    },
     "lodash.memoize": {
       "version": "4.1.2",
       "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
@@ -10355,6 +11903,12 @@
       "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
       "dev": true
     },
+    "lodash.union": {
+      "version": "4.6.0",
+      "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz",
+      "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==",
+      "dev": true
+    },
     "loupe": {
       "version": "2.3.6",
       "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.6.tgz",
@@ -10458,6 +12012,18 @@
       "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz",
       "integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ=="
     },
+    "mkdirp": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
+      "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
+      "dev": true
+    },
+    "mkdirp-classic": {
+      "version": "0.5.3",
+      "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
+      "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
+      "dev": true
+    },
     "mock-fs": {
       "version": "5.2.0",
       "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.2.0.tgz",
@@ -10470,12 +12036,28 @@
       "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
       "dev": true
     },
+    "nan": {
+      "version": "2.18.0",
+      "resolved": "https://registry.npmjs.org/nan/-/nan-2.18.0.tgz",
+      "integrity": "sha512-W7tfG7vMOGtD30sHoZSSc/JVYiyDPEyQVso/Zz+/uQd0B0L46gtC+pHha5FFMRpil6fm/AoEcRWyOVi4+E/f8w==",
+      "dev": true,
+      "optional": true
+    },
     "natural-compare": {
       "version": "1.4.0",
       "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
       "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
       "dev": true
     },
+    "node-fetch": {
+      "version": "2.7.0",
+      "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
+      "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
+      "dev": true,
+      "requires": {
+        "whatwg-url": "^5.0.0"
+      }
+    },
     "node-int64": {
       "version": "0.4.0",
       "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
@@ -10776,6 +12358,12 @@
         }
       }
     },
+    "process-nextick-args": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+      "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
+      "dev": true
+    },
     "prompts": {
       "version": "2.4.2",
       "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
@@ -10786,11 +12374,49 @@
         "sisteransi": "^1.0.5"
       }
     },
+    "proper-lockfile": {
+      "version": "4.1.2",
+      "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz",
+      "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==",
+      "dev": true,
+      "requires": {
+        "graceful-fs": "^4.2.4",
+        "retry": "^0.12.0",
+        "signal-exit": "^3.0.2"
+      },
+      "dependencies": {
+        "signal-exit": {
+          "version": "3.0.7",
+          "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
+          "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
+          "dev": true
+        }
+      }
+    },
+    "properties-reader": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/properties-reader/-/properties-reader-2.3.0.tgz",
+      "integrity": "sha512-z597WicA7nDZxK12kZqHr2TcvwNU1GCfA5UwfDY/HDp3hXPoPlb5rlEx9bwGTiJnc0OqbBTkU975jDToth8Gxw==",
+      "dev": true,
+      "requires": {
+        "mkdirp": "^1.0.4"
+      }
+    },
     "proxy-from-env": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
       "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
     },
+    "pump": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz",
+      "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==",
+      "dev": true,
+      "requires": {
+        "end-of-stream": "^1.1.0",
+        "once": "^1.3.1"
+      }
+    },
     "punycode": {
       "version": "2.3.0",
       "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz",
@@ -10809,6 +12435,12 @@
       "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
       "dev": true
     },
+    "queue-tick": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/queue-tick/-/queue-tick-1.0.1.tgz",
+      "integrity": "sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==",
+      "dev": true
+    },
     "react-is": {
       "version": "18.2.0",
       "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz",
@@ -10891,6 +12523,46 @@
         }
       }
     },
+    "readable-stream": {
+      "version": "3.6.2",
+      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+      "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+      "dev": true,
+      "requires": {
+        "inherits": "^2.0.3",
+        "string_decoder": "^1.1.1",
+        "util-deprecate": "^1.0.1"
+      }
+    },
+    "readdir-glob": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz",
+      "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==",
+      "dev": true,
+      "requires": {
+        "minimatch": "^5.1.0"
+      },
+      "dependencies": {
+        "brace-expansion": {
+          "version": "2.0.1",
+          "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+          "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+          "dev": true,
+          "requires": {
+            "balanced-match": "^1.0.0"
+          }
+        },
+        "minimatch": {
+          "version": "5.1.6",
+          "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
+          "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
+          "dev": true,
+          "requires": {
+            "brace-expansion": "^2.0.1"
+          }
+        }
+      }
+    },
     "regexp-tree": {
       "version": "0.1.27",
       "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz",
@@ -10960,6 +12632,12 @@
       "integrity": "sha512-X2UW6Nw3n/aMgDVy+0rSqgHlv39WZAlZrXCdnbyEiKm17DSqHX4MmQMaST3FbeWR5FTuRcUwYAziZajji0Y7mg==",
       "dev": true
     },
+    "retry": {
+      "version": "0.12.0",
+      "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
+      "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==",
+      "dev": true
+    },
     "reusify": {
       "version": "1.0.4",
       "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
@@ -11009,6 +12687,18 @@
         "queue-microtask": "^1.2.2"
       }
     },
+    "safe-buffer": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+      "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+      "dev": true
+    },
+    "safer-buffer": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+      "dev": true
+    },
     "semver": {
       "version": "7.5.4",
       "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
@@ -11113,12 +12803,52 @@
       "integrity": "sha512-lpT8hSQp9jAKp9mhtBU4Xjon8LPGBvLIuBiSVhMEtmLecTh2mO0tlqrAMp47tBXzMr13NJMQ2lf7RpQGLJ3HsQ==",
       "dev": true
     },
+    "split-ca": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/split-ca/-/split-ca-1.0.1.tgz",
+      "integrity": "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==",
+      "dev": true
+    },
     "sprintf-js": {
       "version": "1.0.3",
       "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
       "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
       "dev": true
     },
+    "ssh-remote-port-forward": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/ssh-remote-port-forward/-/ssh-remote-port-forward-1.0.4.tgz",
+      "integrity": "sha512-x0LV1eVDwjf1gmG7TTnfqIzf+3VPRz7vrNIjX6oYLbeCrf/PeVY6hkT68Mg+q02qXxQhrLjB0jfgvhevoCRmLQ==",
+      "dev": true,
+      "requires": {
+        "@types/ssh2": "^0.5.48",
+        "ssh2": "^1.4.0"
+      },
+      "dependencies": {
+        "@types/ssh2": {
+          "version": "0.5.52",
+          "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-0.5.52.tgz",
+          "integrity": "sha512-lbLLlXxdCZOSJMCInKH2+9V/77ET2J6NPQHpFI0kda61Dd1KglJs+fPQBchizmzYSOJBgdTajhPqBO1xxLywvg==",
+          "dev": true,
+          "requires": {
+            "@types/node": "*",
+            "@types/ssh2-streams": "*"
+          }
+        }
+      }
+    },
+    "ssh2": {
+      "version": "1.14.0",
+      "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.14.0.tgz",
+      "integrity": "sha512-AqzD1UCqit8tbOKoj6ztDDi1ffJZ2rV2SwlgrVVrHPkV5vWqGJOVp5pmtj18PunkPJAuKQsnInyKV+/Nb2bUnA==",
+      "dev": true,
+      "requires": {
+        "asn1": "^0.2.6",
+        "bcrypt-pbkdf": "^1.0.2",
+        "cpu-features": "~0.0.8",
+        "nan": "^2.17.0"
+      }
+    },
     "stack-utils": {
       "version": "2.0.6",
       "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
@@ -11136,6 +12866,25 @@
         }
       }
     },
+    "streamx": {
+      "version": "2.15.6",
+      "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.15.6.tgz",
+      "integrity": "sha512-q+vQL4AAz+FdfT137VF69Cc/APqUbxy+MDOImRrMvchJpigHj9GksgDU2LYbO9rx7RX6osWgxJB2WxhYv4SZAw==",
+      "dev": true,
+      "requires": {
+        "fast-fifo": "^1.1.0",
+        "queue-tick": "^1.0.1"
+      }
+    },
+    "string_decoder": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+      "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+      "dev": true,
+      "requires": {
+        "safe-buffer": "~5.2.0"
+      }
+    },
     "string-length": {
       "version": "4.0.2",
       "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz",
@@ -11240,6 +12989,43 @@
         "tslib": "^2.5.0"
       }
     },
+    "tar-fs": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.4.tgz",
+      "integrity": "sha512-5AFQU8b9qLfZCX9zp2duONhPmZv0hGYiBPJsyUdqMjzq/mqVpy/rEUSeHk1+YitmxugaptgBh5oDGU3VsAJq4w==",
+      "dev": true,
+      "requires": {
+        "mkdirp-classic": "^0.5.2",
+        "pump": "^3.0.0",
+        "tar-stream": "^3.1.5"
+      },
+      "dependencies": {
+        "tar-stream": {
+          "version": "3.1.6",
+          "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.6.tgz",
+          "integrity": "sha512-B/UyjYwPpMBv+PaFSWAmtYjwdrlEaZQEhMIBFNC5oEG8lpiW8XjcSdmEaClj28ArfKScKHs2nshz3k2le6crsg==",
+          "dev": true,
+          "requires": {
+            "b4a": "^1.6.4",
+            "fast-fifo": "^1.2.0",
+            "streamx": "^2.15.0"
+          }
+        }
+      }
+    },
+    "tar-stream": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
+      "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
+      "dev": true,
+      "requires": {
+        "bl": "^4.0.3",
+        "end-of-stream": "^1.4.1",
+        "fs-constants": "^1.0.0",
+        "inherits": "^2.0.3",
+        "readable-stream": "^3.1.1"
+      }
+    },
     "test-exclude": {
       "version": "6.0.0",
       "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
@@ -11267,6 +13053,29 @@
         }
       }
     },
+    "testcontainers": {
+      "version": "10.4.0",
+      "resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-10.4.0.tgz",
+      "integrity": "sha512-kMmJXOAuJeQTRbGSrIEBaAzTzGzmY4+DU5xW5CxgzxgywCoy53ubeiTh3eZ1rzT54YR3zf0nijlb/l7OT4E+/g==",
+      "dev": true,
+      "requires": {
+        "@balena/dockerignore": "^1.0.2",
+        "@types/dockerode": "^3.3.21",
+        "archiver": "^5.3.2",
+        "async-lock": "^1.4.0",
+        "byline": "^5.0.0",
+        "debug": "^4.3.4",
+        "docker-compose": "^0.24.2",
+        "dockerode": "^3.3.5",
+        "get-port": "^5.1.1",
+        "node-fetch": "^2.7.0",
+        "proper-lockfile": "^4.1.2",
+        "properties-reader": "^2.3.0",
+        "ssh-remote-port-forward": "^1.0.4",
+        "tar-fs": "^3.0.4",
+        "tmp": "^0.2.1"
+      }
+    },
     "text-table": {
       "version": "0.2.0",
       "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
@@ -11279,6 +13088,15 @@
       "integrity": "sha512-KxVu8EYHDPBdUYdKZdKtU2aj2XfEx9AfjXxE/Aj0vT06w2icA09Vus1rh6eSu1y01akYg6BjIK/hxyLJINoMLQ==",
       "dev": true
     },
+    "tmp": {
+      "version": "0.2.1",
+      "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz",
+      "integrity": "sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==",
+      "dev": true,
+      "requires": {
+        "rimraf": "^3.0.0"
+      }
+    },
     "tmpl": {
       "version": "1.0.5",
       "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
@@ -11300,6 +13118,12 @@
         "is-number": "^7.0.0"
       }
     },
+    "tr46": {
+      "version": "0.0.3",
+      "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
+      "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
+      "dev": true
+    },
     "ts-api-utils": {
       "version": "1.0.3",
       "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.0.3.tgz",
@@ -11367,6 +13191,12 @@
         }
       }
     },
+    "tweetnacl": {
+      "version": "0.14.5",
+      "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
+      "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
+      "dev": true
+    },
     "type-check": {
       "version": "0.4.0",
       "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -11425,6 +13255,12 @@
         "punycode": "^2.1.0"
       }
     },
+    "util-deprecate": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+      "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+      "dev": true
+    },
     "v8-compile-cache-lib": {
       "version": "3.0.1",
       "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
@@ -11469,6 +13305,22 @@
         "makeerror": "1.0.12"
       }
     },
+    "webidl-conversions": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
+      "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
+      "dev": true
+    },
+    "whatwg-url": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
+      "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
+      "dev": true,
+      "requires": {
+        "tr46": "~0.0.3",
+        "webidl-conversions": "^3.0.0"
+      }
+    },
     "which": {
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -11605,6 +13457,51 @@
       "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
       "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
       "dev": true
+    },
+    "zip-stream": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz",
+      "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==",
+      "dev": true,
+      "requires": {
+        "archiver-utils": "^3.0.4",
+        "compress-commons": "^4.1.2",
+        "readable-stream": "^3.6.0"
+      },
+      "dependencies": {
+        "archiver-utils": {
+          "version": "3.0.4",
+          "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz",
+          "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==",
+          "dev": true,
+          "requires": {
+            "glob": "^7.2.3",
+            "graceful-fs": "^4.2.0",
+            "lazystream": "^1.0.0",
+            "lodash.defaults": "^4.2.0",
+            "lodash.difference": "^4.5.0",
+            "lodash.flatten": "^4.4.0",
+            "lodash.isplainobject": "^4.0.6",
+            "lodash.union": "^4.6.0",
+            "normalize-path": "^3.0.0",
+            "readable-stream": "^3.6.0"
+          }
+        },
+        "glob": {
+          "version": "7.2.3",
+          "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
+          "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
+          "dev": true,
+          "requires": {
+            "fs.realpath": "^1.0.0",
+            "inflight": "^1.0.4",
+            "inherits": "2",
+            "minimatch": "^3.1.1",
+            "once": "^1.3.0",
+            "path-is-absolute": "^1.0.0"
+          }
+        }
+      }
     }
   }
 }
diff --git a/cli/package.json b/cli/package.json
index a1550f7b95..580fd1c9c6 100644
--- a/cli/package.json
+++ b/cli/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@immich/cli",
-  "version": "2.0.4",
+  "version": "2.0.5",
   "description": "Command Line Interface (CLI) for Immich",
   "main": "dist/index.js",
   "bin": {
@@ -21,6 +21,7 @@
     "yaml": "^2.3.1"
   },
   "devDependencies": {
+    "@testcontainers/postgresql": "^10.4.0",
     "@types/byte-size": "^8.1.0",
     "@types/chai": "^4.3.5",
     "@types/cli-progress": "^3.11.0",
@@ -37,6 +38,7 @@
     "eslint-plugin-jest": "^27.2.2",
     "eslint-plugin-prettier": "^5.0.0",
     "eslint-plugin-unicorn": "^49.0.0",
+    "immich": "file:../server",
     "jest": "^29.5.0",
     "jest-extended": "^4.0.0",
     "jest-message-util": "^29.5.0",
@@ -50,13 +52,15 @@
   },
   "scripts": {
     "build": "tsc --project tsconfig.build.json",
-    "lint": "eslint \"src/**/*.ts\" --max-warnings 0",
+    "lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\" --max-warnings 0",
+    "lint:fix": "npm run lint -- --fix",
     "prepack": "npm run build",
     "test": "jest",
     "test:cov": "jest --coverage",
     "format": "prettier --check .",
     "format:fix": "prettier --write .",
-    "check": "tsc --noEmit"
+    "check": "tsc --noEmit",
+    "test:e2e": "NODE_OPTIONS='--experimental-vm-modules' jest --config test/e2e/jest-e2e.json --runInBand"
   },
   "jest": {
     "clearMocks": true,
@@ -71,10 +75,15 @@
       "^.+\\.ts$": "ts-jest"
     },
     "collectCoverageFrom": [
-      "<rootDir>/src/**/*.(t|j)s"
+      "<rootDir>/src/**/*.(t|j)s",
+      "!**/open-api/**"
     ],
     "moduleNameMapper": {
-      "^@api(|/.*)$": "<rootDir>/src/api/$1"
+      "^@api(|/.*)$": "<rootDir>/src/api/$1",
+      "^@test(|/.*)$": "<rootDir>../server/test/$1",
+      "^@app/immich(|/.*)$": "<rootDir>../server/src/immich/$1",
+      "^@app/infra(|/.*)$": "<rootDir>../server/src/infra/$1",
+      "^@app/domain(|/.*)$": "<rootDir>../server/src/domain/$1"
     },
     "coverageDirectory": "./coverage",
     "testEnvironment": "node"
diff --git a/cli/src/cli/base-command.ts b/cli/src/cli/base-command.ts
index d47f973ac6..205b10a90b 100644
--- a/cli/src/cli/base-command.ts
+++ b/cli/src/cli/base-command.ts
@@ -1,10 +1,9 @@
 import { ImmichApi } from '../api/client';
-import path from 'node:path';
 import { SessionService } from '../services/session.service';
 import { LoginError } from '../cores/errors/login-error';
 import { exit } from 'node:process';
-import os from 'os';
 import { ServerVersionResponseDto, UserResponseDto } from 'src/api/open-api';
+import { BaseOptionsDto } from 'src/cores/dto/base-options-dto';
 
 export abstract class BaseCommand {
   protected sessionService!: SessionService;
@@ -12,14 +11,11 @@ export abstract class BaseCommand {
   protected user!: UserResponseDto;
   protected serverVersion!: ServerVersionResponseDto;
 
-  protected configDir;
-  protected authPath;
-
-  constructor() {
-    const userHomeDir = os.homedir();
-    this.configDir = path.join(userHomeDir, '.config/immich/');
-    this.sessionService = new SessionService(this.configDir);
-    this.authPath = path.join(this.configDir, 'auth.yml');
+  constructor(options: BaseOptionsDto) {
+    if (!options.config) {
+      throw new Error('Config directory is required');
+    }
+    this.sessionService = new SessionService(options.config);
   }
 
   public async connect(): Promise<void> {
diff --git a/cli/src/commands/upload.ts b/cli/src/commands/upload.ts
index 8bd3d24d9c..58c5785583 100644
--- a/cli/src/commands/upload.ts
+++ b/cli/src/commands/upload.ts
@@ -2,7 +2,7 @@ import { Asset } from '../cores/models/asset';
 import { CrawlService } from '../services';
 import { UploadOptionsDto } from '../cores/dto/upload-options-dto';
 import { CrawlOptionsDto } from '../cores/dto/crawl-options-dto';
-
+import fs from 'node:fs';
 import cliProgress from 'cli-progress';
 import byteSize from 'byte-size';
 import { BaseCommand } from '../cli/base-command';
@@ -15,8 +15,6 @@ export default class Upload extends BaseCommand {
   public async run(paths: string[], options: UploadOptionsDto): Promise<void> {
     await this.connect();
 
-    const deviceId = 'CLI';
-
     const formatResponse = await this.immichApi.serverInfoApi.getSupportedMediaTypes();
     const crawlService = new CrawlService(formatResponse.data.image, formatResponse.data.video);
 
@@ -25,14 +23,26 @@ export default class Upload extends BaseCommand {
     crawlOptions.recursive = options.recursive;
     crawlOptions.exclusionPatterns = options.exclusionPatterns;
 
+    const files: string[] = [];
+
+    for (const pathArgument of paths) {
+      const fileStat = await fs.promises.lstat(pathArgument);
+
+      if (fileStat.isFile()) {
+        files.push(pathArgument);
+      }
+    }
+
     const crawledFiles: string[] = await crawlService.crawl(crawlOptions);
 
+    crawledFiles.push(...files);
+
     if (crawledFiles.length === 0) {
       console.log('No assets found, exiting');
       return;
     }
 
-    const assetsToUpload = crawledFiles.map((path) => new Asset(path, deviceId));
+    const assetsToUpload = crawledFiles.map((path) => new Asset(path));
 
     const uploadProgress = new cliProgress.SingleBar(
       {
diff --git a/cli/src/constants.ts b/cli/src/constants.ts
new file mode 100644
index 0000000000..63cb116bbc
--- /dev/null
+++ b/cli/src/constants.ts
@@ -0,0 +1,37 @@
+import pkg from '../package.json';
+
+export interface ICLIVersion {
+  major: number;
+  minor: number;
+  patch: number;
+}
+
+export class CLIVersion implements ICLIVersion {
+  constructor(
+    public readonly major: number,
+    public readonly minor: number,
+    public readonly patch: number,
+  ) {}
+
+  toString() {
+    return `${this.major}.${this.minor}.${this.patch}`;
+  }
+
+  toJSON() {
+    const { major, minor, patch } = this;
+    return { major, minor, patch };
+  }
+
+  static fromString(version: string): CLIVersion {
+    const regex = /(?:v)?(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)/i;
+    const matchResult = version.match(regex);
+    if (matchResult) {
+      const [, major, minor, patch] = matchResult.map(Number);
+      return new CLIVersion(major, minor, patch);
+    } else {
+      throw new Error(`Invalid version format: ${version}`);
+    }
+  }
+}
+
+export const cliVersion = CLIVersion.fromString(pkg.version);
diff --git a/cli/src/cores/dto/base-options-dto.ts b/cli/src/cores/dto/base-options-dto.ts
new file mode 100644
index 0000000000..56e351731c
--- /dev/null
+++ b/cli/src/cores/dto/base-options-dto.ts
@@ -0,0 +1,3 @@
+export class BaseOptionsDto {
+  config?: string;
+}
diff --git a/cli/src/cores/dto/upload-options-dto.ts b/cli/src/cores/dto/upload-options-dto.ts
index b788f9b4f7..77bd7cd493 100644
--- a/cli/src/cores/dto/upload-options-dto.ts
+++ b/cli/src/cores/dto/upload-options-dto.ts
@@ -1,9 +1,8 @@
 export class UploadOptionsDto {
-  recursive = false;
-  exclusionPatterns!: string[];
-  dryRun = false;
-  skipHash = false;
-  delete = false;
-  readOnly = true;
-  album = false;
+  recursive? = false;
+  exclusionPatterns?: string[] = [];
+  dryRun? = false;
+  skipHash? = false;
+  delete? = false;
+  album? = false;
 }
diff --git a/cli/src/cores/errors/login-error.ts b/cli/src/cores/errors/login-error.ts
index da60bca0cd..e00b997f1f 100644
--- a/cli/src/cores/errors/login-error.ts
+++ b/cli/src/cores/errors/login-error.ts
@@ -2,10 +2,8 @@ export class LoginError extends Error {
   constructor(message: string) {
     super(message);
 
-    // assign the error class name in your custom error (as a shortcut)
     this.name = this.constructor.name;
 
-    // capturing the stack trace keeps the reference to your error class
     Error.captureStackTrace(this, this.constructor);
   }
 }
diff --git a/cli/src/cores/models/asset.ts b/cli/src/cores/models/asset.ts
index 78f7ddba7f..7bcbf3089a 100644
--- a/cli/src/cores/models/asset.ts
+++ b/cli/src/cores/models/asset.ts
@@ -17,9 +17,8 @@ export class Asset {
   fileSize!: number;
   albumName?: string;
 
-  constructor(path: string, deviceId: string) {
+  constructor(path: string) {
     this.path = path;
-    this.deviceId = deviceId;
   }
 
   async process() {
@@ -45,12 +44,11 @@ export class Asset {
     if (!this.deviceAssetId) throw new Error('Device asset id not set');
     if (!this.fileCreatedAt) throw new Error('File created at not set');
     if (!this.fileModifiedAt) throw new Error('File modified at not set');
-    if (!this.deviceId) throw new Error('Device id not set');
 
     const data: any = {
       assetData: this.assetData as any,
       deviceAssetId: this.deviceAssetId,
-      deviceId: this.deviceId,
+      deviceId: 'CLI',
       fileCreatedAt: this.fileCreatedAt,
       fileModifiedAt: this.fileModifiedAt,
       isFavorite: String(false),
diff --git a/cli/src/index.ts b/cli/src/index.ts
index 39c17cafdb..8f538ead8b 100644
--- a/cli/src/index.ts
+++ b/cli/src/index.ts
@@ -1,13 +1,23 @@
 #! /usr/bin/env node
 
-import { program, Option } from 'commander';
+import { Option, Command } from 'commander';
 import Upload from './commands/upload';
 import ServerInfo from './commands/server-info';
 import LoginKey from './commands/login/key';
 import Logout from './commands/logout';
 import { version } from '../package.json';
 
-program.name('immich').description('Immich command line interface').version(version);
+import path from 'node:path';
+import os from 'os';
+
+const userHomeDir = os.homedir();
+const configDir = path.join(userHomeDir, '.config/immich/');
+
+const program = new Command()
+  .name('immich')
+  .version(version)
+  .description('Command line interface for Immich')
+  .addOption(new Option('-d, --config', 'Configuration directory').env('IMMICH_CONFIG_DIR').default(configDir));
 
 program
   .command('upload')
@@ -30,14 +40,14 @@ program
   .argument('[paths...]', 'One or more paths to assets to be uploaded')
   .action(async (paths, options) => {
     options.exclusionPatterns = options.ignore;
-    await new Upload().run(paths, options);
+    await new Upload(program.opts()).run(paths, options);
   });
 
 program
   .command('server-info')
   .description('Display server information')
   .action(async () => {
-    await new ServerInfo().run();
+    await new ServerInfo(program.opts()).run();
   });
 
 program
@@ -46,14 +56,14 @@ program
   .argument('[instanceUrl]')
   .argument('[apiKey]')
   .action(async (paths, options) => {
-    await new LoginKey().run(paths, options);
+    await new LoginKey(program.opts()).run(paths, options);
   });
 
 program
   .command('logout')
   .description('Remove stored credentials')
   .action(async () => {
-    await new Logout().run();
+    await new Logout(program.opts()).run();
   });
 
 program.parse(process.argv);
diff --git a/cli/src/services/session.service.spec.ts b/cli/src/services/session.service.spec.ts
index 5f2e2a19ac..9c8f748c63 100644
--- a/cli/src/services/session.service.spec.ts
+++ b/cli/src/services/session.service.spec.ts
@@ -1,8 +1,17 @@
 import { SessionService } from './session.service';
-import mockfs from 'mock-fs';
 import fs from 'node:fs';
 import yaml from 'yaml';
 import { LoginError } from '../cores/errors/login-error';
+import {
+  TEST_AUTH_FILE,
+  TEST_CONFIG_DIR,
+  TEST_IMMICH_API_KEY,
+  TEST_IMMICH_INSTANCE_URL,
+  createTestAuthFile,
+  deleteAuthFile,
+  readTestAuthFile,
+  spyOnConsole,
+} from '../../test/cli-test-utils';
 
 const mockPingServer = jest.fn(() => Promise.resolve({ data: { res: 'pong' } }));
 const mockUserInfo = jest.fn(() => Promise.resolve({ data: { email: 'admin@example.com' } }));
@@ -22,74 +31,85 @@ jest.mock('../api/open-api', () => {
 
 describe('SessionService', () => {
   let sessionService: SessionService;
+  let consoleSpy: jest.SpyInstance;
+
   beforeAll(() => {
-    // Write a dummy output before mock-fs to prevent some annoying errors
-    console.log();
+    consoleSpy = spyOnConsole();
   });
 
   beforeEach(() => {
-    const configDir = '/config';
-    sessionService = new SessionService(configDir);
+    deleteAuthFile();
+    sessionService = new SessionService(TEST_CONFIG_DIR);
+  });
+
+  afterEach(() => {
+    deleteAuthFile();
   });
 
   it('should connect to immich', async () => {
-    mockfs({
-      '/config/auth.yml': 'apiKey: pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg\ninstanceUrl: https://test/api',
-    });
+    await createTestAuthFile(
+      JSON.stringify({
+        apiKey: TEST_IMMICH_API_KEY,
+        instanceUrl: TEST_IMMICH_INSTANCE_URL,
+      }),
+    );
+
     await sessionService.connect();
     expect(mockPingServer).toHaveBeenCalledTimes(1);
   });
 
   it('should error if no auth file exists', async () => {
-    mockfs();
     await sessionService.connect().catch((error) => {
       expect(error.message).toEqual('No auth file exist. Please login first');
     });
   });
 
   it('should error if auth file is missing instance URl', async () => {
-    mockfs({
-      '/config/auth.yml': 'foo: pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg\napiKey: https://test/api',
-    });
+    await createTestAuthFile(
+      JSON.stringify({
+        apiKey: TEST_IMMICH_API_KEY,
+      }),
+    );
     await sessionService.connect().catch((error) => {
       expect(error).toBeInstanceOf(LoginError);
-      expect(error.message).toEqual('Instance URL missing in auth config file /config/auth.yml');
+      expect(error.message).toEqual(`Instance URL missing in auth config file ${TEST_AUTH_FILE}`);
     });
   });
 
   it('should error if auth file is missing api key', async () => {
-    mockfs({
-      '/config/auth.yml': 'instanceUrl: pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg\nbar: https://test/api',
-    });
-    await sessionService.connect().catch((error) => {
-      expect(error).toBeInstanceOf(LoginError);
-      expect(error.message).toEqual('API key missing in auth config file /config/auth.yml');
-    });
+    await createTestAuthFile(
+      JSON.stringify({
+        instanceUrl: TEST_IMMICH_INSTANCE_URL,
+      }),
+    );
+
+    await expect(sessionService.connect()).rejects.toThrow(
+      new LoginError(`API key missing in auth config file ${TEST_AUTH_FILE}`),
+    );
   });
 
-  it.skip('should create auth file when logged in', async () => {
-    mockfs();
+  it('should create auth file when logged in', async () => {
+    await sessionService.keyLogin(TEST_IMMICH_INSTANCE_URL, TEST_IMMICH_API_KEY);
 
-    await sessionService.keyLogin('https://test/api', 'pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg');
-
-    const data: string = await fs.promises.readFile('/config/auth.yml', 'utf8');
+    const data: string = await readTestAuthFile();
     const authConfig = yaml.parse(data);
-    expect(authConfig.instanceUrl).toBe('https://test/api');
-    expect(authConfig.apiKey).toBe('pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg');
+    expect(authConfig.instanceUrl).toBe(TEST_IMMICH_INSTANCE_URL);
+    expect(authConfig.apiKey).toBe(TEST_IMMICH_API_KEY);
   });
 
   it('should delete auth file when logging out', async () => {
-    mockfs({
-      '/config/auth.yml': 'apiKey: pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg\ninstanceUrl: https://test/api',
-    });
+    await createTestAuthFile(
+      JSON.stringify({
+        apiKey: TEST_IMMICH_API_KEY,
+        instanceUrl: TEST_IMMICH_INSTANCE_URL,
+      }),
+    );
     await sessionService.logout();
 
-    await fs.promises.access('/auth.yml', fs.constants.F_OK).catch((error) => {
+    await fs.promises.access(TEST_AUTH_FILE, fs.constants.F_OK).catch((error) => {
       expect(error.message).toContain('ENOENT');
     });
-  });
 
-  afterEach(() => {
-    mockfs.restore();
+    expect(consoleSpy.mock.calls).toEqual([[`Removed auth file ${TEST_AUTH_FILE}`]]);
   });
 });
diff --git a/cli/src/services/session.service.ts b/cli/src/services/session.service.ts
index d1c9d789c7..95cad8476f 100644
--- a/cli/src/services/session.service.ts
+++ b/cli/src/services/session.service.ts
@@ -5,33 +5,39 @@ import { ImmichApi } from '../api/client';
 import { LoginError } from '../cores/errors/login-error';
 
 export class SessionService {
-  readonly configDir: string;
+  readonly configDir!: string;
   readonly authPath!: string;
   private api!: ImmichApi;
 
   constructor(configDir: string) {
     this.configDir = configDir;
-    this.authPath = path.join(this.configDir, 'auth.yml');
+    this.authPath = path.join(configDir, '/auth.yml');
   }
 
   public async connect(): Promise<ImmichApi> {
-    await fs.promises.access(this.authPath, fs.constants.F_OK).catch((error) => {
-      if (error.code === 'ENOENT') {
-        throw new LoginError('No auth file exist. Please login first');
+    let instanceUrl = process.env.IMMICH_INSTANCE_URL;
+    let apiKey = process.env.IMMICH_API_KEY;
+
+    if (!instanceUrl || !apiKey) {
+      await fs.promises.access(this.authPath, fs.constants.F_OK).catch((error) => {
+        if (error.code === 'ENOENT') {
+          throw new LoginError('No auth file exist. Please login first');
+        }
+      });
+
+      const data: string = await fs.promises.readFile(this.authPath, 'utf8');
+      const parsedConfig = yaml.parse(data);
+
+      instanceUrl = parsedConfig.instanceUrl;
+      apiKey = parsedConfig.apiKey;
+
+      if (!instanceUrl) {
+        throw new LoginError(`Instance URL missing in auth config file ${this.authPath}`);
       }
-    });
 
-    const data: string = await fs.promises.readFile(this.authPath, 'utf8');
-    const parsedConfig = yaml.parse(data);
-    const instanceUrl: string = parsedConfig.instanceUrl;
-    const apiKey: string = parsedConfig.apiKey;
-
-    if (!instanceUrl) {
-      throw new LoginError('Instance URL missing in auth config file ' + this.authPath);
-    }
-
-    if (!apiKey) {
-      throw new LoginError('API key missing in auth config file ' + this.authPath);
+      if (!apiKey) {
+        throw new LoginError(`API key missing in auth config file ${this.authPath}`);
+      }
     }
 
     this.api = new ImmichApi(instanceUrl, apiKey);
@@ -59,10 +65,6 @@ export class SessionService {
       }
     }
 
-    if (!fs.existsSync(this.configDir)) {
-      console.error('waah');
-    }
-
     fs.writeFileSync(this.authPath, yaml.stringify({ instanceUrl, apiKey }));
 
     console.log('Wrote auth info to ' + this.authPath);
@@ -82,7 +84,7 @@ export class SessionService {
     });
 
     if (pingResponse.res !== 'pong') {
-      throw new Error('Unexpected ping reply');
+      throw new Error(`Could not parse response. Is Immich listening on ${this.api.apiConfiguration.instanceUrl}?`);
     }
   }
 }
diff --git a/cli/test/cli-test-utils.ts b/cli/test/cli-test-utils.ts
new file mode 100644
index 0000000000..f2f6ee1fe5
--- /dev/null
+++ b/cli/test/cli-test-utils.ts
@@ -0,0 +1,38 @@
+import { BaseOptionsDto } from 'src/cores/dto/base-options-dto';
+import fs from 'node:fs';
+import path from 'node:path';
+
+export const TEST_CONFIG_DIR = '/tmp/immich/';
+export const TEST_AUTH_FILE = path.join(TEST_CONFIG_DIR, 'auth.yml');
+export const TEST_IMMICH_INSTANCE_URL = 'https://test/api';
+export const TEST_IMMICH_API_KEY = 'pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg';
+
+export const CLI_BASE_OPTIONS: BaseOptionsDto = { config: TEST_CONFIG_DIR };
+
+export const spyOnConsole = () => jest.spyOn(console, 'log').mockImplementation();
+
+export const createTestAuthFile = async (contents: string) => {
+  if (!fs.existsSync(TEST_CONFIG_DIR)) {
+    // Create config folder if it doesn't exist
+    const created = await fs.promises.mkdir(TEST_CONFIG_DIR, { recursive: true });
+    if (!created) {
+      throw new Error(`Failed to create config folder ${TEST_CONFIG_DIR}`);
+    }
+  }
+
+  fs.writeFileSync(TEST_AUTH_FILE, contents);
+};
+
+export const readTestAuthFile = async (): Promise<string> => {
+  return await fs.promises.readFile(TEST_AUTH_FILE, 'utf8');
+};
+
+export const deleteAuthFile = () => {
+  try {
+    fs.unlinkSync(TEST_AUTH_FILE);
+  } catch (error: any) {
+    if (error.code !== 'ENOENT') {
+      throw error;
+    }
+  }
+};
diff --git a/cli/test/e2e/jest-e2e.json b/cli/test/e2e/jest-e2e.json
new file mode 100644
index 0000000000..f5e7726284
--- /dev/null
+++ b/cli/test/e2e/jest-e2e.json
@@ -0,0 +1,24 @@
+{
+  "moduleFileExtensions": ["js", "json", "ts"],
+  "modulePaths": ["<rootDir>"],
+  "rootDir": "../..",
+  "globalSetup": "<rootDir>/test/e2e/setup.ts",
+  "testEnvironment": "node",
+  "testRegex": ".e2e-spec.ts$",
+  "testTimeout": 6000000,
+  "transform": {
+    "^.+\\.(t|j)s$": "ts-jest"
+  },
+  "collectCoverageFrom": [
+    "<rootDir>/src/**/*.(t|j)s",
+    "!<rootDir>/src/**/*.spec.(t|s)s",
+    "!<rootDir>/src/infra/migrations/**"
+  ],
+  "coverageDirectory": "./coverage",
+  "moduleNameMapper": {
+    "^@test(|/.*)$": "<rootDir>../server/test/$1",
+    "^@app/immich(|/.*)$": "<rootDir>../server/src/immich/$1",
+    "^@app/infra(|/.*)$": "<rootDir>../server/src/infra/$1",
+    "^@app/domain(|/.*)$": "<rootDir>/../server/src/domain/$1"
+  }
+}
diff --git a/cli/test/e2e/login-key.e2e-spec.ts b/cli/test/e2e/login-key.e2e-spec.ts
new file mode 100644
index 0000000000..e952808c92
--- /dev/null
+++ b/cli/test/e2e/login-key.e2e-spec.ts
@@ -0,0 +1,48 @@
+import { api } from '@test/api';
+import { restoreTempFolder, testApp } from 'immich/test/test-utils';
+import { LoginResponseDto } from 'src/api/open-api';
+import { APIKeyCreateResponseDto } from '@app/domain';
+import LoginKey from 'src/commands/login/key';
+import { LoginError } from 'src/cores/errors/login-error';
+import { CLI_BASE_OPTIONS, spyOnConsole } from 'test/cli-test-utils';
+
+describe(`login-key (e2e)`, () => {
+  let server: any;
+  let admin: LoginResponseDto;
+  let apiKey: APIKeyCreateResponseDto;
+  let instanceUrl: string;
+  spyOnConsole();
+
+  beforeAll(async () => {
+    server = (await testApp.create()).getHttpServer();
+    if (!process.env.IMMICH_INSTANCE_URL) {
+      throw new Error('IMMICH_INSTANCE_URL environment variable not set');
+    } else {
+      instanceUrl = process.env.IMMICH_INSTANCE_URL;
+    }
+  });
+
+  afterAll(async () => {
+    await testApp.teardown();
+    await restoreTempFolder();
+  });
+
+  beforeEach(async () => {
+    await testApp.reset();
+    await restoreTempFolder();
+    await api.authApi.adminSignUp(server);
+    admin = await api.authApi.adminLogin(server);
+    apiKey = await api.apiKeyApi.createApiKey(server, admin.accessToken);
+    process.env.IMMICH_API_KEY = apiKey.secret;
+  });
+
+  it('should error when providing an invalid API key', async () => {
+    await expect(async () => await new LoginKey(CLI_BASE_OPTIONS).run(instanceUrl, 'invalid')).rejects.toThrow(
+      new LoginError(`Failed to connect to server ${instanceUrl}: Request failed with status code 401`),
+    );
+  });
+
+  it('should log in when providing the correct API key', async () => {
+    await new LoginKey(CLI_BASE_OPTIONS).run(instanceUrl, apiKey.secret);
+  });
+});
diff --git a/cli/test/e2e/server-info.e2e-spec.ts b/cli/test/e2e/server-info.e2e-spec.ts
new file mode 100644
index 0000000000..bead36133b
--- /dev/null
+++ b/cli/test/e2e/server-info.e2e-spec.ts
@@ -0,0 +1,42 @@
+import { api } from '@test/api';
+import { restoreTempFolder, testApp } from 'immich/test/test-utils';
+import { LoginResponseDto } from 'src/api/open-api';
+import ServerInfo from 'src/commands/server-info';
+import { APIKeyCreateResponseDto } from '@app/domain';
+import { CLI_BASE_OPTIONS, spyOnConsole } from 'test/cli-test-utils';
+
+describe(`server-info (e2e)`, () => {
+  let server: any;
+  let admin: LoginResponseDto;
+  let apiKey: APIKeyCreateResponseDto;
+  const consoleSpy = spyOnConsole();
+
+  beforeAll(async () => {
+    server = (await testApp.create()).getHttpServer();
+  });
+
+  afterAll(async () => {
+    await testApp.teardown();
+    await restoreTempFolder();
+  });
+
+  beforeEach(async () => {
+    await testApp.reset();
+    await restoreTempFolder();
+    await api.authApi.adminSignUp(server);
+    admin = await api.authApi.adminLogin(server);
+    apiKey = await api.apiKeyApi.createApiKey(server, admin.accessToken);
+    process.env.IMMICH_API_KEY = apiKey.secret;
+  });
+
+  it('should show server version', async () => {
+    await new ServerInfo(CLI_BASE_OPTIONS).run();
+
+    expect(consoleSpy.mock.calls).toEqual([
+      [expect.stringMatching(new RegExp('Server is running version \\d+.\\d+.\\d+'))],
+      [expect.stringMatching('Supported image types: .*')],
+      [expect.stringMatching('Supported video types: .*')],
+      ['Images: 0, Videos: 0, Total: 0'],
+    ]);
+  });
+});
diff --git a/cli/test/e2e/setup.ts b/cli/test/e2e/setup.ts
new file mode 100644
index 0000000000..822d20f6f3
--- /dev/null
+++ b/cli/test/e2e/setup.ts
@@ -0,0 +1,43 @@
+import path from 'path';
+import { PostgreSqlContainer } from '@testcontainers/postgresql';
+import { access } from 'fs/promises';
+
+export default async () => {
+  let IMMICH_TEST_ASSET_PATH: string = '';
+
+  if (process.env.IMMICH_TEST_ASSET_PATH === undefined) {
+    IMMICH_TEST_ASSET_PATH = path.normalize(`${__dirname}/../../../server/test/assets/`);
+    process.env.IMMICH_TEST_ASSET_PATH = IMMICH_TEST_ASSET_PATH;
+  } else {
+    IMMICH_TEST_ASSET_PATH = process.env.IMMICH_TEST_ASSET_PATH;
+  }
+
+  const directoryExists = async (dirPath: string) =>
+    await access(dirPath)
+      .then(() => true)
+      .catch(() => false);
+
+  if (!(await directoryExists(`${IMMICH_TEST_ASSET_PATH}/albums`))) {
+    throw new Error(
+      `Test assets not found. Please checkout https://github.com/immich-app/test-assets into ${IMMICH_TEST_ASSET_PATH} before testing`,
+    );
+  }
+
+  if (process.env.DB_HOSTNAME === undefined) {
+    // DB hostname not set which likely means we're not running e2e through docker compose. Start a local postgres container.
+    const pg = await new PostgreSqlContainer('tensorchord/pgvecto-rs:pg14-v0.1.11')
+      .withExposedPorts(5432)
+      .withDatabase('immich')
+      .withUsername('postgres')
+      .withPassword('postgres')
+      .withReuse()
+      .start();
+
+    process.env.DB_URL = pg.getConnectionUri();
+  }
+
+  process.env.NODE_ENV = 'development';
+  process.env.IMMICH_TEST_ENV = 'true';
+  process.env.IMMICH_CONFIG_FILE = path.normalize(`${__dirname}/../../../server/test/e2e/immich-e2e-config.json`);
+  process.env.TZ = 'Z';
+};
diff --git a/cli/test/e2e/upload.e2e-spec.ts b/cli/test/e2e/upload.e2e-spec.ts
new file mode 100644
index 0000000000..738d9125d1
--- /dev/null
+++ b/cli/test/e2e/upload.e2e-spec.ts
@@ -0,0 +1,49 @@
+import { api } from '@test/api';
+import { IMMICH_TEST_ASSET_PATH, restoreTempFolder, testApp } from 'immich/test/test-utils';
+import { LoginResponseDto } from 'src/api/open-api';
+import Upload from 'src/commands/upload';
+import { APIKeyCreateResponseDto } from '@app/domain';
+import { CLI_BASE_OPTIONS, spyOnConsole } from 'test/cli-test-utils';
+
+describe(`upload (e2e)`, () => {
+  let server: any;
+  let admin: LoginResponseDto;
+  let apiKey: APIKeyCreateResponseDto;
+  spyOnConsole();
+
+  beforeAll(async () => {
+    server = (await testApp.create()).getHttpServer();
+  });
+
+  afterAll(async () => {
+    await testApp.teardown();
+    await restoreTempFolder();
+  });
+
+  beforeEach(async () => {
+    await testApp.reset();
+    await restoreTempFolder();
+    await api.authApi.adminSignUp(server);
+    admin = await api.authApi.adminLogin(server);
+    apiKey = await api.apiKeyApi.createApiKey(server, admin.accessToken);
+    process.env.IMMICH_API_KEY = apiKey.secret;
+  });
+
+  it('should upload a folder recursively', async () => {
+    await new Upload(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], { recursive: true });
+    const assets = await api.assetApi.getAllAssets(server, admin.accessToken);
+    expect(assets.length).toBeGreaterThan(4);
+  });
+
+  it('should create album from folder name', async () => {
+    await new Upload(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], {
+      recursive: true,
+      album: true,
+    });
+
+    const albums = await api.albumApi.getAllAlbums(server, admin.accessToken);
+    expect(albums.length).toEqual(1);
+    const natureAlbum = albums[0];
+    expect(natureAlbum.albumName).toEqual('nature');
+  });
+});
diff --git a/cli/test/global-setup.js b/cli/test/global-setup.js
new file mode 100644
index 0000000000..6e1fbf41d0
--- /dev/null
+++ b/cli/test/global-setup.js
@@ -0,0 +1,3 @@
+module.exports = async () => {
+  process.env.TZ = 'UTC';
+};
diff --git a/cli/tsconfig.json b/cli/tsconfig.json
index b44be15d1c..d704a3163b 100644
--- a/cli/tsconfig.json
+++ b/cli/tsconfig.json
@@ -8,17 +8,24 @@
     "experimentalDecorators": true,
     "allowSyntheticDefaultImports": true,
     "resolveJsonModule": true,
-    "target": "es2022",
+    "target": "es2021",
     "moduleResolution": "node16",
     "sourceMap": true,
     "outDir": "./dist",
     "incremental": true,
     "skipLibCheck": true,
     "esModuleInterop": true,
+    "rootDirs": ["src", "../server/src"],
     "baseUrl": "./",
     "paths": {
-      "@test": ["test"],
-      "@test/*": ["test/*"]
+      "@test": ["../server/test"],
+      "@test/*": ["../server/test/*"],
+      "@app/immich": ["../server/src/immich"],
+      "@app/immich/*": ["../server/src/immich/*"],
+      "@app/infra": ["../server/src/infra"],
+      "@app/infra/*": ["../server/src/infra/*"],
+      "@app/domain": ["../server/src/domain"],
+      "@app/domain/*": ["../server/src/domain/*"]
     }
   },
   "exclude": ["dist", "node_modules", "upload"]
diff --git a/server/src/infra/infra.config.ts b/server/src/infra/infra.config.ts
index 5e001d9dc0..3758380a13 100644
--- a/server/src/infra/infra.config.ts
+++ b/server/src/infra/infra.config.ts
@@ -5,6 +5,7 @@ import { RedisOptions } from 'ioredis';
 
 function parseRedisConfig(): RedisOptions {
   if (process.env.IMMICH_TEST_ENV == 'true') {
+    // Currently running e2e tests, do not use redis
     return {};
   }
 
diff --git a/server/src/infra/infra.module.ts b/server/src/infra/infra.module.ts
index 56ea2b4878..4d5cadeb63 100644
--- a/server/src/infra/infra.module.ts
+++ b/server/src/infra/infra.module.ts
@@ -101,6 +101,7 @@ const imports = [
 const moduleExports = [...providers];
 
 if (process.env.IMMICH_TEST_ENV !== 'true') {
+  // Currently not running e2e tests, set up redis and bull queues
   imports.push(BullModule.forRoot(bullConfig));
   imports.push(BullModule.registerQueue(...bullQueues));
   moduleExports.push(BullModule);
diff --git a/server/test/api/album-api.ts b/server/test/api/album-api.ts
index 70a016da16..92c75dc64b 100644
--- a/server/test/api/album-api.ts
+++ b/server/test/api/album-api.ts
@@ -20,4 +20,9 @@ export const albumApi = {
     expect(res.status).toEqual(200);
     return res.body as AlbumResponseDto;
   },
+  getAllAlbums: async (server: any, accessToken: string) => {
+    const res = await request(server).get(`/album/`).set('Authorization', `Bearer ${accessToken}`).send();
+    expect(res.status).toEqual(200);
+    return res.body as AlbumResponseDto[];
+  },
 };
diff --git a/server/test/api/api-key-api.ts b/server/test/api/api-key-api.ts
new file mode 100644
index 0000000000..a35f13f7d9
--- /dev/null
+++ b/server/test/api/api-key-api.ts
@@ -0,0 +1,16 @@
+import { APIKeyCreateResponseDto } from '@app/domain';
+import { apiKeyCreateStub } from '@test';
+import request from 'supertest';
+
+export const apiKeyApi = {
+  createApiKey: async (server: any, accessToken: string) => {
+    const { status, body } = await request(server)
+      .post('/api-key')
+      .set('Authorization', `Bearer ${accessToken}`)
+      .send(apiKeyCreateStub);
+
+    expect(status).toBe(201);
+
+    return body as APIKeyCreateResponseDto;
+  },
+};
diff --git a/server/test/api/index.ts b/server/test/api/index.ts
index 55cf2526d7..21987c5004 100644
--- a/server/test/api/index.ts
+++ b/server/test/api/index.ts
@@ -1,5 +1,6 @@
 import { activityApi } from './activity-api';
 import { albumApi } from './album-api';
+import { apiKeyApi } from './api-key-api';
 import { assetApi } from './asset-api';
 import { authApi } from './auth-api';
 import { libraryApi } from './library-api';
@@ -10,6 +11,7 @@ import { userApi } from './user-api';
 export const api = {
   activityApi,
   authApi,
+  apiKeyApi,
   assetApi,
   libraryApi,
   sharedLinkApi,
diff --git a/docker/docker-compose.test.yml b/server/test/docker-compose.server-e2e.yml
similarity index 83%
rename from docker/docker-compose.test.yml
rename to server/test/docker-compose.server-e2e.yml
index dbedce2c67..350a7a9248 100644
--- a/docker/docker-compose.test.yml
+++ b/server/test/docker-compose.server-e2e.yml
@@ -1,18 +1,17 @@
-version: "3.8"
+version: '3.8'
 
-name: "immich-test-e2e"
+name: 'immich-test-e2e'
 
 services:
   immich-server:
     image: immich-server-dev:latest
     build:
-      context: ../
+      context: ../../
       dockerfile: server/Dockerfile
       target: dev
-    entrypoint: [ "/usr/local/bin/npm", "run" ]
+    entrypoint: ['/usr/local/bin/npm', 'run']
     command: test:e2e
     volumes:
-      - ../server:/usr/src/app
       - /usr/src/app/node_modules
     environment:
       - DB_HOSTNAME=database
diff --git a/server/test/e2e/activity.e2e-spec.ts b/server/test/e2e/activity.e2e-spec.ts
index 3fe38e7151..2029e5a160 100644
--- a/server/test/e2e/activity.e2e-spec.ts
+++ b/server/test/e2e/activity.e2e-spec.ts
@@ -15,7 +15,7 @@ describe(`${ActivityController.name} (e2e)`, () => {
   let nonOwner: LoginResponseDto;
 
   beforeAll(async () => {
-    [server] = await testApp.create();
+    server = (await testApp.create()).getHttpServer();
     await testApp.reset();
     await api.authApi.adminSignUp(server);
     admin = await api.authApi.adminLogin(server);
diff --git a/server/test/e2e/album.e2e-spec.ts b/server/test/e2e/album.e2e-spec.ts
index 8058d9593c..67dd546582 100644
--- a/server/test/e2e/album.e2e-spec.ts
+++ b/server/test/e2e/album.e2e-spec.ts
@@ -24,7 +24,7 @@ describe(`${AlbumController.name} (e2e)`, () => {
   let user2Albums: AlbumResponseDto[];
 
   beforeAll(async () => {
-    [server] = await testApp.create();
+    server = (await testApp.create()).getHttpServer();
   });
 
   afterAll(async () => {
diff --git a/server/test/e2e/asset.e2e-spec.ts b/server/test/e2e/asset.e2e-spec.ts
index ef9eff8093..2ca47902f0 100644
--- a/server/test/e2e/asset.e2e-spec.ts
+++ b/server/test/e2e/asset.e2e-spec.ts
@@ -63,7 +63,8 @@ describe(`${AssetController.name} (e2e)`, () => {
   };
 
   beforeAll(async () => {
-    [server, app] = await testApp.create();
+    app = await testApp.create();
+    server = app.getHttpServer();
     assetRepository = app.get<IAssetRepository>(IAssetRepository);
 
     await testApp.reset();
diff --git a/server/test/e2e/auth.e2e-spec.ts b/server/test/e2e/auth.e2e-spec.ts
index f7ab847723..2cf7c33dab 100644
--- a/server/test/e2e/auth.e2e-spec.ts
+++ b/server/test/e2e/auth.e2e-spec.ts
@@ -39,8 +39,7 @@ describe(`${AuthController.name} (e2e)`, () => {
   let accessToken: string;
 
   beforeAll(async () => {
-    await testApp.reset();
-    [server] = await testApp.create();
+    server = (await testApp.create()).getHttpServer();
   });
 
   afterAll(async () => {
diff --git a/server/test/e2e/formats.e2e-spec.ts b/server/test/e2e/formats.e2e-spec.ts
index 40f43a92a7..7f25f2d761 100644
--- a/server/test/e2e/formats.e2e-spec.ts
+++ b/server/test/e2e/formats.e2e-spec.ts
@@ -90,10 +90,7 @@ describe(`Supported file formats (e2e)`, () => {
         iso: 20,
         focalLength: 3.99,
         fNumber: 1.8,
-        state: 'Douglas County, Nebraska',
         timeZone: 'America/Chicago',
-        city: 'Ralston',
-        country: 'United States of America',
       },
     },
     {
@@ -168,7 +165,7 @@ describe(`Supported file formats (e2e)`, () => {
   const testsToRun = formatTests.filter((formatTest) => formatTest.runTest);
 
   beforeAll(async () => {
-    [server] = await testApp.create({ jobs: true });
+    server = (await testApp.create({ jobs: true })).getHttpServer();
   });
 
   afterAll(async () => {
diff --git a/server/test/e2e/immich-e2e-config.json b/server/test/e2e/immich-e2e-config.json
new file mode 100644
index 0000000000..39fbf9c24f
--- /dev/null
+++ b/server/test/e2e/immich-e2e-config.json
@@ -0,0 +1,11 @@
+{
+  "reverseGeocoding": {
+    "enabled": false
+  },
+  "machineLearning": {
+    "enabled": false
+  },
+  "logging": {
+    "enabled": false
+  }
+}
diff --git a/server/test/e2e/library.e2e-spec.ts b/server/test/e2e/library.e2e-spec.ts
index 92c604e005..bb31d6c8b0 100644
--- a/server/test/e2e/library.e2e-spec.ts
+++ b/server/test/e2e/library.e2e-spec.ts
@@ -13,7 +13,7 @@ describe(`${LibraryController.name} (e2e)`, () => {
   let admin: LoginResponseDto;
 
   beforeAll(async () => {
-    [server] = await testApp.create({ jobs: true });
+    server = (await testApp.create({ jobs: true })).getHttpServer();
   });
 
   afterAll(async () => {
diff --git a/server/test/e2e/oauth.e2e-spec.ts b/server/test/e2e/oauth.e2e-spec.ts
index b2c997076e..422ced54ba 100644
--- a/server/test/e2e/oauth.e2e-spec.ts
+++ b/server/test/e2e/oauth.e2e-spec.ts
@@ -8,7 +8,7 @@ describe(`${OAuthController.name} (e2e)`, () => {
   let server: any;
 
   beforeAll(async () => {
-    [server] = await testApp.create();
+    server = (await testApp.create()).getHttpServer();
   });
 
   afterAll(async () => {
diff --git a/server/test/e2e/partner.e2e-spec.ts b/server/test/e2e/partner.e2e-spec.ts
index 512491b696..ac83e90cf9 100644
--- a/server/test/e2e/partner.e2e-spec.ts
+++ b/server/test/e2e/partner.e2e-spec.ts
@@ -12,7 +12,7 @@ describe(`${PartnerController.name} (e2e)`, () => {
   let user3: LoginResponseDto;
 
   beforeAll(async () => {
-    [server] = await testApp.create();
+    server = (await testApp.create()).getHttpServer();
 
     await testApp.reset();
     await api.authApi.adminSignUp(server);
diff --git a/server/test/e2e/person.e2e-spec.ts b/server/test/e2e/person.e2e-spec.ts
index 82903ce63a..ab33b05b35 100644
--- a/server/test/e2e/person.e2e-spec.ts
+++ b/server/test/e2e/person.e2e-spec.ts
@@ -17,7 +17,8 @@ describe(`${PersonController.name}`, () => {
   let hiddenPerson: PersonEntity;
 
   beforeAll(async () => {
-    [server, app] = await testApp.create();
+    app = await testApp.create();
+    server = app.getHttpServer();
     personRepository = app.get<IPersonRepository>(IPersonRepository);
   });
 
diff --git a/server/test/e2e/search.e2e-spec.ts b/server/test/e2e/search.e2e-spec.ts
index d8668767c0..04b421c88c 100644
--- a/server/test/e2e/search.e2e-spec.ts
+++ b/server/test/e2e/search.e2e-spec.ts
@@ -24,7 +24,8 @@ describe(`${SearchController.name}`, () => {
   let asset1: AssetResponseDto;
 
   beforeAll(async () => {
-    [server, app] = await testApp.create();
+    app = await testApp.create();
+    server = app.getHttpServer();
     assetRepository = app.get<IAssetRepository>(IAssetRepository);
     smartInfoRepository = app.get<ISmartInfoRepository>(ISmartInfoRepository);
   });
diff --git a/server/test/e2e/server-info.e2e-spec.ts b/server/test/e2e/server-info.e2e-spec.ts
index 08ec0e2cfd..70330a0b24 100644
--- a/server/test/e2e/server-info.e2e-spec.ts
+++ b/server/test/e2e/server-info.e2e-spec.ts
@@ -11,7 +11,7 @@ describe(`${ServerInfoController.name} (e2e)`, () => {
   let nonAdmin: LoginResponseDto;
 
   beforeAll(async () => {
-    [server] = await testApp.create();
+    server = (await testApp.create()).getHttpServer();
 
     await testApp.reset();
     await api.authApi.adminSignUp(server);
@@ -74,10 +74,10 @@ describe(`${ServerInfoController.name} (e2e)`, () => {
       expect(status).toBe(200);
       expect(body).toEqual({
         clipEncode: false,
-        configFile: false,
+        configFile: true,
         facialRecognition: false,
         map: true,
-        reverseGeocoding: true,
+        reverseGeocoding: false,
         oauth: false,
         oauthAutoLaunch: false,
         passwordLogin: true,
diff --git a/server/test/e2e/setup.ts b/server/test/e2e/setup.ts
index 75de2d0b00..1b9fcf239d 100644
--- a/server/test/e2e/setup.ts
+++ b/server/test/e2e/setup.ts
@@ -8,8 +8,8 @@ export default async () => {
   if (!allTests) {
     console.warn(
       `\n\n
-      *** Not running all e2e tests. Run 'make test-e2e' to run all tests inside Docker (recommended)\n
-      *** or set 'IMMICH_RUN_ALL_TESTS=true' to run all tests(requires dependencies to be installed)\n`,
+      *** Not running all server e2e tests. Run 'make test-e2e' to run all tests inside Docker (recommended)\n
+      *** or set 'IMMICH_RUN_ALL_TESTS=true' to run all tests (requires dependencies to be installed)\n`,
     );
   }
 
@@ -47,7 +47,7 @@ export default async () => {
   }
 
   process.env.NODE_ENV = 'development';
-  process.env.IMMICH_MACHINE_LEARNING_ENABLED = 'false';
   process.env.IMMICH_TEST_ENV = 'true';
+  process.env.IMMICH_CONFIG_FILE = path.normalize(`${__dirname}/immich-e2e-config.json`);
   process.env.TZ = 'Z';
 };
diff --git a/server/test/e2e/shared-link.e2e-spec.ts b/server/test/e2e/shared-link.e2e-spec.ts
index 787af1bfe8..80a4f11392 100644
--- a/server/test/e2e/shared-link.e2e-spec.ts
+++ b/server/test/e2e/shared-link.e2e-spec.ts
@@ -33,7 +33,8 @@ describe(`${SharedLinkController.name} (e2e)`, () => {
   let app: INestApplication<any>;
 
   beforeAll(async () => {
-    [server, app] = await testApp.create();
+    app = await testApp.create();
+    server = app.getHttpServer();
     const assetRepository = app.get<IAssetRepository>(IAssetRepository);
 
     await testApp.reset();
diff --git a/server/test/e2e/system-config.e2e-spec.ts b/server/test/e2e/system-config.e2e-spec.ts
index 15ed54da34..3e9ae040d5 100644
--- a/server/test/e2e/system-config.e2e-spec.ts
+++ b/server/test/e2e/system-config.e2e-spec.ts
@@ -11,7 +11,7 @@ describe(`${SystemConfigController.name} (e2e)`, () => {
   let nonAdmin: LoginResponseDto;
 
   beforeAll(async () => {
-    [server] = await testApp.create();
+    server = (await testApp.create()).getHttpServer();
 
     await testApp.reset();
     await api.authApi.adminSignUp(server);
diff --git a/server/test/e2e/user.e2e-spec.ts b/server/test/e2e/user.e2e-spec.ts
index 41f393b7d5..6517629be8 100644
--- a/server/test/e2e/user.e2e-spec.ts
+++ b/server/test/e2e/user.e2e-spec.ts
@@ -18,7 +18,8 @@ describe(`${UserController.name}`, () => {
   let userRepository: Repository<UserEntity>;
 
   beforeAll(async () => {
-    [server, app] = await testApp.create();
+    app = await testApp.create();
+    server = app.getHttpServer();
     userRepository = app.select(AppModule).get(getRepositoryToken(UserEntity));
   });
 
diff --git a/server/test/fixtures/api-key.stub.ts b/server/test/fixtures/api-key.stub.ts
index 582afcfb59..de1b8dc176 100644
--- a/server/test/fixtures/api-key.stub.ts
+++ b/server/test/fixtures/api-key.stub.ts
@@ -11,3 +11,7 @@ export const keyStub = {
     user: userStub.admin,
   } as APIKeyEntity),
 };
+
+export const apiKeyCreateStub = {
+  name: 'API Key',
+};
diff --git a/server/test/test-utils.ts b/server/test/test-utils.ts
index c870123b09..9fac33427e 100644
--- a/server/test/test-utils.ts
+++ b/server/test/test-utils.ts
@@ -4,10 +4,12 @@ import { dataSource, databaseChecks } from '@app/infra';
 import { AssetEntity, AssetType, LibraryType } from '@app/infra/entities';
 import { INestApplication } from '@nestjs/common';
 import { Test } from '@nestjs/testing';
+
 import { randomBytes } from 'crypto';
 import * as fs from 'fs';
 import { DateTime } from 'luxon';
 import path from 'path';
+import { Server } from 'tls';
 import { EntityTarget, ObjectLiteral } from 'typeorm';
 import { AppService } from '../src/microservices/app.service';
 
@@ -61,7 +63,7 @@ interface TestAppOptions {
 let app: INestApplication;
 
 export const testApp = {
-  create: async (options?: TestAppOptions): Promise<[any, INestApplication]> => {
+  create: async (options?: TestAppOptions): Promise<INestApplication> => {
     const { jobs } = options || { jobs: false };
 
     const moduleFixture = await Test.createTestingModule({ imports: [AppModule], providers: [AppService] })
@@ -84,20 +86,27 @@ export const testApp = {
       .compile();
 
     app = await moduleFixture.createNestApplication().init();
+    await app.listen(0);
 
     if (jobs) {
       await app.get(AppService).init();
     }
 
-    return [app.getHttpServer(), app];
+    const port = app.getHttpServer().address().port;
+    const protocol = app instanceof Server ? 'https' : 'http';
+    process.env.IMMICH_INSTANCE_URL = protocol + '://127.0.0.1:' + port;
+
+    return app;
   },
   reset: async (options?: ResetOptions) => {
     await db.reset(options);
   },
   teardown: async () => {
-    await app.get(AppService).teardown();
+    if (app) {
+      await app.get(AppService).teardown();
+      await app.close();
+    }
     await db.disconnect();
-    await app.close();
   },
 };