0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-27 21:39:16 -05:00

refactor(cli): reorg options and logic

This commit is contained in:
Gao Sun 2022-10-10 23:55:07 +08:00
parent a5cd73d961
commit fa195f4aa5
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
10 changed files with 283 additions and 242 deletions

View file

@ -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",

View file

@ -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<unknown, { action: string }> = {
log.error('Unsupported action');
}
const pool = await createPoolFromEnv();
const pool = await createPoolFromConfig();
const alterations = await getUndeployedAlterations(pool);
log.info(

View file

@ -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<unknown, { key: string; keys: string[] }>
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<unknown, { key: string; value: string }> =
const guarded = logtoConfigGuards[key].parse(JSON.parse(value));
const pool = await createPoolFromEnv();
const pool = await createPoolFromConfig();
await updateValueByKey(pool, key, guarded);
await pool.end();

View file

@ -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<unknown, { path?: string; silent?: boolean }> = {
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;

View file

@ -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<unknown, { path?: string; skipSeed: boolean }> = {
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;

View file

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

View file

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

View file

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

View file

@ -108,46 +108,47 @@ export const oraPromise = async <T>(
}
};
const cliConfig = new Map<string, Optional<string>>();
export enum ConfigKey {
DatabaseUrl = 'DB_URL',
}
export type GetCliConfig = {
key: string;
export const cliConfig = new Map<ConfigKey, Optional<string>>();
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;
};

8
pnpm-lock.yaml generated
View file

@ -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: