1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-22 01:47:08 +02:00

refactor(cli): simplify (#7962)

* refactor(cli): yup

* fix missing return for authenticate

---------

Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
This commit is contained in:
Jason Rasmussen 2024-03-14 19:09:28 -04:00 committed by GitHub
parent cda45f9bfb
commit 12fb90c232
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 262 additions and 467 deletions

View File

@ -19,8 +19,9 @@ module.exports = {
'@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'error', '@typescript-eslint/no-floating-promises': 'error',
'unicorn/prefer-module': 'off', 'unicorn/prefer-module': 'off',
'unicorn/prevent-abbreviations': 'off',
'unicorn/no-process-exit': 'off',
curly: 2, curly: 2,
'prettier/prettier': 0, 'prettier/prettier': 0,
'unicorn/prevent-abbreviations': 'error',
}, },
}; };

47
cli/package-lock.json generated
View File

@ -37,6 +37,7 @@
"prettier-plugin-organize-imports": "^3.2.4", "prettier-plugin-organize-imports": "^3.2.4",
"typescript": "^5.3.3", "typescript": "^5.3.3",
"vite": "^5.0.12", "vite": "^5.0.12",
"vite-tsconfig-paths": "^4.3.2",
"vitest": "^1.2.2", "vitest": "^1.2.2",
"yaml": "^2.3.1" "yaml": "^2.3.1"
}, },
@ -45,6 +46,7 @@
} }
}, },
"../open-api/typescript-sdk": { "../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.98.2", "version": "1.98.2",
"dev": true, "dev": true,
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
@ -2620,6 +2622,12 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/graphemer": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
@ -4308,6 +4316,26 @@
"typescript": ">=4.2.0" "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": { "node_modules/tslib": {
"version": "2.6.2", "version": "2.6.2",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
@ -4512,6 +4540,25 @@
"url": "https://opencollective.com/vitest" "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": { "node_modules/vitest": {
"version": "1.3.1", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-1.3.1.tgz", "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.3.1.tgz",

View File

@ -35,6 +35,7 @@
"prettier-plugin-organize-imports": "^3.2.4", "prettier-plugin-organize-imports": "^3.2.4",
"typescript": "^5.3.3", "typescript": "^5.3.3",
"vite": "^5.0.12", "vite": "^5.0.12",
"vite-tsconfig-paths": "^4.3.2",
"vitest": "^1.2.2", "vitest": "^1.2.2",
"yaml": "^2.3.1" "yaml": "^2.3.1"
}, },

View File

@ -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 byteSize from 'byte-size';
import cliProgress from 'cli-progress'; import cliProgress from 'cli-progress';
import { chunk, zip } from 'lodash-es'; 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 { access, constants, stat, unlink } from 'node:fs/promises';
import os from 'node:os'; import os from 'node:os';
import { basename } from 'node:path'; import { basename } from 'node:path';
import { ImmichApi } from 'src/services/api.service'; import { CrawlService } from 'src/services/crawl.service';
import { CrawlService } from '../services/crawl.service'; import { BaseOptions, authenticate } from 'src/utils';
import { BaseCommand } from './base-command';
const zipDefined = zip as <T, U>(a: T[], b: U[]) => [T, U][]; 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; recursive? = false;
exclusionPatterns?: string[] = []; exclusionPatterns?: string[] = [];
dryRun? = false; dryRun? = false;
@ -118,11 +125,13 @@ export class UploadOptionsDto {
concurrency? = 4; concurrency? = 4;
} }
export class UploadCommand extends BaseCommand { export const upload = (paths: string[], baseOptions: BaseOptions, uploadOptions: UploadOptionsDto) =>
api!: ImmichApi; new UploadCommand().run(paths, baseOptions, uploadOptions);
public async run(paths: string[], options: UploadOptionsDto): Promise<void> { // TODO refactor this
this.api = await this.connect(); class UploadCommand {
public async run(paths: string[], baseOptions: BaseOptions, options: UploadOptionsDto): Promise<void> {
await authenticate(baseOptions);
console.log('Crawling for assets...'); console.log('Crawling for assets...');
const files = await this.getFiles(paths, options); const files = await this.getFiles(paths, options);
@ -264,7 +273,7 @@ export class UploadCommand extends BaseCommand {
} }
public async getAlbums(): Promise<Map<string, string>> { public async getAlbums(): Promise<Map<string, string>> {
const existingAlbums = await this.api.getAllAlbums(); const existingAlbums = await getAllAlbums({});
const albumMapping = new Map<string, string>(); const albumMapping = new Map<string, string>();
for (const album of existingAlbums) { for (const album of existingAlbums) {
@ -313,7 +322,7 @@ export class UploadCommand extends BaseCommand {
try { try {
for (const albumNames of chunk(newAlbums, options.concurrency)) { for (const albumNames of chunk(newAlbums, options.concurrency)) {
const newAlbumIds = await Promise.all( 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)) { for (const [albumName, albumId] of zipDefined(albumNames, newAlbumIds)) {
@ -348,7 +357,7 @@ export class UploadCommand extends BaseCommand {
try { try {
for (const [albumId, assets] of albumToAssets.entries()) { for (const [albumId, assets] of albumToAssets.entries()) {
for (const assetBatch of chunk(assets, Math.min(1000 * (options.concurrency ?? 4), 65_000))) { 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); albumUpdateProgress.increment(assetBatch.length);
} }
} }
@ -404,17 +413,18 @@ export class UploadCommand extends BaseCommand {
const assetBulkUploadCheckDto = { const assetBulkUploadCheckDto = {
assets: zipDefined(assetsToCheck, checksums).map(([asset, checksum]) => ({ id: asset.path, checksum })), 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; return checkResponse.results;
} }
private async uploadAssets(assets: Asset[]): Promise<string[]> { private async uploadAssets(assets: Asset[]): Promise<string[]> {
const fileRequests = await Promise.all(assets.map((asset) => asset.getUploadFormData())); 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[]> { 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); const crawlService = new CrawlService(formatResponse.image, formatResponse.video);
return crawlService.crawl({ return crawlService.crawl({
@ -426,14 +436,12 @@ export class UploadCommand extends BaseCommand {
} }
private async uploadAsset(data: FormData): Promise<{ id: string }> { 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', method: 'post',
redirect: 'error', redirect: 'error',
headers: { headers: headers as Record<string, string>,
'x-api-key': this.api.apiKey,
},
body: data, body: data,
}); });
if (response.status !== 200 && response.status !== 201) { if (response.status !== 200 && response.status !== 201) {

48
cli/src/commands/auth.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,11 +2,10 @@
import { Command, Option } from 'commander'; import { Command, Option } from 'commander';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; 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 { 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/'); 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') new Option('-d, --config-directory <directory>', 'Configuration directory where auth.yml will be stored')
.env('IMMICH_CONFIG_DIR') .env('IMMICH_CONFIG_DIR')
.default(defaultConfigDirectory), .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 program
.command('upload') .command('upload')
.description('Upload assets') .description('Upload assets')
.usage('[options] [paths...]') .usage('[paths...] [options]')
.addOption(new Option('-r, --recursive', 'Recursive').env('IMMICH_RECURSIVE').default(false)) .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, --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(new Option('-H, --include-hidden', 'Include hidden folders').env('IMMICH_INCLUDE_HIDDEN').default(false))
.addOption( .addOption(
@ -50,32 +69,6 @@ program
) )
.addOption(new Option('--delete', 'Delete local assets after upload').env('IMMICH_DELETE_ASSETS')) .addOption(new Option('--delete', 'Delete local assets after upload').env('IMMICH_DELETE_ASSETS'))
.argument('[paths...]', 'One or more paths to assets to be uploaded') .argument('[paths...]', 'One or more paths to assets to be uploaded')
.action(async (paths, options) => { .action((paths, options) => upload(paths, program.opts(), 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();
});
program.parse(process.argv); program.parse(process.argv);

View File

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

View File

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

View File

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

89
cli/src/utils.ts Normal file
View File

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

View File

@ -1,4 +1,5 @@
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({ export default defineConfig({
build: { build: {
@ -14,4 +15,5 @@ export default defineConfig({
// bundle everything except for Node built-ins // bundle everything except for Node built-ins
noExternal: /^(?!node:).*$/, noExternal: /^(?!node:).*$/,
}, },
plugins: [tsconfigPaths()],
}); });

View File

@ -21,7 +21,9 @@ describe(`immich login-key`, () => {
it('should require a valid key', async () => { it('should require a valid key', async () => {
const { stderr, exitCode } = await immichCli(['login-key', app, 'immich-is-so-cool']); 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); expect(exitCode).toBe(1);
}); });