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 (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 { - this.api = await this.connect(); +// TODO refactor this +class UploadCommand { + public async run(paths: string[], baseOptions: BaseOptions, options: UploadOptionsDto): Promise { + 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> { - const existingAlbums = await this.api.getAllAlbums(); + const existingAlbums = await getAllAlbums({}); const albumMapping = new Map(); 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 { 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 { - 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, 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 { - 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 { - 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 { - 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 ', '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 => { - 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(); - // @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 { - 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 { - 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 { - 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 { - 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 => { + 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 => { + 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 (promise: Promise): 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); });