2023-09-13 16:12:43 +08:00
|
|
|
import { execSync, execFile } from 'node:child_process';
|
2023-03-24 16:28:36 +00:00
|
|
|
import { createWriteStream, existsSync } from 'node:fs';
|
|
|
|
import { readdir, readFile } from 'node:fs/promises';
|
|
|
|
import { createRequire } from 'node:module';
|
|
|
|
import path from 'node:path';
|
2023-09-13 16:12:43 +08:00
|
|
|
import { promisify } from 'node:util';
|
2022-10-03 17:52:28 +08:00
|
|
|
|
2023-04-11 01:48:19 +08:00
|
|
|
import { ConsoleLog } from '@logto/shared';
|
2022-10-21 13:14:17 +08:00
|
|
|
import type { Optional } from '@silverhand/essentials';
|
2023-12-20 16:45:01 +08:00
|
|
|
import { assert, conditional, conditionalString } from '@silverhand/essentials';
|
2022-10-03 17:52:28 +08:00
|
|
|
import chalk from 'chalk';
|
2022-10-21 13:14:17 +08:00
|
|
|
import type { Progress } from 'got';
|
2022-11-21 19:18:32 +08:00
|
|
|
import { got } from 'got';
|
2022-10-03 19:00:44 +08:00
|
|
|
import { HttpsProxyAgent } from 'hpagent';
|
2022-10-08 23:27:43 +08:00
|
|
|
import inquirer from 'inquirer';
|
2022-12-12 13:43:23 +08:00
|
|
|
import type { Options } from 'ora';
|
2022-10-03 17:52:28 +08:00
|
|
|
import ora from 'ora';
|
2022-11-08 20:29:26 +08:00
|
|
|
import { z } from 'zod';
|
2022-10-03 17:52:28 +08:00
|
|
|
|
2023-09-13 16:12:43 +08:00
|
|
|
import { coreDirectory, defaultPath } from './constants.js';
|
|
|
|
|
2022-10-03 17:52:28 +08:00
|
|
|
export const safeExecSync = (command: string) => {
|
|
|
|
try {
|
|
|
|
return execSync(command, { encoding: 'utf8', stdio: 'pipe' });
|
|
|
|
} catch {}
|
|
|
|
};
|
|
|
|
|
2023-04-11 01:48:19 +08:00
|
|
|
// The explicit type annotation is required to make `.fatal()`
|
|
|
|
// works correctly without `return`:
|
|
|
|
//
|
|
|
|
// ```ts
|
|
|
|
// const foo: number | undefined;
|
|
|
|
// consoleLog.fatal();
|
|
|
|
// typeof foo // Still `number | undefined` without explicit type annotation
|
|
|
|
// ```
|
|
|
|
//
|
|
|
|
// For now I have no idea why.
|
|
|
|
export const consoleLog: ConsoleLog = new ConsoleLog();
|
2022-10-03 17:52:28 +08:00
|
|
|
|
2023-03-28 21:45:45 +08:00
|
|
|
export const getProxy = () => {
|
2022-10-03 19:00:44 +08:00
|
|
|
const { HTTPS_PROXY, HTTP_PROXY, https_proxy, http_proxy } = process.env;
|
2023-03-28 21:45:45 +08:00
|
|
|
|
|
|
|
return HTTPS_PROXY ?? https_proxy ?? HTTP_PROXY ?? http_proxy;
|
|
|
|
};
|
|
|
|
|
|
|
|
export const downloadFile = async (url: string, destination: string) => {
|
2022-10-03 17:52:28 +08:00
|
|
|
const file = createWriteStream(destination);
|
2023-03-28 21:45:45 +08:00
|
|
|
const proxy = getProxy();
|
2022-10-03 19:00:44 +08:00
|
|
|
const stream = got.stream(url, {
|
|
|
|
...(proxy && { agent: { https: new HttpsProxyAgent({ proxy }) } }),
|
|
|
|
});
|
2022-10-03 17:52:28 +08:00
|
|
|
const spinner = ora({
|
|
|
|
text: 'Connecting',
|
2023-09-18 18:27:25 +08:00
|
|
|
prefixText: ConsoleLog.prefixes.info,
|
2022-10-03 17:52:28 +08:00
|
|
|
}).start();
|
|
|
|
|
|
|
|
stream.pipe(file);
|
|
|
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
stream.on('downloadProgress', ({ total, percent }: Progress) => {
|
|
|
|
if (!total) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
// eslint-disable-next-line @silverhand/fp/no-mutation
|
|
|
|
spinner.text = `${(percent * 100).toFixed(1)}%`;
|
|
|
|
});
|
|
|
|
|
|
|
|
file.on('error', (error) => {
|
|
|
|
spinner.fail();
|
|
|
|
reject(error.message);
|
|
|
|
});
|
|
|
|
|
|
|
|
file.on('finish', () => {
|
|
|
|
file.close();
|
|
|
|
spinner.succeed();
|
|
|
|
resolve(file);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
};
|
2022-10-05 02:30:37 +08:00
|
|
|
|
2022-10-07 19:48:17 +08:00
|
|
|
export const getPathInModule = (moduleName: string, relativePath = '/') =>
|
|
|
|
// https://stackoverflow.com/a/49455609/12514940
|
|
|
|
path.join(
|
2023-01-09 19:39:56 +08:00
|
|
|
path.dirname(createRequire(import.meta.url).resolve(`${moduleName}/package.json`)),
|
2022-10-07 19:48:17 +08:00
|
|
|
relativePath
|
|
|
|
);
|
|
|
|
|
2022-10-07 23:31:13 +08:00
|
|
|
export const oraPromise = async <T>(
|
|
|
|
promise: PromiseLike<T>,
|
2022-12-12 13:43:23 +08:00
|
|
|
options?: Options,
|
2022-10-07 23:31:13 +08:00
|
|
|
exitOnError = false
|
|
|
|
) => {
|
2023-09-18 18:27:25 +08:00
|
|
|
const spinner = ora({ prefixText: ConsoleLog.prefixes.info, ...options }).start();
|
2022-10-07 23:31:13 +08:00
|
|
|
|
|
|
|
try {
|
|
|
|
const result = await promise;
|
|
|
|
spinner.succeed();
|
|
|
|
|
|
|
|
return result;
|
|
|
|
} catch (error: unknown) {
|
|
|
|
spinner.fail();
|
|
|
|
|
|
|
|
if (exitOnError) {
|
2023-04-11 01:48:19 +08:00
|
|
|
consoleLog.fatal(error);
|
2022-10-07 23:31:13 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
throw error;
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2022-10-19 11:35:39 +08:00
|
|
|
export const isTty = () => process.stdin.isTTY;
|
|
|
|
|
2022-10-10 23:55:07 +08:00
|
|
|
export enum ConfigKey {
|
|
|
|
DatabaseUrl = 'DB_URL',
|
|
|
|
}
|
2022-10-08 23:42:42 +08:00
|
|
|
|
2022-10-10 23:55:07 +08:00
|
|
|
export const cliConfig = new Map<ConfigKey, Optional<string>>();
|
|
|
|
|
|
|
|
export type GetCliConfigWithPrompt = {
|
|
|
|
key: ConfigKey;
|
2022-10-08 23:27:43 +08:00
|
|
|
readableKey: string;
|
|
|
|
comments?: string;
|
|
|
|
defaultValue?: string;
|
|
|
|
};
|
|
|
|
|
2022-10-10 23:55:07 +08:00
|
|
|
export const getCliConfigWithPrompt = async ({
|
|
|
|
key,
|
|
|
|
readableKey,
|
|
|
|
comments,
|
|
|
|
defaultValue,
|
|
|
|
}: GetCliConfigWithPrompt) => {
|
2022-10-19 11:35:39 +08:00
|
|
|
if (cliConfig.has(key) || !isTty()) {
|
2022-10-08 23:42:42 +08:00
|
|
|
return cliConfig.get(key);
|
|
|
|
}
|
|
|
|
|
2022-10-19 11:35:39 +08:00
|
|
|
const { input } = await inquirer.prompt<{ input?: string }>({
|
|
|
|
type: 'input',
|
|
|
|
name: 'input',
|
|
|
|
message: `Enter your ${readableKey}${conditionalString(comments && ' ' + comments)}`,
|
|
|
|
default: defaultValue,
|
|
|
|
});
|
2022-10-08 23:27:43 +08:00
|
|
|
|
2022-10-10 23:55:07 +08:00
|
|
|
cliConfig.set(key, input);
|
2022-10-08 23:42:42 +08:00
|
|
|
|
2022-10-10 23:55:07 +08:00
|
|
|
return input;
|
2022-10-08 23:27:43 +08:00
|
|
|
};
|
2022-10-12 23:07:57 +08:00
|
|
|
|
|
|
|
// https://stackoverflow.com/a/53187807/12514940
|
|
|
|
/**
|
|
|
|
* Returns the index of the last element in the array where predicate is true, and -1
|
|
|
|
* otherwise.
|
|
|
|
* @param array The source array to search in
|
|
|
|
* @param predicate find calls predicate once for each element of the array, in descending
|
|
|
|
* order, until it finds one where predicate returns true. If such an element is found,
|
|
|
|
* findLastIndex immediately returns that element index. Otherwise, findLastIndex returns -1.
|
|
|
|
*/
|
|
|
|
export function findLastIndex<T>(
|
|
|
|
array: readonly T[],
|
|
|
|
predicate: (value: T, index: number, object: readonly T[]) => boolean
|
|
|
|
): number {
|
|
|
|
// eslint-disable-next-line @silverhand/fp/no-let
|
|
|
|
let { length } = array;
|
|
|
|
|
|
|
|
// eslint-disable-next-line @silverhand/fp/no-mutation
|
|
|
|
while (length--) {
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
|
|
if (predicate(array[length]!, length, array)) {
|
|
|
|
return length;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return -1;
|
|
|
|
}
|
2022-11-08 20:29:26 +08:00
|
|
|
|
2023-09-13 16:12:43 +08:00
|
|
|
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;
|
|
|
|
};
|
|
|
|
|
2023-12-20 16:45:01 +08:00
|
|
|
export const inquireInstancePath = async (initialPath?: string, skipCoreCheck?: boolean) => {
|
2023-09-13 16:12:43 +08:00
|
|
|
const inquire = async () => {
|
2023-12-20 16:45:01 +08:00
|
|
|
if (!initialPath && (skipCoreCheck ?? (await validatePath('.')) === true)) {
|
2023-09-13 16:12:43 +08:00
|
|
|
return path.resolve('.');
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!isTty()) {
|
|
|
|
assert(initialPath, new Error('Path is missing'));
|
|
|
|
|
|
|
|
return initialPath;
|
|
|
|
}
|
|
|
|
|
|
|
|
const { instancePath } = await inquirer.prompt<{ instancePath: string }>(
|
|
|
|
{
|
|
|
|
name: 'instancePath',
|
|
|
|
message: 'Where is your Logto instance?',
|
|
|
|
type: 'input',
|
|
|
|
default: defaultPath,
|
|
|
|
filter: (value: string) => value.trim(),
|
2023-12-20 16:45:01 +08:00
|
|
|
validate: conditional(!skipCoreCheck && validatePath),
|
2023-09-13 16:12:43 +08:00
|
|
|
},
|
|
|
|
{ instancePath: initialPath }
|
|
|
|
);
|
|
|
|
|
|
|
|
return instancePath;
|
|
|
|
};
|
|
|
|
|
|
|
|
const instancePath = await inquire();
|
|
|
|
|
2023-12-20 16:45:01 +08:00
|
|
|
if (!skipCoreCheck) {
|
|
|
|
const validated = await validatePath(instancePath);
|
|
|
|
|
|
|
|
if (validated !== true) {
|
|
|
|
consoleLog.fatal(validated);
|
|
|
|
}
|
2023-09-13 16:12:43 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
return instancePath;
|
|
|
|
};
|
|
|
|
|
2022-11-08 20:29:26 +08:00
|
|
|
const getConnectorPackageName = async (directory: string) => {
|
|
|
|
const filePath = path.join(directory, 'package.json');
|
|
|
|
|
|
|
|
if (!existsSync(filePath)) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const json = await readFile(filePath, 'utf8');
|
|
|
|
const { name } = z.object({ name: z.string() }).parse(JSON.parse(json));
|
|
|
|
|
|
|
|
if (name.startsWith('connector-') || Boolean(name.split('/')[1]?.startsWith('connector-'))) {
|
|
|
|
return name;
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
export type ConnectorPackage = {
|
|
|
|
name: string;
|
|
|
|
path: string;
|
|
|
|
};
|
|
|
|
|
|
|
|
export const getConnectorPackagesFromDirectory = async (directory: string) => {
|
|
|
|
const content = await readdir(directory, 'utf8');
|
|
|
|
const rawPackages = await Promise.all(
|
|
|
|
content.map(async (value) => {
|
|
|
|
const currentDirectory = path.join(directory, value);
|
|
|
|
|
|
|
|
return { name: await getConnectorPackageName(currentDirectory), path: currentDirectory };
|
|
|
|
})
|
|
|
|
);
|
|
|
|
|
|
|
|
return rawPackages.filter(
|
|
|
|
(packageInfo): packageInfo is ConnectorPackage => typeof packageInfo.name === 'string'
|
|
|
|
);
|
|
|
|
};
|
2023-09-13 16:12:43 +08:00
|
|
|
|
|
|
|
const execPromise = promisify(execFile);
|
|
|
|
|
|
|
|
export const lintLocaleFiles = async (
|
|
|
|
/** Logto instance path */
|
|
|
|
instancePath: string,
|
2023-12-20 16:45:01 +08:00
|
|
|
/** Target package name, ignore to lint both `phrases` and `phrases-experience` packages */
|
|
|
|
packageName?: string
|
2023-09-13 16:12:43 +08:00
|
|
|
) => {
|
|
|
|
const spinner = ora({
|
|
|
|
text: 'Running `eslint --fix` for locales',
|
|
|
|
}).start();
|
|
|
|
|
2023-09-15 10:29:41 +08:00
|
|
|
const targetPackages = packageName ? [packageName] : ['phrases', 'phrases-experience'];
|
2023-09-13 16:12:43 +08:00
|
|
|
|
|
|
|
await Promise.all(
|
|
|
|
targetPackages.map(async (packageName) => {
|
|
|
|
const phrasesPath = path.join(instancePath, 'packages', packageName);
|
|
|
|
const localesPath = path.join(phrasesPath, 'src/locales');
|
|
|
|
await execPromise(
|
|
|
|
'pnpm',
|
|
|
|
['eslint', '--ext', '.ts', path.relative(phrasesPath, localesPath), '--fix'],
|
|
|
|
{ cwd: phrasesPath }
|
|
|
|
);
|
|
|
|
})
|
|
|
|
);
|
|
|
|
|
|
|
|
spinner.succeed('Ran `eslint --fix` for locales');
|
|
|
|
};
|