From fa195f4aa50a5351558132b2d9b0ab4c1db8f618 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Mon, 10 Oct 2022 23:55:07 +0800 Subject: [PATCH] refactor(cli): reorg options and logic --- packages/cli/package.json | 1 - .../cli/src/commands/database/alteration.ts | 4 +- packages/cli/src/commands/database/config.ts | 6 +- packages/cli/src/commands/install.ts | 188 ------------------ packages/cli/src/commands/install/index.ts | 71 +++++++ packages/cli/src/commands/install/utils.ts | 154 ++++++++++++++ packages/cli/src/database.ts | 20 +- packages/cli/src/index.ts | 14 +- packages/cli/src/utilities.ts | 59 +++--- pnpm-lock.yaml | 8 - 10 files changed, 283 insertions(+), 242 deletions(-) delete mode 100644 packages/cli/src/commands/install.ts create mode 100644 packages/cli/src/commands/install/index.ts create mode 100644 packages/cli/src/commands/install/utils.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index 59ae30eed..dd36cd7ec 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -61,7 +61,6 @@ "@silverhand/eslint-config": "1.0.0", "@silverhand/jest-config": "1.0.0", "@silverhand/ts-config": "1.0.0", - "@types/decompress": "^4.2.4", "@types/fs-extra": "^9.0.13", "@types/inquirer": "^8.2.1", "@types/jest": "^28.1.6", diff --git a/packages/cli/src/commands/database/alteration.ts b/packages/cli/src/commands/database/alteration.ts index 1b8f5bf80..4ec9a73b3 100644 --- a/packages/cli/src/commands/database/alteration.ts +++ b/packages/cli/src/commands/database/alteration.ts @@ -8,7 +8,7 @@ import { copy, existsSync, remove, readdir } from 'fs-extra'; import { DatabasePool } from 'slonik'; import { CommandModule } from 'yargs'; -import { createPoolFromEnv } from '../../database'; +import { createPoolFromConfig } from '../../database'; import { getCurrentDatabaseAlterationTimestamp, updateDatabaseTimestamp, @@ -140,7 +140,7 @@ const alteration: CommandModule = { log.error('Unsupported action'); } - const pool = await createPoolFromEnv(); + const pool = await createPoolFromConfig(); const alterations = await getUndeployedAlterations(pool); log.info( diff --git a/packages/cli/src/commands/database/config.ts b/packages/cli/src/commands/database/config.ts index 5b089cea7..eeb6252d4 100644 --- a/packages/cli/src/commands/database/config.ts +++ b/packages/cli/src/commands/database/config.ts @@ -3,7 +3,7 @@ import { deduplicate } from '@silverhand/essentials'; import chalk from 'chalk'; import { CommandModule } from 'yargs'; -import { createPoolFromEnv } from '../../database'; +import { createPoolFromConfig } from '../../database'; import { getRowsByKeys, updateValueByKey } from '../../queries/logto-config'; import { log } from '../../utilities'; @@ -48,7 +48,7 @@ export const getConfig: CommandModule const queryKeys = deduplicate([key, ...keys]); validateKeys(queryKeys); - const pool = await createPoolFromEnv(); + const pool = await createPoolFromConfig(); const { rows } = await getRowsByKeys(pool, queryKeys); await pool.end(); @@ -88,7 +88,7 @@ export const setConfig: CommandModule = const guarded = logtoConfigGuards[key].parse(JSON.parse(value)); - const pool = await createPoolFromEnv(); + const pool = await createPoolFromConfig(); await updateValueByKey(pool, key, guarded); await pool.end(); diff --git a/packages/cli/src/commands/install.ts b/packages/cli/src/commands/install.ts deleted file mode 100644 index 050f6fd70..000000000 --- a/packages/cli/src/commands/install.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { execSync } from 'child_process'; -import { existsSync } from 'fs'; -import { mkdir } from 'fs/promises'; -import os from 'os'; -import path from 'path'; - -import { conditional } from '@silverhand/essentials'; -import chalk from 'chalk'; -import { remove, writeFile } from 'fs-extra'; -import inquirer from 'inquirer'; -import * as semver from 'semver'; -import tar from 'tar'; -import { CommandModule } from 'yargs'; - -import { createPoolAndDatabaseIfNeeded, getDatabaseUrlFromEnv } from '../database'; -import { downloadFile, log, oraPromise, safeExecSync } from '../utilities'; -import { seedByPool } from './database/seed'; - -export type InstallArgs = { - path?: string; - silent?: boolean; -}; - -const defaultPath = path.join(os.homedir(), 'logto'); -const pgRequired = new semver.SemVer('14.0.0'); - -const validateNodeVersion = () => { - const required = new semver.SemVer('16.0.0'); - const current = new semver.SemVer(execSync('node -v', { encoding: 'utf8', stdio: 'pipe' })); - - if (required.compare(current) > 0) { - log.error(`Logto requires NodeJS >=${required.version}, but ${current.version} found.`); - } - - if (current.major > required.major) { - log.warn( - `Logto is tested under NodeJS ^${required.version}, but version ${current.version} found.` - ); - } -}; - -const inquireInstancePath = async (initialPath?: string) => { - const { instancePath } = await inquirer.prompt<{ instancePath: string }>( - { - name: 'instancePath', - message: 'Where should we create your Logto instance?', - type: 'input', - default: defaultPath, - filter: (value: string) => value.trim(), - validate: (value: string) => - existsSync(path.resolve(value)) - ? `The path ${chalk.green(value)} already exists, please try another.` - : true, - }, - { instancePath: initialPath } - ); - - return instancePath; -}; - -const validateDatabase = async () => { - const { hasPostgresUrl } = await inquirer.prompt<{ hasPostgresUrl?: boolean }>({ - name: 'hasPostgresUrl', - message: `Logto requires PostgreSQL >=${pgRequired.version} but cannot find in the current environment.\n Do you have a remote PostgreSQL instance ready?`, - type: 'confirm', - when: () => { - const pgOutput = safeExecSync('postgres --version') ?? ''; - // Filter out all brackets in the output since Homebrew will append `(Homebrew)`. - const pgArray = pgOutput.split(' ').filter((value) => !value.startsWith('(')); - const pgCurrent = semver.coerce(pgArray[pgArray.length - 1]); - - return !pgCurrent || pgCurrent.compare(pgRequired) < 0; - }, - }); - - if (hasPostgresUrl === false) { - log.error('Logto requires a Postgres instance to run.'); - } -}; - -const downloadRelease = async () => { - const tarFilePath = path.resolve(os.tmpdir(), './logto.tar.gz'); - - log.info(`Download Logto to ${tarFilePath}`); - await downloadFile( - 'https://github.com/logto-io/logto/releases/latest/download/logto.tar.gz', - tarFilePath - ); - - return tarFilePath; -}; - -const decompress = async (toPath: string, tarPath: string) => { - try { - await mkdir(toPath); - await tar.extract({ file: tarPath, cwd: toPath, strip: 1 }); - } catch (error: unknown) { - log.error(error); - } -}; - -const installLogto = async ({ path: pathArgument = defaultPath, silent = false }: InstallArgs) => { - validateNodeVersion(); - - // Get instance path - const instancePath = await inquireInstancePath(conditional(silent && pathArgument)); - - // Validate database URL - await validateDatabase(); - - // Download and decompress - const tarPath = await downloadRelease(); - await oraPromise( - decompress(instancePath, tarPath), - { - text: `Decompress to ${instancePath}`, - prefixText: chalk.blue('[info]'), - }, - true - ); - - try { - // Seed database - const pool = await createPoolAndDatabaseIfNeeded(); // It will ask for database URL and save to config - await seedByPool(pool, 'all'); - await pool.end(); - } catch (error: unknown) { - console.error(error); - - const { value } = await inquirer.prompt<{ value: boolean }>({ - name: 'value', - type: 'confirm', - message: - 'Error occurred during seeding your Logto database. Nothing has changed since the seeding process was in a transaction.\n' + - ' Would you like to continue without seed?', - default: false, - }); - - if (!value) { - await oraPromise(remove(instancePath), { - text: 'Clean up', - prefixText: chalk.blue('[info]'), - }); - - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); - } - - log.info(`You can use ${chalk.green('db seed')} command to seed when ready.`); - } - - // Save to dot env - const databaseUrl = await getDatabaseUrlFromEnv(); - const dotEnvPath = path.resolve(instancePath, '.env'); - await writeFile(dotEnvPath, `DB_URL=${databaseUrl}`, { - encoding: 'utf8', - }); - log.info(`Saved database URL to ${chalk.blue(dotEnvPath)}`); - - // Finale - const startCommand = `cd ${instancePath} && npm start`; - log.info( - `Use the command below to start Logto. Happy hacking!\n\n ${chalk.green(startCommand)}` - ); -}; - -const install: CommandModule = { - command: ['init', 'i', 'install'], - describe: 'Download and run the latest Logto release', - builder: (yargs) => - yargs.options({ - path: { - alias: 'p', - describe: 'Path of Logto, must be a non-existing path', - type: 'string', - }, - silent: { - alias: 's', - describe: 'Entering non-interactive mode', - type: 'boolean', - }, - }), - handler: async ({ path, silent }) => { - await installLogto({ path, silent }); - }, -}; - -export default install; diff --git a/packages/cli/src/commands/install/index.ts b/packages/cli/src/commands/install/index.ts new file mode 100644 index 000000000..3d08fbac2 --- /dev/null +++ b/packages/cli/src/commands/install/index.ts @@ -0,0 +1,71 @@ +import chalk from 'chalk'; +import { CommandModule } from 'yargs'; + +import { getDatabaseUrlFromConfig } from '../../database'; +import { log } from '../../utilities'; +import { + validateNodeVersion, + inquireInstancePath, + validateDatabase, + downloadRelease, + seedDatabase, + createEnv, + logFinale, + decompress, +} from './utils'; + +export type InstallArgs = { + path?: string; + skipSeed: boolean; +}; + +const installLogto = async ({ path, skipSeed }: InstallArgs) => { + validateNodeVersion(); + + // Get instance path + const instancePath = await inquireInstancePath(path); + + // Validate if user has a valid database + await validateDatabase(); + + // Download and decompress + const tarPath = await downloadRelease(); + await decompress(instancePath, tarPath); + + // Seed database + if (skipSeed) { + log.info(`You can use ${chalk.green('db seed')} command to seed database when ready.`); + } else { + await seedDatabase(instancePath); + } + + // Save to dot env + await createEnv(instancePath, await getDatabaseUrlFromConfig()); + + // Finale + logFinale(instancePath); +}; + +const install: CommandModule = { + command: ['init', 'i', 'install'], + describe: 'Download and run the latest Logto release', + builder: (yargs) => + yargs.options({ + path: { + alias: 'p', + describe: 'Path of Logto, must be a non-existing path', + type: 'string', + }, + skipSeed: { + alias: 'ss', + describe: 'Skip Logto database seeding', + type: 'boolean', + default: false, + }, + }), + handler: async ({ path, skipSeed }) => { + await installLogto({ path, skipSeed }); + }, +}; + +export default install; diff --git a/packages/cli/src/commands/install/utils.ts b/packages/cli/src/commands/install/utils.ts new file mode 100644 index 000000000..d120d694c --- /dev/null +++ b/packages/cli/src/commands/install/utils.ts @@ -0,0 +1,154 @@ +import { execSync } from 'child_process'; +import { existsSync } from 'fs'; +import { mkdir } from 'fs/promises'; +import os from 'os'; +import path from 'path'; + +import chalk from 'chalk'; +import { remove, writeFile } from 'fs-extra'; +import inquirer from 'inquirer'; +import * as semver from 'semver'; +import tar from 'tar'; + +import { createPoolAndDatabaseIfNeeded } from '../../database'; +import { cliConfig, ConfigKey, downloadFile, log, oraPromise, safeExecSync } from '../../utilities'; +import { seedByPool } from '../database/seed'; + +export const defaultPath = path.join(os.homedir(), 'logto'); +const pgRequired = new semver.SemVer('14.0.0'); + +export const validateNodeVersion = () => { + const required = new semver.SemVer('16.0.0'); + const current = new semver.SemVer(execSync('node -v', { encoding: 'utf8', stdio: 'pipe' })); + + if (required.compare(current) > 0) { + log.error(`Logto requires NodeJS >=${required.version}, but ${current.version} found.`); + } + + if (current.major > required.major) { + log.warn( + `Logto is tested under NodeJS ^${required.version}, but version ${current.version} found.` + ); + } +}; + +const validatePath = (value: string) => + existsSync(path.resolve(value)) + ? `The path ${chalk.green(value)} already exists, please try another.` + : true; + +export const inquireInstancePath = async (initialPath?: string) => { + const { instancePath } = await inquirer.prompt<{ instancePath: string }>( + { + name: 'instancePath', + message: 'Where should we create your Logto instance?', + type: 'input', + default: defaultPath, + filter: (value: string) => value.trim(), + validate: validatePath, + }, + { instancePath: initialPath } + ); + + // Validate for initialPath + const validated = validatePath(instancePath); + + if (validated !== true) { + log.error(validated); + } + + return instancePath; +}; + +export const validateDatabase = async () => { + if (cliConfig.has(ConfigKey.DatabaseUrl)) { + return; + } + + const { hasPostgresUrl } = await inquirer.prompt<{ hasPostgresUrl?: boolean }>({ + name: 'hasPostgresUrl', + message: `Logto requires PostgreSQL >=${pgRequired.version} but cannot find in the current environment.\n Do you have a remote PostgreSQL instance ready?`, + type: 'confirm', + when: () => { + const pgOutput = safeExecSync('postgres --version') ?? ''; + // Filter out all brackets in the output since Homebrew will append `(Homebrew)`. + const pgArray = pgOutput.split(' ').filter((value) => !value.startsWith('(')); + const pgCurrent = semver.coerce(pgArray[pgArray.length - 1]); + + return !pgCurrent || pgCurrent.compare(pgRequired) < 0; + }, + }); + + if (hasPostgresUrl === false) { + log.error('Logto requires a Postgres instance to run.'); + } +}; + +export const downloadRelease = async () => { + const tarFilePath = path.resolve(os.tmpdir(), './logto.tar.gz'); + + log.info(`Download Logto to ${tarFilePath}`); + await downloadFile( + 'https://github.com/logto-io/logto/releases/latest/download/logto.tar.gz', + tarFilePath + ); + + return tarFilePath; +}; + +export const decompress = async (toPath: string, tarPath: string) => { + const run = async () => { + try { + await mkdir(toPath); + await tar.extract({ file: tarPath, cwd: toPath, strip: 1 }); + } catch (error: unknown) { + log.error(error); + } + }; + + return oraPromise( + run(), + { + text: `Decompress to ${toPath}`, + prefixText: chalk.blue('[info]'), + }, + true + ); +}; + +export const seedDatabase = async (instancePath: string) => { + try { + const pool = await createPoolAndDatabaseIfNeeded(); + await seedByPool(pool, 'all'); + await pool.end(); + } catch (error: unknown) { + console.error(error); + + await oraPromise(remove(instancePath), { + text: 'Clean up', + prefixText: chalk.blue('[info]'), + }); + + log.error( + 'Error occurred during seeding your Logto database. Nothing has changed since the seeding process was in a transaction.\n\n' + + ` To skip the database seeding, append ${chalk.green( + '--skip-seed' + )} to the command options.` + ); + } +}; + +export const createEnv = async (instancePath: string, databaseUrl: string) => { + const dotEnvPath = path.resolve(instancePath, '.env'); + await writeFile(dotEnvPath, `DB_URL=${databaseUrl}`, { + encoding: 'utf8', + }); + log.info(`Saved database URL to ${chalk.blue(dotEnvPath)}`); +}; + +export const logFinale = (instancePath: string) => { + const startCommand = `cd ${instancePath} && npm start`; + log.info( + `Use the command below to start Logto. Happy hacking!\n\n ${chalk.green(startCommand)}` + ); +}; diff --git a/packages/cli/src/database.ts b/packages/cli/src/database.ts index 4e39e8b67..3c7109bde 100644 --- a/packages/cli/src/database.ts +++ b/packages/cli/src/database.ts @@ -5,19 +5,19 @@ import { createPool, parseDsn, sql, stringifyDsn } from 'slonik'; import { createInterceptors } from 'slonik-interceptor-preset'; import { z } from 'zod'; -import { getCliConfig, log } from './utilities'; +import { ConfigKey, getCliConfigWithPrompt, log } from './utilities'; export const defaultDatabaseUrl = 'postgresql://localhost:5432/logto'; -export const getDatabaseUrlFromEnv = async () => - (await getCliConfig({ - key: 'DB_URL', +export const getDatabaseUrlFromConfig = async () => + (await getCliConfigWithPrompt({ + key: ConfigKey.DatabaseUrl, readableKey: 'Logto database URL', defaultValue: defaultDatabaseUrl, })) ?? ''; -export const createPoolFromEnv = async () => { - const databaseUrl = await getDatabaseUrlFromEnv(); +export const createPoolFromConfig = async () => { + const databaseUrl = await getDatabaseUrlFromConfig(); return createPool(databaseUrl, { interceptors: createInterceptors(), @@ -25,14 +25,14 @@ export const createPoolFromEnv = async () => { }; /** - * Create a database pool with the database URL in config. + * Create a database pool with the URL in CLI config; if no URL found, prompt to input. * If the given database does not exists, it will try to create a new database by connecting to the maintenance database `postgres`. * * @returns A new database pool with the database URL in config. */ export const createPoolAndDatabaseIfNeeded = async () => { try { - return await createPoolFromEnv(); + return await createPoolFromConfig(); } catch (error: unknown) { const result = z.object({ code: z.string() }).safeParse(error); @@ -42,7 +42,7 @@ export const createPoolAndDatabaseIfNeeded = async () => { log.error(error); } - const databaseUrl = await getDatabaseUrlFromEnv(); + const databaseUrl = await getDatabaseUrlFromConfig(); const dsn = parseDsn(databaseUrl); // It's ok to fall back to '?' since: // - Database name is required to connect in the previous pool @@ -59,7 +59,7 @@ export const createPoolAndDatabaseIfNeeded = async () => { log.succeed(`Created database ${databaseName}`); - return createPoolFromEnv(); + return createPoolFromConfig(); } }; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 408be9114..a47130561 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -5,6 +5,7 @@ import { hideBin } from 'yargs/helpers'; import database from './commands/database'; import install from './commands/install'; +import { cliConfig, ConfigKey } from './utilities'; void yargs(hideBin(process.argv)) .option('env', { @@ -12,8 +13,19 @@ void yargs(hideBin(process.argv)) describe: 'The path to your `.env` file', type: 'string', }) - .middleware(({ env }) => { + .option('databaseUrl', { + alias: ['db-url'], + describe: 'The Postgres URL to Logto database', + type: 'string', + }) + .middleware(({ env, databaseUrl }) => { dotenv.config({ path: env }); + + const initialDatabaseUrl = databaseUrl ?? process.env[ConfigKey.DatabaseUrl]; + + if (initialDatabaseUrl) { + cliConfig.set(ConfigKey.DatabaseUrl, initialDatabaseUrl); + } }) .command(install) .command(database) diff --git a/packages/cli/src/utilities.ts b/packages/cli/src/utilities.ts index 9f715abe5..c2d7d42f9 100644 --- a/packages/cli/src/utilities.ts +++ b/packages/cli/src/utilities.ts @@ -108,46 +108,47 @@ export const oraPromise = async ( } }; -const cliConfig = new Map>(); +export enum ConfigKey { + DatabaseUrl = 'DB_URL', +} -export type GetCliConfig = { - key: string; +export const cliConfig = new Map>(); + +export type GetCliConfigWithPrompt = { + key: ConfigKey; readableKey: string; comments?: string; defaultValue?: string; }; -export const getCliConfig = async ({ key, readableKey, comments, defaultValue }: GetCliConfig) => { +export const getCliConfigWithPrompt = async ({ + key, + readableKey, + comments, + defaultValue, +}: GetCliConfigWithPrompt) => { if (cliConfig.has(key)) { return cliConfig.get(key); } - const { [key]: value } = process.env; + const { input } = await inquirer + .prompt<{ input?: string }>({ + type: 'input', + name: 'input', + message: `Enter your ${readableKey}${conditionalString(comments && ' ' + comments)}`, + default: defaultValue, + }) + .catch(async (error) => { + if (error.isTtyError) { + log.error(`No ${readableKey} (${chalk.green(key)}) configured in option nor env`); + } - if (!value) { - const { input } = await inquirer - .prompt<{ input?: string }>({ - type: 'input', - name: 'input', - message: `Enter your ${readableKey}${conditionalString(comments && ' ' + comments)}`, - default: defaultValue, - }) - .catch(async (error) => { - if (error.isTtyError) { - log.error(`No ${readableKey} (${chalk.green(key)}) configured in env`); - } + // The type definition does not give us type except `any`, throw it directly will honor the original behavior. + // eslint-disable-next-line @typescript-eslint/no-throw-literal + throw error; + }); - // The type definition does not give us type except `any`, throw it directly will honor the original behavior. - // eslint-disable-next-line @typescript-eslint/no-throw-literal - throw error; - }); + cliConfig.set(key, input); - cliConfig.set(key, input); - - return input; - } - - cliConfig.set(key, value); - - return value; + return input; }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 51de5fd0b..238ca4fd3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,7 +26,6 @@ importers: '@silverhand/essentials': ^1.3.0 '@silverhand/jest-config': 1.0.0 '@silverhand/ts-config': 1.0.0 - '@types/decompress': ^4.2.4 '@types/fs-extra': ^9.0.13 '@types/inquirer': ^8.2.1 '@types/jest': ^28.1.6 @@ -85,7 +84,6 @@ importers: '@silverhand/eslint-config': 1.0.0_swk2g7ygmfleszo5c33j4vooni '@silverhand/jest-config': 1.0.0_bi2kohzqnxavgozw3csgny5hju '@silverhand/ts-config': 1.0.0_typescript@4.7.4 - '@types/decompress': 4.2.4 '@types/fs-extra': 9.0.13 '@types/inquirer': 8.2.1 '@types/jest': 28.1.6 @@ -4415,12 +4413,6 @@ packages: '@types/ms': 0.7.31 dev: true - /@types/decompress/4.2.4: - resolution: {integrity: sha512-/C8kTMRTNiNuWGl5nEyKbPiMv6HA+0RbEXzFhFBEzASM6+oa4tJro9b8nj7eRlOFfuLdzUU+DS/GPDlvvzMOhA==} - dependencies: - '@types/node': 17.0.23 - dev: true - /@types/etag/1.8.1: resolution: {integrity: sha512-bsKkeSqN7HYyYntFRAmzcwx/dKW4Wa+KVMTInANlI72PWLQmOpZu96j0OqHZGArW4VQwCmJPteQlXaUDeOB0WQ==} dependencies: