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:
parent
cda45f9bfb
commit
12fb90c232
@ -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
47
cli/package-lock.json
generated
@ -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",
|
||||||
|
@ -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"
|
||||||
},
|
},
|
||||||
|
@ -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
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 { 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);
|
||||||
|
@ -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 { 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()],
|
||||||
});
|
});
|
||||||
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user