From 4ccbe4ac6566aff0db1cd98a74441640677f6060 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Tue, 11 Oct 2022 17:38:58 +0800 Subject: [PATCH] feat(cli): add connector command --- packages/cli/package.json | 1 + packages/cli/src/commands/connector/add.ts | 46 ++++++ packages/cli/src/commands/connector/index.ts | 13 ++ packages/cli/src/commands/connector/utils.ts | 161 +++++++++++++++++++ packages/cli/src/index.ts | 2 + pnpm-lock.yaml | 2 + 6 files changed, 225 insertions(+) create mode 100644 packages/cli/src/commands/connector/add.ts create mode 100644 packages/cli/src/commands/connector/index.ts create mode 100644 packages/cli/src/commands/connector/utils.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index dd36cd7ec..dd3042fa1 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -48,6 +48,7 @@ "inquirer": "^8.2.2", "nanoid": "^3.3.4", "ora": "^5.0.0", + "p-retry": "^4.6.1", "roarr": "^7.11.0", "semver": "^7.3.7", "slonik": "^30.0.0", diff --git a/packages/cli/src/commands/connector/add.ts b/packages/cli/src/commands/connector/add.ts new file mode 100644 index 000000000..25f530334 --- /dev/null +++ b/packages/cli/src/commands/connector/add.ts @@ -0,0 +1,46 @@ +import chalk from 'chalk'; +import { CommandModule } from 'yargs'; + +import { oraPromise } from '../../utilities'; +import { + addConnectors, + fetchOfficialConnectorList, + inquireInstancePath, + normalizePackageName, +} from './utils'; + +const add: CommandModule = { + command: ['add [packages...]', 'a', 'install', 'i'], + describe: 'Add specific Logto connectors', + builder: (yargs) => + yargs + .positional('packages', { + describe: 'The additional connector package names', + type: 'string', + array: true, + default: [], + }) + .option('official', { + alias: 'o', + type: 'boolean', + default: false, + describe: + 'Add all official connectors.\n' + + "If it's true, the specified package names will be ignored.", + }) + .option('path', { alias: 'p', type: 'string', describe: 'The path to your Logto instance' }), + handler: async ({ packages: packageNames, path, official }) => { + const instancePath = await inquireInstancePath(path); + + const packages = official + ? await oraPromise(fetchOfficialConnectorList(), { + text: 'Fetch official connector list', + prefixText: chalk.blue('[info]'), + }) + : packageNames.map((name) => normalizePackageName(name)); + + await addConnectors(instancePath, packages); + }, +}; + +export default add; diff --git a/packages/cli/src/commands/connector/index.ts b/packages/cli/src/commands/connector/index.ts new file mode 100644 index 000000000..0e30e34dd --- /dev/null +++ b/packages/cli/src/commands/connector/index.ts @@ -0,0 +1,13 @@ +import { noop } from '@silverhand/essentials'; +import { CommandModule } from 'yargs'; + +import add from './add'; + +const connector: CommandModule = { + command: ['connector', 'c'], + describe: 'Command for Logto connectors', + builder: (yargs) => yargs.command(add).demandCommand(1), + handler: noop, +}; + +export default connector; diff --git a/packages/cli/src/commands/connector/utils.ts b/packages/cli/src/commands/connector/utils.ts new file mode 100644 index 000000000..d639987eb --- /dev/null +++ b/packages/cli/src/commands/connector/utils.ts @@ -0,0 +1,161 @@ +import { exec } from 'child_process'; +import { existsSync } from 'fs'; +import { readFile, mkdir, unlink } from 'fs/promises'; +import path from 'path'; +import { promisify } from 'util'; + +import { conditionalString } from '@silverhand/essentials'; +import chalk from 'chalk'; +import { ensureDir, remove } from 'fs-extra'; +import inquirer from 'inquirer'; +import pRetry from 'p-retry'; +import tar from 'tar'; +import { z } from 'zod'; + +import { log } from '../../utilities'; +import { defaultPath } from '../install/utils'; + +const coreDirectory = 'packages/core'; +const execPromise = promisify(exec); +export const npmPackResultGuard = z + .object({ + name: z.string(), + version: z.string(), + filename: z.string(), + }) + .array(); + +const buildPathErrorMessage = (value: string) => + `The path ${chalk.green(value)} does not contain a Logto instance, please try another.`; + +const validatePath = async (value: string) => { + const corePackageJsonPath = path.resolve(path.join(value, coreDirectory, 'package.json')); + + if (!existsSync(corePackageJsonPath)) { + return buildPathErrorMessage(value); + } + + const packageJson = await readFile(corePackageJsonPath, { encoding: 'utf8' }); + const packageName = await z + .object({ name: z.string() }) + .parseAsync(JSON.parse(packageJson)) + .then(({ name }) => name) + .catch(() => ''); + + if (packageName !== '@logto/core') { + return buildPathErrorMessage(value); + } + + return true; +}; + +export const inquireInstancePath = async (initialPath?: string) => { + const { instancePath } = await inquirer.prompt<{ instancePath: string }>( + { + name: 'instancePath', + message: 'Where is your Logto instance?', + type: 'input', + default: defaultPath, + filter: (value: string) => value.trim(), + validate: validatePath, + }, + { instancePath: initialPath } + ); + + // Validate for initialPath + const validated = await validatePath(instancePath); + + if (validated !== true) { + log.error(validated); + } + + return instancePath; +}; + +const packagePrefix = 'connector-'; + +export const normalizePackageName = (name: string) => + name + .split('/') + // Prepend prefix to the last fragment if needed + .map((fragment, index, array) => + index === array.length - 1 && !fragment.startsWith(packagePrefix) && !fragment.startsWith('@') + ? packagePrefix + fragment + : fragment + ) + .join('/'); + +export const addConnectors = async (instancePath: string, packageNames: string[]) => { + const cwd = path.join(instancePath, coreDirectory, 'connectors'); + + if (!existsSync(cwd)) { + await mkdir(cwd); + } + + log.info('Fetch connector metadata'); + + const results = await Promise.all( + packageNames.map(async (packageName) => { + const run = async () => { + const { stdout } = await execPromise(`npm pack ${packageName} --json`, { cwd }); + const result = npmPackResultGuard.parse(JSON.parse(stdout)); + + if (!result[0]) { + throw new Error( + `Unable to execute ${chalk.green('npm pack')} on package ${chalk.green(packageName)}` + ); + } + + const { filename, name } = result[0]; + const escapedFilename = filename.replace(/\//g, '-').replace(/@/g, ''); + const tarPath = path.join(cwd, escapedFilename); + const packageDirectory = path.join(cwd, name.replace(/\//g, '-')); + + await remove(packageDirectory); + await ensureDir(packageDirectory); + await tar.extract({ cwd: packageDirectory, file: tarPath, strip: 1 }); + await unlink(tarPath); + + log.succeed(`Added ${chalk.green(name)}`); + }; + + try { + await pRetry(run, { retries: 2 }); + } catch (error: unknown) { + console.warn(`[${packageName}]`, error); + + return packageName; + } + }) + ); + + const errorPackages = results.filter(Boolean); + const errorCount = errorPackages.length; + + log.info( + errorCount + ? `Finished with ${errorCount} error${conditionalString(errorCount > 1 && 's')}.` + : 'Finished' + ); + + if (errorCount) { + log.warn('Failed to add ' + errorPackages.map((name) => chalk.green(name)).join(', ')); + } +}; + +const officialConnectorPrefix = '@logto/connector-'; + +export const fetchOfficialConnectorList = async () => { + const { stdout } = await execPromise(`npm search ${officialConnectorPrefix} --json`); + const packages = z + .object({ name: z.string() }) + .transform(({ name }) => name) + .array() + .parse(JSON.parse(stdout)); + + return packages.filter((name) => + ['mock', 'kit'].every( + (excluded) => !name.slice(officialConnectorPrefix.length).startsWith(excluded) + ) + ); +}; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index a47130561..ff8ce851c 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -3,6 +3,7 @@ import dotenv from 'dotenv'; import yargs from 'yargs'; import { hideBin } from 'yargs/helpers'; +import connector from './commands/connector'; import database from './commands/database'; import install from './commands/install'; import { cliConfig, ConfigKey } from './utilities'; @@ -29,6 +30,7 @@ void yargs(hideBin(process.argv)) }) .command(install) .command(database) + .command(connector) .demandCommand(1) .showHelpOnFail(false, `Specify ${chalk.green('--help')} for available options`) .strict() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 349e348ae..d644740f1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,6 +46,7 @@ importers: lint-staged: ^13.0.0 nanoid: ^3.3.4 ora: ^5.0.0 + p-retry: ^4.6.1 prettier: ^2.7.1 rimraf: ^3.0.2 roarr: ^7.11.0 @@ -72,6 +73,7 @@ importers: inquirer: 8.2.2 nanoid: 3.3.4 ora: 5.4.1 + p-retry: 4.6.1 roarr: 7.11.0 semver: 7.3.7 slonik: 30.1.2