diff --git a/cli/.eslintrc.cjs b/cli/.eslintrc.cjs
index 33ee3bd1e8..18a48ac7e1 100644
--- a/cli/.eslintrc.cjs
+++ b/cli/.eslintrc.cjs
@@ -19,8 +19,9 @@ module.exports = {
     '@typescript-eslint/no-explicit-any': 'off',
     '@typescript-eslint/no-floating-promises': 'error',
     'unicorn/prefer-module': 'off',
+    'unicorn/prevent-abbreviations': 'off',
+    'unicorn/no-process-exit': 'off',
     curly: 2,
     'prettier/prettier': 0,
-    'unicorn/prevent-abbreviations': 'error',
   },
 };
diff --git a/cli/package-lock.json b/cli/package-lock.json
index 0a234771f7..69be801322 100644
--- a/cli/package-lock.json
+++ b/cli/package-lock.json
@@ -37,6 +37,7 @@
         "prettier-plugin-organize-imports": "^3.2.4",
         "typescript": "^5.3.3",
         "vite": "^5.0.12",
+        "vite-tsconfig-paths": "^4.3.2",
         "vitest": "^1.2.2",
         "yaml": "^2.3.1"
       },
@@ -45,6 +46,7 @@
       }
     },
     "../open-api/typescript-sdk": {
+      "name": "@immich/sdk",
       "version": "1.98.2",
       "dev": true,
       "license": "GNU Affero General Public License version 3",
@@ -2620,6 +2622,12 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/globrex": {
+      "version": "0.1.2",
+      "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz",
+      "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==",
+      "dev": true
+    },
     "node_modules/graphemer": {
       "version": "1.4.0",
       "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
@@ -4308,6 +4316,26 @@
         "typescript": ">=4.2.0"
       }
     },
+    "node_modules/tsconfck": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.0.3.tgz",
+      "integrity": "sha512-4t0noZX9t6GcPTfBAbIbbIU4pfpCwh0ueq3S4O/5qXI1VwK1outmxhe9dOiEWqMz3MW2LKgDTpqWV+37IWuVbA==",
+      "dev": true,
+      "bin": {
+        "tsconfck": "bin/tsconfck.js"
+      },
+      "engines": {
+        "node": "^18 || >=20"
+      },
+      "peerDependencies": {
+        "typescript": "^5.0.0"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/tslib": {
       "version": "2.6.2",
       "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
@@ -4512,6 +4540,25 @@
         "url": "https://opencollective.com/vitest"
       }
     },
+    "node_modules/vite-tsconfig-paths": {
+      "version": "4.3.2",
+      "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-4.3.2.tgz",
+      "integrity": "sha512-0Vd/a6po6Q+86rPlntHye7F31zA2URZMbH8M3saAZ/xR9QoGN/L21bxEGfXdWmFdNkqPpRdxFT7nmNe12e9/uA==",
+      "dev": true,
+      "dependencies": {
+        "debug": "^4.1.1",
+        "globrex": "^0.1.2",
+        "tsconfck": "^3.0.3"
+      },
+      "peerDependencies": {
+        "vite": "*"
+      },
+      "peerDependenciesMeta": {
+        "vite": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/vitest": {
       "version": "1.3.1",
       "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.3.1.tgz",
diff --git a/cli/package.json b/cli/package.json
index b76c24f94e..45c60569e7 100644
--- a/cli/package.json
+++ b/cli/package.json
@@ -35,6 +35,7 @@
     "prettier-plugin-organize-imports": "^3.2.4",
     "typescript": "^5.3.3",
     "vite": "^5.0.12",
+    "vite-tsconfig-paths": "^4.3.2",
     "vitest": "^1.2.2",
     "yaml": "^2.3.1"
   },
diff --git a/cli/src/commands/upload.command.ts b/cli/src/commands/asset.ts
similarity index 90%
rename from cli/src/commands/upload.command.ts
rename to cli/src/commands/asset.ts
index 250fd79c62..b6c159c9ba 100644
--- a/cli/src/commands/upload.command.ts
+++ b/cli/src/commands/asset.ts
@@ -1,4 +1,12 @@
-import { AssetBulkUploadCheckResult } from '@immich/sdk';
+import {
+  AssetBulkUploadCheckResult,
+  addAssetsToAlbum,
+  checkBulkUpload,
+  createAlbum,
+  defaults,
+  getAllAlbums,
+  getSupportedMediaTypes,
+} from '@immich/sdk';
 import byteSize from 'byte-size';
 import cliProgress from 'cli-progress';
 import { chunk, zip } from 'lodash-es';
@@ -7,9 +15,8 @@ import fs, { createReadStream } from 'node:fs';
 import { access, constants, stat, unlink } from 'node:fs/promises';
 import os from 'node:os';
 import { basename } from 'node:path';
-import { ImmichApi } from 'src/services/api.service';
-import { CrawlService } from '../services/crawl.service';
-import { BaseCommand } from './base-command';
+import { CrawlService } from 'src/services/crawl.service';
+import { BaseOptions, authenticate } from 'src/utils';
 
 const zipDefined = zip as <T, U>(a: T[], b: U[]) => [T, U][];
 
@@ -106,7 +113,7 @@ class Asset {
   }
 }
 
-export class UploadOptionsDto {
+class UploadOptionsDto {
   recursive? = false;
   exclusionPatterns?: string[] = [];
   dryRun? = false;
@@ -118,11 +125,13 @@ export class UploadOptionsDto {
   concurrency? = 4;
 }
 
-export class UploadCommand extends BaseCommand {
-  api!: ImmichApi;
+export const upload = (paths: string[], baseOptions: BaseOptions, uploadOptions: UploadOptionsDto) =>
+  new UploadCommand().run(paths, baseOptions, uploadOptions);
 
-  public async run(paths: string[], options: UploadOptionsDto): Promise<void> {
-    this.api = await this.connect();
+// TODO refactor this
+class UploadCommand {
+  public async run(paths: string[], baseOptions: BaseOptions, options: UploadOptionsDto): Promise<void> {
+    await authenticate(baseOptions);
 
     console.log('Crawling for assets...');
     const files = await this.getFiles(paths, options);
@@ -264,7 +273,7 @@ export class UploadCommand extends BaseCommand {
   }
 
   public async getAlbums(): Promise<Map<string, string>> {
-    const existingAlbums = await this.api.getAllAlbums();
+    const existingAlbums = await getAllAlbums({});
 
     const albumMapping = new Map<string, string>();
     for (const album of existingAlbums) {
@@ -313,7 +322,7 @@ export class UploadCommand extends BaseCommand {
     try {
       for (const albumNames of chunk(newAlbums, options.concurrency)) {
         const newAlbumIds = await Promise.all(
-          albumNames.map((albumName: string) => this.api.createAlbum({ albumName }).then((r) => r.id)),
+          albumNames.map((albumName: string) => createAlbum({ createAlbumDto: { albumName } }).then((r) => r.id)),
         );
 
         for (const [albumName, albumId] of zipDefined(albumNames, newAlbumIds)) {
@@ -348,7 +357,7 @@ export class UploadCommand extends BaseCommand {
     try {
       for (const [albumId, assets] of albumToAssets.entries()) {
         for (const assetBatch of chunk(assets, Math.min(1000 * (options.concurrency ?? 4), 65_000))) {
-          await this.api.addAssetsToAlbum(albumId, { ids: assetBatch });
+          await addAssetsToAlbum({ id: albumId, bulkIdsDto: { ids: assetBatch } });
           albumUpdateProgress.increment(assetBatch.length);
         }
       }
@@ -404,17 +413,18 @@ export class UploadCommand extends BaseCommand {
     const assetBulkUploadCheckDto = {
       assets: zipDefined(assetsToCheck, checksums).map(([asset, checksum]) => ({ id: asset.path, checksum })),
     };
-    const checkResponse = await this.api.checkBulkUpload(assetBulkUploadCheckDto);
+    const checkResponse = await checkBulkUpload({ assetBulkUploadCheckDto });
     return checkResponse.results;
   }
 
   private async uploadAssets(assets: Asset[]): Promise<string[]> {
     const fileRequests = await Promise.all(assets.map((asset) => asset.getUploadFormData()));
-    return Promise.all(fileRequests.map((request) => this.uploadAsset(request).then((response) => response.id)));
+    const results = await Promise.all(fileRequests.map((request) => this.uploadAsset(request)));
+    return results.map((response) => response.id);
   }
 
   private async crawl(paths: string[], options: UploadOptionsDto): Promise<string[]> {
-    const formatResponse = await this.api.getSupportedMediaTypes();
+    const formatResponse = await getSupportedMediaTypes();
     const crawlService = new CrawlService(formatResponse.image, formatResponse.video);
 
     return crawlService.crawl({
@@ -426,14 +436,12 @@ export class UploadCommand extends BaseCommand {
   }
 
   private async uploadAsset(data: FormData): Promise<{ id: string }> {
-    const url = this.api.instanceUrl + '/asset/upload';
+    const { baseUrl, headers } = defaults;
 
-    const response = await fetch(url, {
+    const response = await fetch(`${baseUrl}/asset/upload`, {
       method: 'post',
       redirect: 'error',
-      headers: {
-        'x-api-key': this.api.apiKey,
-      },
+      headers: headers as Record<string, string>,
       body: data,
     });
     if (response.status !== 200 && response.status !== 201) {
diff --git a/cli/src/commands/auth.ts b/cli/src/commands/auth.ts
new file mode 100644
index 0000000000..05f3d7953d
--- /dev/null
+++ b/cli/src/commands/auth.ts
@@ -0,0 +1,48 @@
+import { getMyUserInfo } from '@immich/sdk';
+import { existsSync } from 'node:fs';
+import { mkdir, unlink } from 'node:fs/promises';
+import { BaseOptions, connect, getAuthFilePath, logError, withError, writeAuthFile } from 'src/utils';
+
+export const login = async (instanceUrl: string, apiKey: string, options: BaseOptions) => {
+  console.log(`Logging in to ${instanceUrl}`);
+
+  const { configDirectory: configDir } = options;
+
+  await connect(instanceUrl, apiKey);
+
+  const [error, userInfo] = await withError(getMyUserInfo());
+  if (error) {
+    logError(error, 'Failed to load user info');
+    process.exit(1);
+  }
+
+  console.log(`Logged in as ${userInfo.email}`);
+
+  if (!existsSync(configDir)) {
+    // Create config folder if it doesn't exist
+    const created = await mkdir(configDir, { recursive: true });
+    if (!created) {
+      console.log(`Failed to create config folder: ${configDir}`);
+      return;
+    }
+  }
+
+  await writeAuthFile(configDir, { instanceUrl, apiKey });
+
+  console.log(`Wrote auth info to ${getAuthFilePath(configDir)}`);
+};
+
+export const logout = async (options: BaseOptions) => {
+  console.log('Logging out...');
+
+  const { configDirectory: configDir } = options;
+
+  const authFile = getAuthFilePath(configDir);
+
+  if (existsSync(authFile)) {
+    await unlink(authFile);
+    console.log(`Removed auth file: ${authFile}`);
+  }
+
+  console.log('Successfully logged out');
+};
diff --git a/cli/src/commands/base-command.ts b/cli/src/commands/base-command.ts
deleted file mode 100644
index 2ecb3fef2d..0000000000
--- a/cli/src/commands/base-command.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import { ServerVersionResponseDto, UserResponseDto } from '@immich/sdk';
-import { ImmichApi } from 'src/services/api.service';
-import { SessionService } from '../services/session.service';
-
-export abstract class BaseCommand {
-  protected sessionService!: SessionService;
-  protected user!: UserResponseDto;
-  protected serverVersion!: ServerVersionResponseDto;
-
-  constructor(options: { configDirectory?: string }) {
-    if (!options.configDirectory) {
-      throw new Error('Config directory is required');
-    }
-    this.sessionService = new SessionService(options.configDirectory);
-  }
-
-  public async connect(): Promise<ImmichApi> {
-    return await this.sessionService.connect();
-  }
-}
diff --git a/cli/src/commands/login.command.ts b/cli/src/commands/login.command.ts
deleted file mode 100644
index 863c287016..0000000000
--- a/cli/src/commands/login.command.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import { BaseCommand } from './base-command';
-
-export class LoginCommand extends BaseCommand {
-  public async run(instanceUrl: string, apiKey: string): Promise<void> {
-    await this.sessionService.login(instanceUrl, apiKey);
-  }
-}
diff --git a/cli/src/commands/logout.command.ts b/cli/src/commands/logout.command.ts
deleted file mode 100644
index 736f774247..0000000000
--- a/cli/src/commands/logout.command.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import { BaseCommand } from './base-command';
-
-export class LogoutCommand extends BaseCommand {
-  public static readonly description = 'Logout and remove persisted credentials';
-  public async run(): Promise<void> {
-    await this.sessionService.logout();
-  }
-}
diff --git a/cli/src/commands/server-info.command.ts b/cli/src/commands/server-info.command.ts
deleted file mode 100644
index c6029b1306..0000000000
--- a/cli/src/commands/server-info.command.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import { BaseCommand } from './base-command';
-
-export class ServerInfoCommand extends BaseCommand {
-  public async run() {
-    const api = await this.connect();
-    const versionInfo = await api.getServerVersion();
-    const mediaTypes = await api.getSupportedMediaTypes();
-    const statistics = await api.getAssetStatistics();
-
-    console.log(`Server Version: ${versionInfo.major}.${versionInfo.minor}.${versionInfo.patch}`);
-    console.log(`Image Types: ${mediaTypes.image.map((extension) => extension.replace('.', ''))}`);
-    console.log(`Video Types: ${mediaTypes.video.map((extension) => extension.replace('.', ''))}`);
-    console.log(
-      `Statistics:\n  Images: ${statistics.images}\n  Videos: ${statistics.videos}\n  Total: ${statistics.total}`,
-    );
-  }
-}
diff --git a/cli/src/commands/server-info.ts b/cli/src/commands/server-info.ts
new file mode 100644
index 0000000000..a7de804df9
--- /dev/null
+++ b/cli/src/commands/server-info.ts
@@ -0,0 +1,15 @@
+import { getAssetStatistics, getServerVersion, getSupportedMediaTypes } from '@immich/sdk';
+import { BaseOptions, authenticate } from 'src/utils';
+
+export const serverInfo = async (options: BaseOptions) => {
+  await authenticate(options);
+
+  const versionInfo = await getServerVersion();
+  const mediaTypes = await getSupportedMediaTypes();
+  const stats = await getAssetStatistics({});
+
+  console.log(`Server Version: ${versionInfo.major}.${versionInfo.minor}.${versionInfo.patch}`);
+  console.log(`Image Types: ${mediaTypes.image.map((extension) => extension.replace('.', ''))}`);
+  console.log(`Video Types: ${mediaTypes.video.map((extension) => extension.replace('.', ''))}`);
+  console.log(`Statistics:\n  Images: ${stats.images}\n  Videos: ${stats.videos}\n  Total: ${stats.total}`);
+};
diff --git a/cli/src/index.ts b/cli/src/index.ts
index e9485190a7..bf7e13f445 100644
--- a/cli/src/index.ts
+++ b/cli/src/index.ts
@@ -2,11 +2,10 @@
 import { Command, Option } from 'commander';
 import os from 'node:os';
 import path from 'node:path';
+import { upload } from 'src/commands/asset';
+import { login, logout } from 'src/commands/auth';
+import { serverInfo } from 'src/commands/server-info';
 import { version } from '../package.json';
-import { LoginCommand } from './commands/login.command';
-import { LogoutCommand } from './commands/logout.command';
-import { ServerInfoCommand } from './commands/server-info.command';
-import { UploadCommand } from './commands/upload.command';
 
 const defaultConfigDirectory = path.join(os.homedir(), '.config/immich/');
 
@@ -18,14 +17,34 @@ const program = new Command()
     new Option('-d, --config-directory <directory>', 'Configuration directory where auth.yml will be stored')
       .env('IMMICH_CONFIG_DIR')
       .default(defaultConfigDirectory),
-  );
+  )
+  .addOption(new Option('-u, --url [url]', 'Immich server URL').env('IMMICH_INSTANCE_URL'))
+  .addOption(new Option('-k, --key [apiKey]', 'Immich API key').env('IMMICH_API_KEY'));
+
+program
+  .command('login')
+  .alias('login-key')
+  .description('Login using an API key')
+  .argument('url', 'Immich server URL')
+  .argument('key', 'Immich API key')
+  .action((url, key) => login(url, key, program.opts()));
+
+program
+  .command('logout')
+  .description('Remove stored credentials')
+  .action(() => logout(program.opts()));
+
+program
+  .command('server-info')
+  .description('Display server information')
+  .action(() => serverInfo(program.opts()));
 
 program
   .command('upload')
   .description('Upload assets')
-  .usage('[options] [paths...]')
+  .usage('[paths...] [options]')
   .addOption(new Option('-r, --recursive', 'Recursive').env('IMMICH_RECURSIVE').default(false))
-  .addOption(new Option('-i, --ignore [paths...]', 'Paths to ignore').env('IMMICH_IGNORE_PATHS'))
+  .addOption(new Option('-i, --ignore [paths...]', 'Paths to ignore').env('IMMICH_IGNORE_PATHS').default([]))
   .addOption(new Option('-h, --skip-hash', "Don't hash files before upload").env('IMMICH_SKIP_HASH').default(false))
   .addOption(new Option('-H, --include-hidden', 'Include hidden folders').env('IMMICH_INCLUDE_HIDDEN').default(false))
   .addOption(
@@ -50,32 +69,6 @@ program
   )
   .addOption(new Option('--delete', 'Delete local assets after upload').env('IMMICH_DELETE_ASSETS'))
   .argument('[paths...]', 'One or more paths to assets to be uploaded')
-  .action(async (paths, options) => {
-    options.exclusionPatterns = options.ignore;
-    await new UploadCommand(program.opts()).run(paths, options);
-  });
-
-program
-  .command('server-info')
-  .description('Display server information')
-  .action(async () => {
-    await new ServerInfoCommand(program.opts()).run();
-  });
-
-program
-  .command('login-key')
-  .description('Login using an API key')
-  .argument('url')
-  .argument('key')
-  .action(async (url, key) => {
-    await new LoginCommand(program.opts()).run(url, key);
-  });
-
-program
-  .command('logout')
-  .description('Remove stored credentials')
-  .action(async () => {
-    await new LogoutCommand(program.opts()).run();
-  });
+  .action((paths, options) => upload(paths, program.opts(), options));
 
 program.parse(process.argv);
diff --git a/cli/src/services/api.service.ts b/cli/src/services/api.service.ts
deleted file mode 100644
index 089eda1201..0000000000
--- a/cli/src/services/api.service.ts
+++ /dev/null
@@ -1,106 +0,0 @@
-import {
-  ApiKeyCreateDto,
-  AssetBulkUploadCheckDto,
-  BulkIdsDto,
-  CreateAlbumDto,
-  CreateAssetDto,
-  LoginCredentialDto,
-  SignUpDto,
-  addAssetsToAlbum,
-  checkBulkUpload,
-  createAlbum,
-  createApiKey,
-  getAllAlbums,
-  getAllAssets,
-  getAssetStatistics,
-  getMyUserInfo,
-  getServerVersion,
-  getSupportedMediaTypes,
-  login,
-  pingServer,
-  signUpAdmin,
-  uploadFile,
-} from '@immich/sdk';
-
-/**
- * Wraps the underlying API to abstract away the options and make API calls mockable for testing.
- */
-export class ImmichApi {
-  private readonly options;
-
-  constructor(
-    public instanceUrl: string,
-    public apiKey: string,
-  ) {
-    this.options = {
-      baseUrl: instanceUrl,
-      headers: {
-        'x-api-key': apiKey,
-      },
-    };
-  }
-
-  setApiKey(apiKey: string) {
-    this.apiKey = apiKey;
-    if (!this.options.headers) {
-      throw new Error('missing headers');
-    }
-    this.options.headers['x-api-key'] = apiKey;
-  }
-
-  addAssetsToAlbum(id: string, bulkIdsDto: BulkIdsDto) {
-    return addAssetsToAlbum({ id, bulkIdsDto }, this.options);
-  }
-
-  checkBulkUpload(assetBulkUploadCheckDto: AssetBulkUploadCheckDto) {
-    return checkBulkUpload({ assetBulkUploadCheckDto }, this.options);
-  }
-
-  createAlbum(createAlbumDto: CreateAlbumDto) {
-    return createAlbum({ createAlbumDto }, this.options);
-  }
-
-  createApiKey(apiKeyCreateDto: ApiKeyCreateDto, options: { headers: { Authorization: string } }) {
-    return createApiKey({ apiKeyCreateDto }, { ...this.options, ...options });
-  }
-
-  getAllAlbums() {
-    return getAllAlbums({}, this.options);
-  }
-
-  getAllAssets() {
-    return getAllAssets({}, this.options);
-  }
-
-  getAssetStatistics() {
-    return getAssetStatistics({}, this.options);
-  }
-
-  getMyUserInfo() {
-    return getMyUserInfo(this.options);
-  }
-
-  getServerVersion() {
-    return getServerVersion(this.options);
-  }
-
-  getSupportedMediaTypes() {
-    return getSupportedMediaTypes(this.options);
-  }
-
-  login(loginCredentialDto: LoginCredentialDto) {
-    return login({ loginCredentialDto }, this.options);
-  }
-
-  pingServer() {
-    return pingServer(this.options);
-  }
-
-  signUpAdmin(signUpDto: SignUpDto) {
-    return signUpAdmin({ signUpDto }, this.options);
-  }
-
-  uploadFile(createAssetDto: CreateAssetDto) {
-    return uploadFile({ createAssetDto }, this.options);
-  }
-}
diff --git a/cli/src/services/session.service.spec.ts b/cli/src/services/session.service.spec.ts
deleted file mode 100644
index c217ab4e6a..0000000000
--- a/cli/src/services/session.service.spec.ts
+++ /dev/null
@@ -1,135 +0,0 @@
-import fs from 'node:fs';
-import path from 'node:path';
-import yaml from 'yaml';
-import { SessionService } from './session.service';
-
-const TEST_CONFIG_DIR = '/tmp/immich/';
-const TEST_AUTH_FILE = path.join(TEST_CONFIG_DIR, 'auth.yml');
-const TEST_IMMICH_INSTANCE_URL = 'https://test/api';
-const TEST_IMMICH_API_KEY = 'pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg';
-
-const spyOnConsole = () => vi.spyOn(console, 'log').mockImplementation(() => {});
-
-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);
-};
-
-const readTestAuthFile = async (): Promise<string> => {
-  return await fs.promises.readFile(TEST_AUTH_FILE, 'utf8');
-};
-
-const deleteAuthFile = () => {
-  try {
-    fs.unlinkSync(TEST_AUTH_FILE);
-  } catch (error: any) {
-    if (error.code !== 'ENOENT') {
-      throw error;
-    }
-  }
-};
-
-const mocks = vi.hoisted(() => {
-  return {
-    getMyUserInfo: vi.fn(() => Promise.resolve({ email: 'admin@example.com' })),
-    pingServer: vi.fn(() => Promise.resolve({ res: 'pong' })),
-  };
-});
-
-vi.mock('./api.service', async (importOriginal) => {
-  const module = await importOriginal<typeof import('./api.service')>();
-  // @ts-expect-error this is only a partial implementation of the return value
-  module.ImmichApi.prototype.getMyUserInfo = mocks.getMyUserInfo;
-  module.ImmichApi.prototype.pingServer = mocks.pingServer;
-  return module;
-});
-
-describe('SessionService', () => {
-  let sessionService: SessionService;
-
-  beforeEach(() => {
-    deleteAuthFile();
-    sessionService = new SessionService(TEST_CONFIG_DIR);
-  });
-
-  afterEach(() => {
-    deleteAuthFile();
-  });
-
-  it('should connect to immich', async () => {
-    await createTestAuthFile(
-      JSON.stringify({
-        apiKey: TEST_IMMICH_API_KEY,
-        instanceUrl: TEST_IMMICH_INSTANCE_URL,
-      }),
-    );
-
-    await sessionService.connect();
-    expect(mocks.pingServer).toHaveBeenCalledTimes(1);
-  });
-
-  it('should error if no auth file exists', async () => {
-    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 () => {
-    await createTestAuthFile(
-      JSON.stringify({
-        apiKey: TEST_IMMICH_API_KEY,
-      }),
-    );
-    await sessionService.connect().catch((error) => {
-      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 () => {
-    await createTestAuthFile(
-      JSON.stringify({
-        instanceUrl: TEST_IMMICH_INSTANCE_URL,
-      }),
-    );
-
-    await expect(sessionService.connect()).rejects.toThrow(`API key missing in auth config file ${TEST_AUTH_FILE}`);
-  });
-
-  it('should create auth file when logged in', async () => {
-    await sessionService.login(TEST_IMMICH_INSTANCE_URL, TEST_IMMICH_API_KEY);
-
-    const data: string = await readTestAuthFile();
-    const authConfig = yaml.parse(data);
-    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 () => {
-    const consoleSpy = spyOnConsole();
-
-    await createTestAuthFile(
-      JSON.stringify({
-        apiKey: TEST_IMMICH_API_KEY,
-        instanceUrl: TEST_IMMICH_INSTANCE_URL,
-      }),
-    );
-    await sessionService.logout();
-
-    await fs.promises.access(TEST_AUTH_FILE, fs.constants.F_OK).catch((error) => {
-      expect(error.message).toContain('ENOENT');
-    });
-
-    expect(consoleSpy.mock.calls).toEqual([
-      ['Logging out...'],
-      [`Removed auth file ${TEST_AUTH_FILE}`],
-      ['Successfully logged out'],
-    ]);
-  });
-});
diff --git a/cli/src/services/session.service.ts b/cli/src/services/session.service.ts
deleted file mode 100644
index 0235b30a4c..0000000000
--- a/cli/src/services/session.service.ts
+++ /dev/null
@@ -1,118 +0,0 @@
-import { existsSync } from 'node:fs';
-import { access, constants, mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
-import path from 'node:path';
-import yaml from 'yaml';
-import { ImmichApi } from './api.service';
-
-class LoginError extends Error {
-  constructor(message: string) {
-    super(message);
-
-    this.name = this.constructor.name;
-
-    Error.captureStackTrace(this, this.constructor);
-  }
-}
-
-export class SessionService {
-  private get authPath() {
-    return path.join(this.configDirectory, '/auth.yml');
-  }
-
-  constructor(private configDirectory: string) {}
-
-  async connect(): Promise<ImmichApi> {
-    let instanceUrl = process.env.IMMICH_INSTANCE_URL;
-    let apiKey = process.env.IMMICH_API_KEY;
-
-    if (!instanceUrl || !apiKey) {
-      await access(this.authPath, constants.F_OK).catch((error) => {
-        if (error.code === 'ENOENT') {
-          throw new LoginError('No auth file exist. Please login first');
-        }
-      });
-
-      const data: string = await 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}`);
-      }
-
-      if (!apiKey) {
-        throw new LoginError(`API key missing in auth config file ${this.authPath}`);
-      }
-    }
-
-    instanceUrl = await this.resolveApiEndpoint(instanceUrl);
-
-    const api = new ImmichApi(instanceUrl, apiKey);
-
-    const pingResponse = await api.pingServer().catch((error) => {
-      throw new Error(`Failed to connect to server ${instanceUrl}: ${error.message}`, error);
-    });
-
-    if (pingResponse.res !== 'pong') {
-      throw new Error(`Could not parse response. Is Immich listening on ${instanceUrl}?`);
-    }
-
-    return api;
-  }
-
-  async login(instanceUrl: string, apiKey: string): Promise<ImmichApi> {
-    console.log(`Logging in to ${instanceUrl}`);
-
-    instanceUrl = await this.resolveApiEndpoint(instanceUrl);
-
-    const api = new ImmichApi(instanceUrl, apiKey);
-
-    // Check if server and api key are valid
-    const userInfo = await api.getMyUserInfo().catch((error) => {
-      throw new LoginError(`Failed to connect to server ${instanceUrl}: ${error.message}`);
-    });
-
-    console.log(`Logged in as ${userInfo.email}`);
-
-    if (!existsSync(this.configDirectory)) {
-      // Create config folder if it doesn't exist
-      const created = await mkdir(this.configDirectory, { recursive: true });
-      if (!created) {
-        throw new Error(`Failed to create config folder ${this.configDirectory}`);
-      }
-    }
-
-    await writeFile(this.authPath, yaml.stringify({ instanceUrl, apiKey }), { mode: 0o600 });
-
-    console.log(`Wrote auth info to ${this.authPath}`);
-
-    return api;
-  }
-
-  async logout(): Promise<void> {
-    console.log('Logging out...');
-
-    if (existsSync(this.authPath)) {
-      await unlink(this.authPath);
-      console.log('Removed auth file ' + this.authPath);
-    }
-
-    console.log('Successfully logged out');
-  }
-
-  private async resolveApiEndpoint(instanceUrl: string): Promise<string> {
-    const wellKnownUrl = new URL('.well-known/immich', instanceUrl);
-    try {
-      const wellKnown = await fetch(wellKnownUrl).then((response) => response.json());
-      const endpoint = new URL(wellKnown.api.endpoint, instanceUrl).toString();
-      if (endpoint !== instanceUrl) {
-        console.debug(`Discovered API at ${endpoint}`);
-      }
-      return endpoint;
-    } catch {
-      return instanceUrl;
-    }
-  }
-}
diff --git a/cli/src/utils.ts b/cli/src/utils.ts
new file mode 100644
index 0000000000..f99a0e66a8
--- /dev/null
+++ b/cli/src/utils.ts
@@ -0,0 +1,89 @@
+import { defaults, getMyUserInfo, isHttpError } from '@immich/sdk';
+import { readFile, writeFile } from 'node:fs/promises';
+import { join } from 'node:path';
+import yaml from 'yaml';
+
+export interface BaseOptions {
+  configDirectory: string;
+  apiKey?: string;
+  instanceUrl?: string;
+}
+
+export interface AuthDto {
+  instanceUrl: string;
+  apiKey: string;
+}
+
+export const authenticate = async (options: BaseOptions): Promise<void> => {
+  const { configDirectory: configDir, instanceUrl, apiKey } = options;
+
+  // provided in command
+  if (instanceUrl && apiKey) {
+    await connect(instanceUrl, apiKey);
+    return;
+  }
+
+  // fallback to file
+  const config = await readAuthFile(configDir);
+  await connect(config.instanceUrl, config.apiKey);
+};
+
+export const connect = async (instanceUrl: string, apiKey: string): Promise<void> => {
+  const wellKnownUrl = new URL('.well-known/immich', instanceUrl);
+  try {
+    const wellKnown = await fetch(wellKnownUrl).then((response) => response.json());
+    const endpoint = new URL(wellKnown.api.endpoint, instanceUrl).toString();
+    if (endpoint !== instanceUrl) {
+      console.debug(`Discovered API at ${endpoint}`);
+    }
+    instanceUrl = endpoint;
+  } catch {
+    // noop
+  }
+
+  defaults.baseUrl = instanceUrl;
+  defaults.headers = { 'x-api-key': apiKey };
+
+  const [error] = await withError(getMyUserInfo());
+  if (isHttpError(error)) {
+    logError(error, 'Failed to connect to server');
+    process.exit(1);
+  }
+};
+
+export const logError = (error: unknown, message: string) => {
+  if (isHttpError(error)) {
+    console.error(`${message}: ${error.status}`);
+    console.error(JSON.stringify(error.data, undefined, 2));
+  } else {
+    console.error(`${message} - ${error}`);
+  }
+};
+
+export const getAuthFilePath = (dir: string) => join(dir, 'auth.yml');
+
+export const readAuthFile = async (dir: string) => {
+  try {
+    const data = await readFile(getAuthFilePath(dir));
+    // TODO add class-transform/validation
+    return yaml.parse(data.toString()) as AuthDto;
+  } catch (error: Error | any) {
+    if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
+      console.log('No auth file exists. Please login first.');
+      process.exit(1);
+    }
+    throw error;
+  }
+};
+
+export const writeAuthFile = async (dir: string, auth: AuthDto) =>
+  writeFile(getAuthFilePath(dir), yaml.stringify(auth), { mode: 0o600 });
+
+export const withError = async <T>(promise: Promise<T>): Promise<[Error, undefined] | [undefined, T]> => {
+  try {
+    const result = await promise;
+    return [undefined, result];
+  } catch (error: Error | any) {
+    return [error, undefined];
+  }
+};
diff --git a/cli/vite.config.ts b/cli/vite.config.ts
index 89ee3a3d3e..f5dd7c8e15 100644
--- a/cli/vite.config.ts
+++ b/cli/vite.config.ts
@@ -1,4 +1,5 @@
 import { defineConfig } from 'vite';
+import tsconfigPaths from 'vite-tsconfig-paths';
 
 export default defineConfig({
   build: {
@@ -14,4 +15,5 @@ export default defineConfig({
     // bundle everything except for Node built-ins
     noExternal: /^(?!node:).*$/,
   },
+  plugins: [tsconfigPaths()],
 });
diff --git a/e2e/src/cli/specs/login.e2e-spec.ts b/e2e/src/cli/specs/login.e2e-spec.ts
index 61702769ca..42877221fc 100644
--- a/e2e/src/cli/specs/login.e2e-spec.ts
+++ b/e2e/src/cli/specs/login.e2e-spec.ts
@@ -21,7 +21,9 @@ describe(`immich login-key`, () => {
 
   it('should require a valid key', async () => {
     const { stderr, exitCode } = await immichCli(['login-key', app, 'immich-is-so-cool']);
-    expect(stderr).toContain('Failed to connect to server http://127.0.0.1:2283/api: Error: 401');
+    expect(stderr).toContain('Failed to connect to server');
+    expect(stderr).toContain('Invalid API key');
+    expect(stderr).toContain('401');
     expect(exitCode).toBe(1);
   });