mirror of
https://github.com/immich-app/immich.git
synced 2024-11-15 09:59:00 -07: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:
parent
cda45f9bfb
commit
12fb90c232
@ -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',
|
||||
},
|
||||
};
|
||||
|
47
cli/package-lock.json
generated
47
cli/package-lock.json
generated
@ -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",
|
||||
|
@ -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"
|
||||
},
|
||||
|
@ -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) {
|
48
cli/src/commands/auth.ts
Normal file
48
cli/src/commands/auth.ts
Normal 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');
|
||||
};
|
@ -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();
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
15
cli/src/commands/server-info.ts
Normal file
15
cli/src/commands/server-info.ts
Normal 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}`);
|
||||
};
|
@ -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);
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -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'],
|
||||
]);
|
||||
});
|
||||
});
|
@ -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
89
cli/src/utils.ts
Normal 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];
|
||||
}
|
||||
};
|
@ -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()],
|
||||
});
|
||||
|
@ -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);
|
||||
});
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user