Compare commits

...

117 commits

Author SHA1 Message Date
Jonathan Jogenfors
8dac9ed053 Merge branch 'main' of https://github.com/immich-app/immich into feat/cli-e2e 2023-11-29 09:34:34 +01:00
Jonathan Jogenfors
21c73baf30 Merge branch 'main' of https://github.com/immich-app/immich into feat/cli-e2e 2023-11-23 10:45:25 +01:00
Jonathan Jogenfors
76a1abe7bd don't touch package lock 2023-11-23 10:45:20 +01:00
Jonathan Jogenfors
50627a0bb5 Merge branch 'main' of https://github.com/immich-app/immich into feat/cli-e2e 2023-11-22 14:00:10 +01:00
Jonathan Jogenfors
1279d42e0b cApiTALiZaTiON 2023-11-22 13:59:54 +01:00
Jonathan Jogenfors
e9fc2b8ff7 version bump 2023-11-22 09:56:01 +01:00
Jonathan Jogenfors
7c2634b7cb Merge branch 'main' of https://github.com/immich-app/immich into feat/cli-e2e 2023-11-22 09:36:06 +01:00
Jonathan Jogenfors
fd1eb95220 Merge branch 'main' of https://github.com/immich-app/immich into feat/cli-e2e 2023-11-21 23:25:51 +01:00
Jonathan Jogenfors
0bfb6ea235 Merge branch 'main' of https://github.com/immich-app/immich into feat/cli-e2e 2023-11-21 21:51:12 +01:00
Jonathan Jogenfors
ac026f32cb wip use axios as upload 2023-11-21 21:37:03 +01:00
Jonathan Jogenfors
e35b9d51f2 fix lint 2023-11-21 12:15:38 +01:00
Jonathan Jogenfors
53657c09a5 bump dependencies 2023-11-21 12:11:29 +01:00
Jonathan Jogenfors
b2df2f59c4 add version option 2023-11-21 12:09:12 +01:00
Jonathan Jogenfors
35359585cb feat: allow single files as argument 2023-11-21 11:58:41 +01:00
Jonathan Jogenfors
d2ecb5ca4d remove form-data 2023-11-21 11:45:57 +01:00
Jonathan Jogenfors
3593bda2fe bump cli version to 2.0.4 2023-11-21 11:44:12 +01:00
Jonathan Jogenfors
daad3efb19 cleanup 2023-11-21 11:44:01 +01:00
Jonathan Jogenfors
fb69b59314 create missing dir 2023-11-21 11:34:40 +01:00
Jonathan Jogenfors
4a40a48c34 fix session service tests 2023-11-21 11:27:09 +01:00
Jonathan Jogenfors
7c6ee25f89 feat: allow configurable config dir 2023-11-21 10:00:51 +01:00
Jonathan Jogenfors
19f83f3c79 Merge branch 'main' of https://github.com/immich-app/immich into feat/cli-e2e 2023-11-21 08:48:09 +01:00
Jonathan Jogenfors
ae3cb5adf6 test key login flow 2023-11-21 00:30:44 +01:00
Jonathan Jogenfors
8d4bc7f6a8 fix lint 2023-11-20 23:50:51 +01:00
Jonathan Jogenfors
f6889c4d45 Merge branch 'main' of https://github.com/immich-app/immich into feat/cli-e2e 2023-11-20 23:48:28 +01:00
Jonathan Jogenfors
c7ed7877f5 check e2e code in lint 2023-11-20 23:48:23 +01:00
Jonathan Jogenfors
191e8fcd77 Merge branch 'chore/publish-npm-cli-dir' of https://github.com/immich-app/immich into feat/cli-e2e 2023-11-20 14:35:56 +01:00
Jonathan Jogenfors
f63bd80dce Merge branch 'main' of https://github.com/immich-app/immich into feat/cli-e2e 2023-11-20 14:35:48 +01:00
Jonathan Jogenfors
6a423433ed Merge branch 'main' of https://github.com/immich-app/immich into chore/publish-npm-cli-dir 2023-11-20 14:32:01 +01:00
Jonathan Jogenfors
507c7e963c chore: normalize the github url 2023-11-20 14:31:53 +01:00
Jonathan Jogenfors
ca381bd2ac chore: bump node setup to v4 2023-11-20 14:24:51 +01:00
Jonathan Jogenfors
f5df0bc6f2 chore: add repo url to npmjs 2023-11-20 14:15:39 +01:00
Jonathan Jogenfors
46d35c74c5 chore: set cli release working dir 2023-11-20 14:14:56 +01:00
Jonathan Jogenfors
c8d417ccda Merge branch 'main' of https://github.com/immich-app/immich into feat/cli-e2e 2023-11-20 14:05:28 +01:00
Jonathan Jogenfors
c124282514 chore: split cli e2e tests into one file per command 2023-11-20 13:56:01 +01:00
Jonathan Jogenfors
6421fcf2f8 assert on server version 2023-11-20 13:50:48 +01:00
Jonathan Jogenfors
eb939cd4f6 cleanup github test names 2023-11-20 13:50:37 +01:00
Jonathan Jogenfors
57ff53beb6 make github actions run makefile e2e tests 2023-11-20 12:57:52 +01:00
Jonathan Jogenfors
bbe6795e33 increase test timeout to 10 minutes 2023-11-20 12:56:10 +01:00
Jonathan Jogenfors
e1c3634490 use new e2e composes in makefile 2023-11-20 12:54:07 +01:00
Jonathan Jogenfors
1080d6a26e reshuffle docker compose files 2023-11-20 11:35:44 +01:00
Jonathan Jogenfors
cf5fb63759 Merge branch 'chore/push-to-npm' of https://github.com/immich-app/immich into feat/cli-e2e 2023-11-20 09:59:57 +01:00
Jonathan Jogenfors
d976140227 run npm ci in server 2023-11-20 09:37:23 +01:00
Jonathan Jogenfors
dfbabf5da7 remove state from e2e 2023-11-20 09:36:17 +01:00
Jonathan Jogenfors
8815d81d00 run npm ci in server 2023-11-20 08:57:31 +01:00
Jonathan Jogenfors
04954c5616 fix: server e2e 2023-11-20 08:54:33 +01:00
Jonathan Jogenfors
713b13cc57 Merge branches 'feat/cli-e2e' and 'main' of https://github.com/immich-app/immich into feat/cli-e2e 2023-11-20 08:36:10 +01:00
Jason Rasmussen
92e4bbca23
chore(cli): push to npm 2023-11-19 19:55:55 -05:00
Jonathan Jogenfors
c5002693a8 chore: add npmignore 2023-11-20 00:11:07 +01:00
Jonathan Jogenfors
c45f68bc46 remove submodule 2023-11-19 23:59:38 +01:00
Jonathan Jogenfors
0388a8a79b Merge branch 'main' of https://github.com/immich-app/immich into feat/cli-e2e 2023-11-19 23:56:49 +01:00
Jonathan Jogenfors
6d3471b6f3 Merge branch 'feat/cli-albums' of https://github.com/immich-app/immich into feat/cli-e2e 2023-11-19 23:14:23 +01:00
Jonathan Jogenfors
b11d7668a6 fix: set correct cli date 2023-11-19 23:11:52 +01:00
Jonathan Jogenfors
ca97af6c12 feat: add cli v2 milestone 2023-11-19 23:08:51 +01:00
Jonathan Jogenfors
7257780ecd chore: fix docs links 2023-11-19 22:57:47 +01:00
Jonathan Jogenfors
74a336d1e6 Merge branch 'main' of https://github.com/immich-app/immich into feat/cli-albums 2023-11-19 22:38:33 +01:00
Jonathan Jogenfors
4e099cc8cb remove cli from dockerignore 2023-11-19 22:38:15 +01:00
Alex
afe1470965
Merge branch 'main' of github.com:immich-app/immich into feat/cli-albums 2023-11-19 10:06:16 -06:00
Jonathan Jogenfors
b00a7d63cf Merge branch 'main' of https://github.com/immich-app/immich into feat/cli-e2e 2023-11-19 00:32:29 +01:00
Jonathan Jogenfors
3ca69601a8 correct docker build 2023-11-19 00:28:14 +01:00
Jonathan Jogenfors
2981a383d2 set docker context 2023-11-17 23:47:37 +01:00
Jonathan Jogenfors
5ede8a8bcc run cli e2e in correct folder 2023-11-17 15:59:29 +01:00
Jonathan Jogenfors
3c8e3e6e8b fix cli docker file 2023-11-17 15:56:55 +01:00
Jonathan Jogenfors
6e4a1c10d0 cli docker e2e tests 2023-11-17 15:53:31 +01:00
Jonathan Jogenfors
ef3d2683d9 set node version 2023-11-17 13:16:11 +01:00
Jonathan Jogenfors
ec37247d95 increase test timeout 2023-11-17 13:10:43 +01:00
Jonathan Jogenfors
c776ef4f16 check out submodules too 2023-11-17 12:22:02 +01:00
Jonathan Jogenfors
0efd9a65b6 choose different working directory 2023-11-17 12:11:11 +01:00
Jonathan Jogenfors
b0b8ce2d6d run npm ci in server 2023-11-17 12:09:48 +01:00
Jonathan Jogenfors
7a2c27484c set matrix strategy in workflow 2023-11-17 12:07:30 +01:00
Jonathan Jogenfors
36e8397ce2 cleanup 2023-11-17 11:59:18 +01:00
Jonathan Jogenfors
9ba8565730 simplify e2e test 2023-11-17 11:47:50 +01:00
Jonathan Jogenfors
969b0b7367 Merge branch 'main' of https://github.com/immich-app/immich into feat/cli-e2e 2023-11-17 11:16:53 +01:00
Jonathan Jogenfors
4c3a1ca168 can do e2e tests in the cli 2023-11-17 11:16:48 +01:00
Jonathan Jogenfors
11adf2e906 add cli e2e to github actions 2023-11-17 09:51:04 +01:00
Jonathan Jogenfors
5e56b65b38 git ignore for geocode in cli 2023-11-17 00:58:36 +01:00
Jonathan Jogenfors
ebd408e06a e2e test can start 2023-11-17 00:30:18 +01:00
Jonathan Jogenfors
f77b833eea Merge branch 'feat/cli-albums' of https://github.com/immich-app/immich into feat/cli-e2e 2023-11-17 00:19:36 +01:00
Jonathan Jogenfors
9ba35e9bbd Merge branch 'main' of https://github.com/immich-app/immich into feat/cli-albums 2023-11-17 00:17:50 +01:00
Jonathan Jogenfors
448f5f9227 chore: remove unneeded packages 2023-11-17 00:17:41 +01:00
Jonathan Jogenfors
11c4051bd8 wip 2023-11-17 00:14:01 +01:00
Jonathan Jogenfors
67827e0929 chore: cleanup 2023-11-16 16:10:59 +01:00
Jonathan Jogenfors
1724eb6f6f feat: add more info to server-info command 2023-11-16 16:09:16 +01:00
Jonathan Jogenfors
a9cbd573f4 Merge branch 'main' of https://github.com/immich-app/immich into feat/cli-albums 2023-11-16 15:38:24 +01:00
Jonathan Jogenfors
6e04f41f1e chore: remove skipped asset field 2023-11-16 15:38:16 +01:00
Jonathan Jogenfors
4be93e5af8 feat: album name on windows 2023-11-16 14:45:20 +01:00
Jonathan Jogenfors
ef3a37726b fix: cleanup 2023-11-16 14:39:09 +01:00
Jonathan Jogenfors
60a622aa33 feat: use sdk 2023-11-16 14:35:50 +01:00
Jonathan Jogenfors
8ee629fd16 Merge branch 'main' of https://github.com/immich-app/immich into feat/cli-albums 2023-11-14 22:55:19 +01:00
Jonathan Jogenfors
927718fa32 Merge branch 'main' of https://github.com/immich-app/immich into feat/cli-albums 2023-11-14 09:06:26 +01:00
Jonathan Jogenfors
eee3b7f789 chore: add version number to cli 2023-11-13 23:52:32 +01:00
Jonathan Jogenfors
8471bf569e chore: rename ignore pattern 2023-11-13 23:36:58 +01:00
Jonathan Jogenfors
ad7ac83c2e Merge branch 'main' of https://github.com/immich-app/immich into feat/cli-albums 2023-11-13 23:19:23 +01:00
Jonathan Jogenfors
97e2ea9607 docs: add cli documentation 2023-11-13 23:19:20 +01:00
Jonathan Jogenfors
b4ca48b246 chore: fix lint 2023-11-13 09:39:02 +01:00
Jonathan Jogenfors
7c40e36e35 Merge branch 'main' of https://github.com/immich-app/immich into feat/cli-albums 2023-11-13 09:23:01 +01:00
Jonathan Jogenfors
633def4955 chore: use crawl service from server 2023-11-13 09:22:58 +01:00
Jonathan Jogenfors
89993314b1 Merge branch 'main' of https://github.com/immich-app/immich into feat/cli-albums 2023-11-11 23:46:47 +01:00
Jonathan Jogenfors
1c4cd3eea4 feat: pull file formats from server 2023-11-11 23:46:29 +01:00
Jonathan Jogenfors
b438c6b514 Merge branch 'main' of https://github.com/immich-app/immich into feat/cli-albums 2023-11-11 23:23:06 +01:00
Jonathan Jogenfors
0d39474fc9 fix: typos 2023-11-11 00:51:02 +01:00
Jonathan Jogenfors
b8c5744e0f fix: add check step to cli 2023-11-11 00:49:55 +01:00
Jonathan Jogenfors
d68a9d2c5e feat: can create albums 2023-11-11 00:43:43 +01:00
Jonathan Jogenfors
02152082d1 fix: success message spacing 2023-11-10 21:14:51 +01:00
Jonathan Jogenfors
f962e8be42 docs: add compilation step to cli 2023-11-10 21:00:30 +01:00
Jonathan Jogenfors
39026072be Merge branch 'main' of https://github.com/immich-app/immich into feat/cli-albums 2023-11-10 20:59:49 +01:00
Jonathan Jogenfors
e76c871740 docs: add todo for file format from server 2023-11-10 20:59:46 +01:00
Jonathan Jogenfors
015d8d8267 feat: add logout to cli 2023-11-10 20:59:16 +01:00
Jonathan Jogenfors
f588416659 chore: remove import functionality from cli 2023-11-10 10:16:59 +01:00
Jonathan Jogenfors
db28694f68 cleanup 2023-11-09 14:44:24 +01:00
Jonathan Jogenfors
06d78d2dac docs: add info on running without building 2023-11-09 12:03:20 +01:00
Jonathan Jogenfors
9d25a60455 feat: rewrite cli readme 2023-11-08 17:34:38 +01:00
Jonathan Jogenfors
55615bcd15 feat: use immich scoped package 2023-11-08 17:11:35 +01:00
Jonathan Jogenfors
b2bbdf423a Merge branch 'main' of https://github.com/immich-app/immich into feat/cli-albums 2023-11-08 16:30:30 +01:00
Jonathan Jogenfors
a983eddb55 docs: remove cli folder 2023-10-11 11:48:35 +02:00
Jonathan Jogenfors
1b7c8a29cb feat: add format fix 2023-10-11 10:59:08 +02:00
Jonathan Jogenfors
4439bc2355 Merge branch 'main' of https://github.com/immich-app/immich into feat/cli-albums 2023-10-11 10:46:57 +02:00
Jonathan Jogenfors
1d385b6cbe Allow building and installing cli 2023-10-04 23:56:12 +02:00
47 changed files with 759 additions and 148 deletions

View file

@ -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/

View file

@ -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,14 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Run npm install
- name: Run npm install in cli
run: npm ci
- uses: chill-viking/npm-ci@latest
name: Run npm install in server
with:
working_directory: "server"
- name: Run linter
run: npm run lint
if: ${{ !cancelled() }}
@ -109,6 +114,23 @@ jobs:
run: npm run test:cov
if: ${{ !cancelled() }}
cli-e2e-tests:
name: CLI (e2e)
strategy:
matrix:
os: [ubuntu-latest] # TODO: macos and windows
runs-on: ${{ matrix.os }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
submodules: "recursive"
- name: Run e2e tests
run: make test-cli-e2e
web-unit-tests:
name: Web
runs-on: ubuntu-latest

View file

@ -16,8 +16,11 @@ 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
test-cli-e2e:
docker compose -f ./cli/test/docker-compose.cli-e2e.yml up --renew-anon-volumes --abort-on-container-exit --exit-code-from immich-cli --remove-orphans --build
prod:
docker compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans

4
cli/.gitignore vendored
View file

@ -10,4 +10,6 @@ oclif.manifest.json
.vscode
.idea
/coverage/
/coverage/
.reverse-geocoding-dump/
upload/

View file

@ -1,4 +1,6 @@
**/*.spec.js
test/**
upload/**
.editorconfig
.eslintignore
.eslintrc.js

19
cli/Dockerfile Normal file
View file

@ -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"]

231
cli/package-lock.json generated
View file

@ -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",
@ -37,6 +37,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 +50,106 @@
"typescript": "^5.0.0"
}
},
"../server": {
"version": "1.88.2",
"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/redis-adapter": "^8.2.1",
"archiver": "^6.0.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",
"local-reverse-geocoder": "0.16.5",
"lodash": "^4.17.21",
"luxon": "^3.4.2",
"mv": "^2.1.1",
"nest-commander": "^3.11.1",
"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.32.6",
"thumbhash": "^0.1.1",
"typeorm": "^0.3.17",
"typesense": "^1.7.1",
"ua-parser-js": "^1.0.35"
},
"bin": {
"immich": "bin/cli.sh",
"immich-admin": "bin/admin-cli.sh"
},
"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/bcrypt": "^5.0.0",
"@types/cookie-parser": "^1.4.3",
"@types/cron": "^2.0.1",
"@types/express": "^4.17.17",
"@types/fluent-ffmpeg": "^2.1.21",
"@types/imagemin": "^8.0.1",
"@types/jest": "29.5.9",
"@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",
"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",
@ -1519,9 +1620,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": {
@ -1567,9 +1668,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",
@ -3891,6 +3992,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",
@ -7905,9 +8010,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": {
@ -7953,9 +8058,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",
@ -7990,9 +8095,9 @@
}
},
"@types/node": {
"version": "20.10.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.0.tgz",
"integrity": "sha512-D0WfRmU9TQ8I9PFx9Yc+EBHw+vSpIub4IDvQivcp26PtPrdMGAq5SDcpXEo/epqa/DXotVpekHiLNTg3iaKXBQ==",
"version": "20.9.4",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.9.4.tgz",
"integrity": "sha512-wmyg8HUhcn6ACjsn8oKYjkN/zUzQeNtMy44weTJSM6p4MMzEOuKbA3OjJ267uPCOW7Xex9dyrNTful8XTQYoDA==",
"dev": true,
"requires": {
"undici-types": "~5.26.4"
@ -9547,6 +9652,98 @@
"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/redis-adapter": "^8.2.1",
"@testcontainers/postgresql": "^10.2.1",
"@types/archiver": "^6.0.0",
"@types/bcrypt": "^5.0.0",
"@types/cookie-parser": "^1.4.3",
"@types/cron": "^2.0.1",
"@types/express": "^4.17.17",
"@types/fluent-ffmpeg": "^2.1.21",
"@types/imagemin": "^8.0.1",
"@types/jest": "29.5.9",
"@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",
"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",
"local-reverse-geocoder": "0.16.5",
"lodash": "^4.17.21",
"luxon": "^3.4.2",
"mock-fs": "^5.2.0",
"mv": "^2.1.1",
"nest-commander": "^3.11.1",
"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.32.6",
"source-map-support": "^0.5.21",
"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",
"typesense": "^1.7.1",
"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",

View file

@ -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": {
@ -35,6 +35,7 @@
"eslint": "^8.43.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-jest": "^27.2.2",
"immich": "file:../server",
"eslint-plugin-prettier": "^5.0.0",
"eslint-plugin-unicorn": "^49.0.0",
"jest": "^29.5.0",
@ -50,13 +51,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 +74,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"

View file

@ -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> {

View file

@ -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(
{

37
cli/src/constants.ts Normal file
View file

@ -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);

View file

@ -0,0 +1,3 @@
export class BaseOptionsDto {
config?: string;
}

View file

@ -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;
}

View file

@ -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);
}
}

View file

@ -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),

View file

@ -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);

View file

@ -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}`]]);
});
});

View file

@ -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}?`);
}
}
}

View file

@ -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;
}
}
};

View file

@ -0,0 +1,31 @@
version: '3.8'
name: 'immich-test-cli-e2e'
services:
immich-cli:
image: immich-cli-dev:latest
build:
context: ../../
dockerfile: cli/Dockerfile
target: test
working_dir: /usr/src/app/cli
command: npm run test:e2e
environment:
- DB_HOSTNAME=database
- DB_USERNAME=postgres
- DB_PASSWORD=postgres
- DB_DATABASE_NAME=e2e_test
- IMMICH_RUN_ALL_TESTS=true
depends_on:
- database
database:
image: postgres:14-alpine@sha256:50d9be76e9a90da4c781554955e0ffc79d9d5c4226838e64b36aacc97cbc35ad
command: -c fsync=off
environment:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
POSTGRES_DB: e2e_test
logging:
driver: none

View file

@ -0,0 +1,24 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"modulePaths": ["<rootDir>"],
"rootDir": "../..",
"globalSetup": "<rootDir>/../server/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"
}
}

View file

@ -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({ jobs: true })).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);
});
});

View file

@ -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({ jobs: true })).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'],
]);
});
});

View file

@ -0,0 +1,46 @@
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({ jobs: true })).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);
});
});

3
cli/test/global-setup.js Normal file
View file

@ -0,0 +1,3 @@
module.exports = async () => {
process.env.TZ = 'UTC';
};

View file

@ -8,7 +8,7 @@
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"target": "es2022",
"target": "es2021",
"moduleResolution": "node16",
"sourceMap": true,
"outDir": "./dist",
@ -17,8 +17,14 @@
"esModuleInterop": true,
"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"]

View file

@ -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[];
},
};

View file

@ -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;
},
};

View file

@ -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,

View file

@ -1,17 +1,16 @@
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
command: npm run test:e2e
volumes:
- ../server:/usr/src/app
- /usr/src/app/node_modules
environment:
- DB_HOSTNAME=database

View file

@ -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);

View file

@ -24,7 +24,7 @@ describe(`${AlbumController.name} (e2e)`, () => {
let user2Albums: AlbumResponseDto[];
beforeAll(async () => {
[server] = await testApp.create();
server = (await testApp.create()).getHttpServer();
});
afterAll(async () => {

View file

@ -83,7 +83,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();

View file

@ -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 () => {

View file

@ -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 () => {

View file

@ -0,0 +1,8 @@
{
"reverseGeocoding": {
"enabled": false
},
"machineLearning": {
"enabled": false
}
}

View file

@ -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 () => {

View file

@ -8,7 +8,7 @@ describe(`${OAuthController.name} (e2e)`, () => {
let server: any;
beforeAll(async () => {
[server] = await testApp.create();
server = (await testApp.create()).getHttpServer();
});
afterAll(async () => {

View file

@ -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);

View file

@ -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);
});

View file

@ -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,

View file

@ -48,7 +48,7 @@ export default async () => {
process.env.NODE_ENV = 'development';
process.env.TYPESENSE_ENABLED = 'false';
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';
};

View file

@ -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();

View file

@ -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);

View file

@ -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));
});

View file

@ -11,3 +11,7 @@ export const keyStub = {
user: userStub.admin,
} as APIKeyEntity),
};
export const apiKeyCreateStub = {
name: 'API Key',
};

View file

@ -5,6 +5,7 @@ import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import * as fs from 'fs';
import path from 'path';
import { Server } from 'tls';
import { EntityTarget, ObjectLiteral } from 'typeorm';
import { AppService } from '../src/microservices/app.service';
@ -58,7 +59,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] })
@ -80,20 +81,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();
},
};