diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 39c4634d5..711878ffa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,6 +12,7 @@ concurrency: jobs: dockerize: + environment: ${{ startsWith(github.ref, 'refs/tags/') && 'release' || '' }} runs-on: ubuntu-latest steps: @@ -79,6 +80,7 @@ jobs: create-github-release: + environment: release runs-on: ubuntu-latest if: startsWith(github.ref, 'refs/tags/') diff --git a/.gitpod.yml b/.gitpod.yml index 70a454875..7d5eaf0c7 100644 --- a/.gitpod.yml +++ b/.gitpod.yml @@ -15,8 +15,6 @@ tasks: pnpm cli db seed pnpm lerna --ignore=@logto/integration-test run --parallel dev env: - ALL_YES: 1 - NO_INQUIRY: 0 TRUST_PROXY_HEADER: 1 DB_URL: postgres://postgres:p0stgr3s@127.0.0.1:5432 diff --git a/merge-eslint-reports.js b/merge-eslint-reports.js index 5408f6c06..2012ec7d1 100644 --- a/merge-eslint-reports.js +++ b/merge-eslint-reports.js @@ -3,7 +3,7 @@ const fs = require('fs'); const directories = fs.readdirSync('./packages'); const reports = directories // Filter out docs temporarily - .filter((dir) => ['docs', 'create'].includes(dir)) + .filter((dir) => !['docs', 'create'].includes(dir)) .map((dir) => fs.readFileSync(`./packages/${dir}/report.json`, { encoding: 'utf-8' })); const merged = []; diff --git a/package.json b/package.json index e5450055b..0e80c2971 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,8 @@ "pnpm": { "peerDependencyRules": { "allowedVersions": { - "react": "^18.0.0" + "react": "^18.0.0", + "jest": "^29.1.2" } } } diff --git a/packages/cli/.gitignore b/packages/cli/.gitignore index 3e3a1fa6e..5b78a4af2 100644 --- a/packages/cli/.gitignore +++ b/packages/cli/.gitignore @@ -1 +1,2 @@ alteration-scripts/ +src/package-json.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index dd36cd7ec..d6b7c7630 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -19,7 +19,8 @@ }, "scripts": { "precommit": "lint-staged", - "build": "rimraf lib && tsc -p tsconfig.build.json", + "prepare:package-json": "node -p \"'export const packageJson = ' + JSON.stringify(require('./package.json'), undefined, 2) + ';'\" > src/package-json.ts", + "build": "rimraf lib && pnpm prepare:package-json && tsc -p tsconfig.build.json", "start": "node .", "start:dev": "ts-node --files src/index.ts", "lint": "eslint --ext .ts src", @@ -41,13 +42,13 @@ "chalk": "^4.1.2", "decamelize": "^5.0.0", "dotenv": "^16.0.0", - "find-up": "^5.0.0", "fs-extra": "^10.1.0", "got": "^11.8.2", "hpagent": "^1.0.0", "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", @@ -58,18 +59,18 @@ "zod": "^3.18.0" }, "devDependencies": { - "@silverhand/eslint-config": "1.0.0", - "@silverhand/jest-config": "1.0.0", - "@silverhand/ts-config": "1.0.0", + "@silverhand/eslint-config": "1.2.0", + "@silverhand/jest-config": "1.2.2", + "@silverhand/ts-config": "1.2.1", "@types/fs-extra": "^9.0.13", "@types/inquirer": "^8.2.1", - "@types/jest": "^28.1.6", + "@types/jest": "^29.1.2", "@types/node": "^16.0.0", "@types/semver": "^7.3.12", "@types/tar": "^6.1.2", "@types/yargs": "^17.0.13", "eslint": "^8.21.0", - "jest": "^28.1.3", + "jest": "^29.1.2", "lint-staged": "^13.0.0", "prettier": "^2.7.1", "rimraf": "^3.0.2", @@ -78,12 +79,9 @@ }, "eslintConfig": { "extends": "@silverhand", - "rules": { - "complexity": [ - "error", - 7 - ] - } + "ignorePatterns": [ + "src/package-json.ts" + ] }, "prettier": "@silverhand/eslint-config/.prettierrc" } diff --git a/packages/cli/src/commands/connector/add.ts b/packages/cli/src/commands/connector/add.ts new file mode 100644 index 000000000..0e59c6ffd --- /dev/null +++ b/packages/cli/src/commands/connector/add.ts @@ -0,0 +1,39 @@ +import { CommandModule } from 'yargs'; + +import { log } from '../../utilities'; +import { addConnectors, addOfficialConnectors, inquireInstancePath } 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); + + if (official) { + await addOfficialConnectors(instancePath); + } + + await addConnectors(instancePath, packageNames); + + log.info('Restart your Logto instance to get the changes reflected.'); + }, +}; + +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..9fbe72fdf --- /dev/null +++ b/packages/cli/src/commands/connector/utils.ts @@ -0,0 +1,172 @@ +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 { connectorDirectory } from '../../constants'; +import { log, oraPromise } 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, connectorDirectory); + + if (!existsSync(cwd)) { + await mkdir(cwd); + } + + log.info('Fetch connector metadata'); + + const results = await Promise.all( + packageNames + .map((name) => normalizePackageName(name)) + .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-'; + +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) + ) + ); +}; + +export const addOfficialConnectors = async (instancePath: string) => { + const packages = await oraPromise(fetchOfficialConnectorList(), { + text: 'Fetch official connector list', + prefixText: chalk.blue('[info]'), + }); + await addConnectors(instancePath, packages); +}; diff --git a/packages/cli/src/commands/database/alteration.test.ts b/packages/cli/src/commands/database/alteration.test.ts deleted file mode 100644 index 91f2a997e..000000000 --- a/packages/cli/src/commands/database/alteration.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { createMockPool } from 'slonik'; - -import * as queries from '../../queries/logto-config'; -import { QueryType } from '../../test-utilities'; -import * as functions from './alteration'; - -const mockQuery: jest.MockedFunction = jest.fn(); - -const pool = createMockPool({ - query: async (sql, values) => { - return mockQuery(sql, values); - }, -}); - -const files = Object.freeze([ - { filename: '1.0.0-1663923770-a.js', path: '/alterations/1.0.0-1663923770-a.js' }, - { filename: '1.0.0-1663923771-b.js', path: '/alterations/1.0.0-1663923771-b.js' }, - { filename: '1.0.0-1663923772-c.js', path: '/alterations/1.0.0-1663923772-c.js' }, -]); - -describe('getUndeployedAlterations()', () => { - beforeEach(() => { - // `getAlterationFiles()` will ensure the order - jest.spyOn(functions, 'getAlterationFiles').mockResolvedValueOnce([...files]); - }); - - it('returns all files if database timestamp is 0', async () => { - jest.spyOn(queries, 'getCurrentDatabaseAlterationTimestamp').mockResolvedValueOnce(0); - - await expect(functions.getUndeployedAlterations(pool)).resolves.toEqual(files); - }); - - it('returns files whose timestamp is greater then database timestamp', async () => { - jest - .spyOn(queries, 'getCurrentDatabaseAlterationTimestamp') - .mockResolvedValueOnce(1_663_923_770); - - await expect(functions.getUndeployedAlterations(pool)).resolves.toEqual([files[1], files[2]]); - }); -}); diff --git a/packages/cli/src/commands/database/alteration/index.test.ts b/packages/cli/src/commands/database/alteration/index.test.ts new file mode 100644 index 000000000..5584dcf83 --- /dev/null +++ b/packages/cli/src/commands/database/alteration/index.test.ts @@ -0,0 +1,81 @@ +import { createMockPool } from 'slonik'; + +import * as functions from '.'; +import * as queries from '../../../queries/logto-config'; +import { QueryType } from '../../../test-utilities'; +import { chooseAlterationsByVersion } from './version'; + +const mockQuery: jest.MockedFunction = jest.fn(); + +const pool = createMockPool({ + query: async (sql, values) => { + return mockQuery(sql, values); + }, +}); + +describe('getUndeployedAlterations()', () => { + const files = Object.freeze([ + { filename: '1.0.0-1663923770-a.js', path: '/alterations/1.0.0-1663923770-a.js' }, + { filename: '1.0.0-1663923771-b.js', path: '/alterations/1.0.0-1663923771-b.js' }, + { filename: '1.0.0-1663923772-c.js', path: '/alterations/1.0.0-1663923772-c.js' }, + ]); + + beforeEach(() => { + // `getAlterationFiles()` will ensure the order + jest.spyOn(functions, 'getAlterationFiles').mockResolvedValueOnce([...files]); + }); + + it('returns all files if database timestamp is 0', async () => { + jest.spyOn(queries, 'getCurrentDatabaseAlterationTimestamp').mockResolvedValueOnce(0); + + await expect(functions.getUndeployedAlterations(pool)).resolves.toEqual(files); + }); + + it('returns files whose timestamp is greater then database timestamp', async () => { + jest + .spyOn(queries, 'getCurrentDatabaseAlterationTimestamp') + .mockResolvedValueOnce(1_663_923_770); + + await expect(functions.getUndeployedAlterations(pool)).resolves.toEqual([files[1], files[2]]); + }); +}); + +describe('chooseAlterationsByVersion()', () => { + const files = Object.freeze( + [ + '1.0.0_beta.9-1663923770-a.js', + '1.0.0_beta.9-1663923771-b.js', + '1.0.0_beta.10-1663923772-c.js', + '1.0.0_beta.11-1663923773-c.js', + '1.0.0_beta.11-1663923774-c.js', + '1.0.0-1663923775-c.js', + '1.0.0-1663923776-c.js', + '1.0.1-1663923777-c.js', + '1.2.0-1663923778-c.js', + 'next-1663923778-c.js', + 'next-1663923779-c.js', + 'next-1663923780-c.js', + 'next1-1663923781-c.js', + ].map((filename) => ({ filename, path: '/alterations/' + filename })) + ); + + it('chooses nothing when input version is invalid', async () => { + await expect(chooseAlterationsByVersion(files, 'next1')).rejects.toThrow( + 'Invalid Version: next1' + ); + await expect(chooseAlterationsByVersion([], 'ok')).rejects.toThrow('Invalid Version: ok'); + }); + + it('chooses correct alteration files', async () => { + await Promise.all([ + expect(chooseAlterationsByVersion([], 'v1.0.0')).resolves.toEqual([]), + expect(chooseAlterationsByVersion(files, 'v1.0.0')).resolves.toEqual(files.slice(0, 7)), + expect(chooseAlterationsByVersion(files, 'v1.0.0-beta.10')).resolves.toEqual( + files.slice(0, 3) + ), + expect(chooseAlterationsByVersion(files, 'v1.1.0')).resolves.toEqual(files.slice(0, 8)), + expect(chooseAlterationsByVersion(files, 'v1.2.0')).resolves.toEqual(files.slice(0, 9)), + expect(chooseAlterationsByVersion(files, 'next')).resolves.toEqual(files.slice(0, 11)), + ]); + }); +}); diff --git a/packages/cli/src/commands/database/alteration.ts b/packages/cli/src/commands/database/alteration/index.ts similarity index 68% rename from packages/cli/src/commands/database/alteration.ts rename to packages/cli/src/commands/database/alteration/index.ts index 4ec9a73b3..6c45de635 100644 --- a/packages/cli/src/commands/database/alteration.ts +++ b/packages/cli/src/commands/database/alteration/index.ts @@ -1,27 +1,29 @@ import path from 'path'; import { AlterationScript } from '@logto/schemas/lib/types/alteration'; -import { conditional, conditionalString } from '@silverhand/essentials'; +import { findPackage } from '@logto/shared'; +import { conditionalString } from '@silverhand/essentials'; import chalk from 'chalk'; -import findUp, { exists } from 'find-up'; import { copy, existsSync, remove, readdir } from 'fs-extra'; import { DatabasePool } from 'slonik'; import { CommandModule } from 'yargs'; -import { createPoolFromConfig } from '../../database'; +import { createPoolFromConfig } from '../../../database'; import { getCurrentDatabaseAlterationTimestamp, updateDatabaseTimestamp, -} from '../../queries/logto-config'; -import { getPathInModule, log } from '../../utilities'; +} from '../../../queries/logto-config'; +import { getPathInModule, log } from '../../../utilities'; +import { AlterationFile } from './type'; +import { chooseAlterationsByVersion } from './version'; -const alterationFileNameRegex = /-(\d+)-?.*\.js$/; +const alterationFilenameRegex = /-(\d+)-?.*\.js$/; -const getTimestampFromFileName = (fileName: string) => { - const match = alterationFileNameRegex.exec(fileName); +const getTimestampFromFilename = (filename: string) => { + const match = alterationFilenameRegex.exec(filename); if (!match?.[1]) { - throw new Error(`Can not get timestamp: ${fileName}`); + throw new Error(`Can not get timestamp: ${filename}`); } return Number(match[1]); @@ -35,8 +37,6 @@ const importAlterationScript = async (filePath: string): Promise => { const alterationDirectory = getPathInModule('@logto/schemas', 'alterations'); @@ -45,18 +45,10 @@ export const getAlterationFiles = async (): Promise => { * since they need a proper context that includes required dependencies (such as slonik) in `node_modules/`. * While the original `@logto/schemas` may remove them in production. */ - const packageDirectory = await findUp( - async (directory) => { - const hasPackageJson = await exists(path.join(directory, 'package.json')); - - return conditional(hasPackageJson && directory); - }, - { - // Until we migrate to ESM - // eslint-disable-next-line unicorn/prefer-module - cwd: __dirname, - type: 'directory', - } + const packageDirectory = await findPackage( + // Until we migrate to ESM + // eslint-disable-next-line unicorn/prefer-module + __dirname ); const localAlterationDirectory = path.resolve( @@ -75,11 +67,11 @@ export const getAlterationFiles = async (): Promise => { await copy(alterationDirectory, localAlterationDirectory); const directory = await readdir(localAlterationDirectory); - const files = directory.filter((file) => alterationFileNameRegex.test(file)); + const files = directory.filter((file) => alterationFilenameRegex.test(file)); return files .slice() - .sort((file1, file2) => getTimestampFromFileName(file1) - getTimestampFromFileName(file2)) + .sort((file1, file2) => getTimestampFromFilename(file1) - getTimestampFromFilename(file2)) .map((filename) => ({ path: path.join(localAlterationDirectory, filename), filename })); }; @@ -91,14 +83,14 @@ export const getLatestAlterationTimestamp = async () => { return 0; } - return getTimestampFromFileName(lastFile.filename); + return getTimestampFromFilename(lastFile.filename); }; export const getUndeployedAlterations = async (pool: DatabasePool) => { const databaseTimestamp = await getCurrentDatabaseAlterationTimestamp(pool); const files = await getAlterationFiles(); - return files.filter(({ filename }) => getTimestampFromFileName(filename) > databaseTimestamp); + return files.filter(({ filename }) => getTimestampFromFilename(filename) > databaseTimestamp); }; const deployAlteration = async ( @@ -110,7 +102,7 @@ const deployAlteration = async ( try { await pool.transaction(async (connection) => { await up(connection); - await updateDatabaseTimestamp(connection, getTimestampFromFileName(filename)); + await updateDatabaseTimestamp(connection, getTimestampFromFilename(filename)); }); } catch (error: unknown) { console.error(error); @@ -126,22 +118,30 @@ const deployAlteration = async ( log.info(`Run alteration ${filename} succeeded`); }; -const alteration: CommandModule = { - command: ['alteration ', 'alt', 'alter'], +const alteration: CommandModule = { + command: ['alteration [target]', 'alt', 'alter'], describe: 'Perform database alteration', builder: (yargs) => - yargs.positional('action', { - describe: 'The action to perform, now it only accepts `deploy`', - type: 'string', - demandOption: true, - }), - handler: async ({ action }) => { + yargs + .positional('action', { + describe: 'The action to perform, now it only accepts `deploy`', + type: 'string', + demandOption: true, + }) + .positional('target', { + describe: 'The target Logto version for alteration', + type: 'string', + }), + handler: async ({ action, target }) => { if (action !== 'deploy') { log.error('Unsupported action'); } const pool = await createPoolFromConfig(); - const alterations = await getUndeployedAlterations(pool); + const alterations = await chooseAlterationsByVersion( + await getUndeployedAlterations(pool), + target + ); log.info( `Found ${alterations.length} alteration${conditionalString( diff --git a/packages/cli/src/commands/database/alteration/type.ts b/packages/cli/src/commands/database/alteration/type.ts new file mode 100644 index 000000000..dc94e658e --- /dev/null +++ b/packages/cli/src/commands/database/alteration/type.ts @@ -0,0 +1 @@ +export type AlterationFile = { path: string; filename: string }; diff --git a/packages/cli/src/commands/database/alteration/version.ts b/packages/cli/src/commands/database/alteration/version.ts new file mode 100644 index 000000000..21605a8ee --- /dev/null +++ b/packages/cli/src/commands/database/alteration/version.ts @@ -0,0 +1,76 @@ +import { conditional } from '@silverhand/essentials'; +import chalk from 'chalk'; +import inquirer from 'inquirer'; +import { SemVer, compare, eq, gt } from 'semver'; + +import { findLastIndex, log } from '../../../utilities'; +import { AlterationFile } from './type'; + +const getVersionFromFilename = (filename: string) => { + try { + return new SemVer(filename.split('-')[0]?.replaceAll('_', '-') ?? 'unknown'); + } catch {} +}; + +const latestTag = 'latest'; +const nextTag = 'next'; + +export const chooseAlterationsByVersion = async ( + alterations: readonly AlterationFile[], + initialVersion?: string +) => { + if (initialVersion === nextTag) { + const endIndex = findLastIndex( + alterations, + ({ filename }) => + filename.startsWith(nextTag + '-') || Boolean(getVersionFromFilename(filename)) + ); + + if (endIndex === -1) { + return []; + } + + log.info(`Deploy target ${chalk.green(nextTag)}`); + + return alterations.slice(0, endIndex); + } + + const versions = alterations + .map(({ filename }) => getVersionFromFilename(filename)) + .filter((version): version is SemVer => version instanceof SemVer) + // Cannot use `Set` to deduplicate since it's a class + .filter((version, index, self) => index === self.findIndex((another) => eq(version, another))) + .slice() + .sort((i, j) => compare(j, i)); + const initialSemVersion = conditional(initialVersion && new SemVer(initialVersion)); + + if (!versions[0]) { + return []; + } + + const { version: targetVersion } = + initialVersion === latestTag + ? { version: versions[0] } + : await inquirer.prompt<{ version: SemVer }>( + { + type: 'list', + message: 'Choose the alteration target version', + name: 'version', + choices: versions.map((semVersion) => ({ + name: semVersion.version, + value: semVersion, + })), + }, + { + version: initialSemVersion, + } + ); + + log.info(`Deploy target ${chalk.green(targetVersion.version)}`); + + return alterations.filter(({ filename }) => { + const version = getVersionFromFilename(filename); + + return version && !gt(version, targetVersion); + }); +}; diff --git a/packages/cli/src/commands/install/index.ts b/packages/cli/src/commands/install/index.ts index 3d08fbac2..1844309dd 100644 --- a/packages/cli/src/commands/install/index.ts +++ b/packages/cli/src/commands/install/index.ts @@ -3,6 +3,7 @@ import { CommandModule } from 'yargs'; import { getDatabaseUrlFromConfig } from '../../database'; import { log } from '../../utilities'; +import { addOfficialConnectors } from '../connector/utils'; import { validateNodeVersion, inquireInstancePath, @@ -12,14 +13,16 @@ import { createEnv, logFinale, decompress, + inquireOfficialConnectors, } from './utils'; export type InstallArgs = { path?: string; skipSeed: boolean; + officialConnectors?: boolean; }; -const installLogto = async ({ path, skipSeed }: InstallArgs) => { +const installLogto = async ({ path, skipSeed, officialConnectors }: InstallArgs) => { validateNodeVersion(); // Get instance path @@ -34,7 +37,11 @@ const installLogto = async ({ path, skipSeed }: InstallArgs) => { // Seed database if (skipSeed) { - log.info(`You can use ${chalk.green('db seed')} command to seed database when ready.`); + log.info( + `Skipped database seeding.\n\n' + ' You can use the ${chalk.green( + 'db seed' + )} command to seed database when ready.\n` + ); } else { await seedDatabase(instancePath); } @@ -42,29 +49,51 @@ const installLogto = async ({ path, skipSeed }: InstallArgs) => { // Save to dot env await createEnv(instancePath, await getDatabaseUrlFromConfig()); + // Add official connectors + if (await inquireOfficialConnectors(officialConnectors)) { + await addOfficialConnectors(instancePath); + } else { + log.info( + 'Skipped adding official connectors.\n\n' + + ` You can use the ${chalk.green('connector add')} command to add connectors at any time.\n` + ); + } + // Finale logFinale(instancePath); }; -const install: CommandModule = { +const install: CommandModule< + unknown, + { + p?: string; + ss: boolean; + oc?: boolean; + } +> = { command: ['init', 'i', 'install'], describe: 'Download and run the latest Logto release', builder: (yargs) => yargs.options({ - path: { - alias: 'p', + p: { + alias: 'path', describe: 'Path of Logto, must be a non-existing path', type: 'string', }, - skipSeed: { - alias: 'ss', + ss: { + alias: 'skip-seed', describe: 'Skip Logto database seeding', type: 'boolean', default: false, }, + oc: { + alias: 'official-connectors', + describe: 'Add official connectors after downloading Logto', + type: 'boolean', + }, }), - handler: async ({ path, skipSeed }) => { - await installLogto({ path, skipSeed }); + handler: async ({ p, ss, oc }) => { + await installLogto({ path: p, skipSeed: ss, officialConnectors: oc }); }, }; diff --git a/packages/cli/src/commands/install/utils.ts b/packages/cli/src/commands/install/utils.ts index d120d694c..5e295b11d 100644 --- a/packages/cli/src/commands/install/utils.ts +++ b/packages/cli/src/commands/install/utils.ts @@ -152,3 +152,17 @@ export const logFinale = (instancePath: string) => { `Use the command below to start Logto. Happy hacking!\n\n ${chalk.green(startCommand)}` ); }; + +export const inquireOfficialConnectors = async (initialAnswer?: boolean) => { + const { value } = await inquirer.prompt<{ value: boolean }>( + { + name: 'value', + message: 'Do you want to add official connectors?', + type: 'confirm', + default: true, + }, + { value: initialAnswer } + ); + + return value; +}; diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts new file mode 100644 index 000000000..a759ff0c4 --- /dev/null +++ b/packages/cli/src/constants.ts @@ -0,0 +1 @@ +export const connectorDirectory = 'connectors'; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index a47130561..e0596c9e4 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -3,22 +3,38 @@ 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 { packageJson } from './package-json'; import { cliConfig, ConfigKey } from './utilities'; void yargs(hideBin(process.argv)) + .version(false) .option('env', { alias: ['e', 'env-file'], describe: 'The path to your `.env` file', type: 'string', }) - .option('databaseUrl', { - alias: ['db-url'], + .option('db', { + alias: ['db-url', 'database-url'], describe: 'The Postgres URL to Logto database', type: 'string', }) - .middleware(({ env, databaseUrl }) => { + .option('version', { + alias: 'v', + describe: 'Print Logto CLI version', + type: 'boolean', + global: false, + }) + .middleware(({ version }) => { + if (version) { + console.log(packageJson.name + ' v' + packageJson.version); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(0); + } + }, true) + .middleware(({ env, db: databaseUrl }) => { dotenv.config({ path: env }); const initialDatabaseUrl = databaseUrl ?? process.env[ConfigKey.DatabaseUrl]; @@ -29,6 +45,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/packages/cli/src/utilities.ts b/packages/cli/src/utilities.ts index c2d7d42f9..62b14efb7 100644 --- a/packages/cli/src/utilities.ts +++ b/packages/cli/src/utilities.ts @@ -152,3 +152,30 @@ export const getCliConfigWithPrompt = async ({ return input; }; + +// 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( + 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; +} diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index 3d69fefb3..ef675dbdc 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -4,7 +4,8 @@ "outDir": "lib", "declaration": true, "module": "node16", - "target": "es2022" + "target": "es2022", + "types": ["node", "jest"] }, "include": [ "src", diff --git a/packages/console/package.json b/packages/console/package.json index 0c3aefdfe..ebaa444d2 100644 --- a/packages/console/package.json +++ b/packages/console/package.json @@ -18,7 +18,7 @@ }, "devDependencies": { "@fontsource/roboto-mono": "^4.5.7", - "@logto/core-kit": "1.0.0-beta.16", + "@logto/core-kit": "1.0.0-beta.18", "@logto/language-kit": "1.0.0-beta.16", "@logto/phrases": "^1.0.0-beta.10", "@logto/phrases-ui": "^1.0.0-beta.10", @@ -29,11 +29,11 @@ "@parcel/transformer-mdx": "2.7.0", "@parcel/transformer-sass": "2.7.0", "@parcel/transformer-svg-react": "2.7.0", - "@silverhand/eslint-config": "1.0.0", - "@silverhand/eslint-config-react": "1.1.0", + "@silverhand/eslint-config": "1.2.0", + "@silverhand/eslint-config-react": "1.2.1", "@silverhand/essentials": "^1.3.0", - "@silverhand/ts-config": "1.0.0", - "@silverhand/ts-config-react": "1.1.0", + "@silverhand/ts-config": "1.2.1", + "@silverhand/ts-config-react": "1.2.1", "@tsconfig/docusaurus": "^1.0.5", "@types/color": "^3.0.3", "@types/lodash.kebabcase": "^4.1.6", @@ -91,8 +91,7 @@ "eslintConfig": { "extends": "@silverhand/react", "rules": { - "complexity": "off", - "@typescript-eslint/prefer-nullish-coalescing": "off" + "complexity": "off" } }, "stylelint": { diff --git a/packages/console/src/assets/images/clear.svg b/packages/console/src/assets/images/clear.svg new file mode 100644 index 000000000..f935ea9ec --- /dev/null +++ b/packages/console/src/assets/images/clear.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/console/src/components/Alert/index.module.scss b/packages/console/src/components/Alert/index.module.scss index 8036346ea..5c5e0bffd 100644 --- a/packages/console/src/components/Alert/index.module.scss +++ b/packages/console/src/components/Alert/index.module.scss @@ -16,7 +16,7 @@ .icon { width: 20px; height: 20px; - color: var(--color-icon); + color: var(--color-text-secondary); } .content { diff --git a/packages/console/src/components/Alert/index.tsx b/packages/console/src/components/Alert/index.tsx index 42bb5a66e..ac32821bb 100644 --- a/packages/console/src/components/Alert/index.tsx +++ b/packages/console/src/components/Alert/index.tsx @@ -38,7 +38,7 @@ const Alert = ({ )} {action && onClick && (
-
)} diff --git a/packages/console/src/components/AppContent/components/Sidebar/components/Contact/index.module.scss b/packages/console/src/components/AppContent/components/Sidebar/components/Contact/index.module.scss index fc8d799bd..1a9ba4768 100644 --- a/packages/console/src/components/AppContent/components/Sidebar/components/Contact/index.module.scss +++ b/packages/console/src/components/AppContent/components/Sidebar/components/Contact/index.module.scss @@ -26,7 +26,7 @@ .description { font: var(--font-body-medium); - color: var(--color-caption); + color: var(--color-text-secondary); } } diff --git a/packages/console/src/components/AppContent/components/UserInfo/index.module.scss b/packages/console/src/components/AppContent/components/UserInfo/index.module.scss index e93ecff05..7e5525453 100644 --- a/packages/console/src/components/AppContent/components/UserInfo/index.module.scss +++ b/packages/console/src/components/AppContent/components/UserInfo/index.module.scss @@ -36,7 +36,7 @@ .role { font: var(--font-body-small); - color: var(--color-caption); + color: var(--color-text-secondary); } } } @@ -58,7 +58,7 @@ } .signOutIcon { - color: var(--color-icon); + color: var(--color-text-secondary); } .spinner { diff --git a/packages/console/src/components/ApplicationName/index.tsx b/packages/console/src/components/ApplicationName/index.tsx index 650fac058..895760f7a 100644 --- a/packages/console/src/components/ApplicationName/index.tsx +++ b/packages/console/src/components/ApplicationName/index.tsx @@ -17,7 +17,7 @@ const ApplicationName = ({ applicationId, isLink = false }: Props) => { const { data } = useSWR(!isAdminConsole && `/api/applications/${applicationId}`); const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); - const name = (isAdminConsole ? <>Admin Console ({t('system_app')}) : data?.name) || '-'; + const name = (isAdminConsole ? <>Admin Console ({t('system_app')}) : data?.name) ?? '-'; if (isLink && !isAdminConsole) { return ( diff --git a/packages/console/src/components/AuditLogTable/index.module.scss b/packages/console/src/components/AuditLogTable/index.module.scss index 66a32c543..cff0275e0 100644 --- a/packages/console/src/components/AuditLogTable/index.module.scss +++ b/packages/console/src/components/AuditLogTable/index.module.scss @@ -6,7 +6,7 @@ align-items: center; .title { - color: var(--color-caption); + color: var(--color-text-secondary); font: var(--font-body-medium); } diff --git a/packages/console/src/components/Button/index.module.scss b/packages/console/src/components/Button/index.module.scss index d61f06112..07ea78ee9 100644 --- a/packages/console/src/components/Button/index.module.scss +++ b/packages/console/src/components/Button/index.module.scss @@ -53,9 +53,8 @@ height: 30px; padding: 0 _.unit(3); - &.plain { + &.text { height: 24px; - padding: 0; } } @@ -63,9 +62,9 @@ height: 36px; padding: 0 _.unit(4); - &.plain { + &.text { + font: var(--font-subhead-1); height: 28px; - padding: 0; } } @@ -73,16 +72,17 @@ height: 44px; padding: 0 _.unit(6); - &.plain { - height: 28px; // same as medium - padding: 0; + &.text { + // same as medium + font: var(--font-subhead-1); + height: 28px; } } &.default { background: var(--color-layer-1); color: var(--color-text); - border-color: var(--color-outline); + border-color: var(--color-border); border-width: 1px; border-style: solid; @@ -196,11 +196,13 @@ } } - &.plain { + &.text { background: none; border-color: none; - font: var(--font-body-medium); + font: var(--font-label-large); color: var(--color-text-link); + padding: _.unit(0.5) _.unit(1); + border-radius: 4px; &:disabled { color: var(--color-disabled); @@ -211,7 +213,7 @@ } &:not(:disabled):hover { - text-decoration: underline; + background-color: var(--color-focused-variant); } } } diff --git a/packages/console/src/components/Button/index.tsx b/packages/console/src/components/Button/index.tsx index 9f0ee18fb..f5dd61d14 100644 --- a/packages/console/src/components/Button/index.tsx +++ b/packages/console/src/components/Button/index.tsx @@ -8,7 +8,7 @@ import { Ring as Spinner } from '@/components/Spinner'; import DangerousRaw from '../DangerousRaw'; import * as styles from './index.module.scss'; -export type ButtonType = 'primary' | 'danger' | 'outline' | 'plain' | 'default' | 'branding'; +export type ButtonType = 'primary' | 'danger' | 'outline' | 'text' | 'default' | 'branding'; type BaseProps = Omit, 'type' | 'size' | 'title'> & { htmlType?: 'button' | 'submit' | 'reset'; diff --git a/packages/console/src/components/CardTitle/index.module.scss b/packages/console/src/components/CardTitle/index.module.scss index be5798ae4..c29c3b8c5 100644 --- a/packages/console/src/components/CardTitle/index.module.scss +++ b/packages/console/src/components/CardTitle/index.module.scss @@ -10,7 +10,7 @@ .subtitle { margin-top: _.unit(1); - color: var(--color-caption); + color: var(--color-text-secondary); } &.large { diff --git a/packages/console/src/components/CopyToClipboard/index.module.scss b/packages/console/src/components/CopyToClipboard/index.module.scss index 542976b50..ae68d6b67 100644 --- a/packages/console/src/components/CopyToClipboard/index.module.scss +++ b/packages/console/src/components/CopyToClipboard/index.module.scss @@ -33,6 +33,11 @@ .copyIcon { margin-left: _.unit(3); + + svg { + width: 16px; + height: 16px; + } } } } diff --git a/packages/console/src/components/FormField/index.module.scss b/packages/console/src/components/FormField/index.module.scss index fe1794d95..3fd003454 100644 --- a/packages/console/src/components/FormField/index.module.scss +++ b/packages/console/src/components/FormField/index.module.scss @@ -20,11 +20,11 @@ margin-left: _.unit(1); width: 16px; height: 16px; - color: var(--color-caption); + color: var(--color-text-secondary); } .required { font: var(--font-body-medium); - color: var(--color-caption); + color: var(--color-text-secondary); } } diff --git a/packages/console/src/components/IconButton/index.module.scss b/packages/console/src/components/IconButton/index.module.scss index a9c6db380..f77264270 100644 --- a/packages/console/src/components/IconButton/index.module.scss +++ b/packages/console/src/components/IconButton/index.module.scss @@ -15,7 +15,7 @@ align-items: center; > svg { - color: var(--color-caption); + color: var(--color-text-secondary); } &:disabled { diff --git a/packages/console/src/components/IconButton/index.tsx b/packages/console/src/components/IconButton/index.tsx index 60dc4e313..14113cde2 100644 --- a/packages/console/src/components/IconButton/index.tsx +++ b/packages/console/src/components/IconButton/index.tsx @@ -1,18 +1,49 @@ +import { AdminConsoleKey } from '@logto/phrases'; +import { Nullable } from '@silverhand/essentials'; import classNames from 'classnames'; -import { HTMLProps } from 'react'; +import { ForwardedRef, forwardRef, HTMLProps, useImperativeHandle, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import Tooltip from '../Tooltip'; import * as styles from './index.module.scss'; export type Props = Omit, 'size' | 'type'> & { size?: 'small' | 'medium' | 'large'; + tooltip?: AdminConsoleKey; }; -const IconButton = ({ size = 'medium', children, className, ...rest }: Props) => { +const IconButton = ( + { size = 'medium', children, className, tooltip, ...rest }: Props, + reference: ForwardedRef +) => { + const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); + const innerReference = useRef(null); + + useImperativeHandle, Nullable>( + reference, + () => innerReference.current + ); + return ( - + <> + + {tooltip && ( + + )} + ); }; -export default IconButton; +export default forwardRef(IconButton); diff --git a/packages/console/src/components/ItemPreview/index.module.scss b/packages/console/src/components/ItemPreview/index.module.scss index f1a89c1fe..e8f53fa71 100644 --- a/packages/console/src/components/ItemPreview/index.module.scss +++ b/packages/console/src/components/ItemPreview/index.module.scss @@ -21,7 +21,7 @@ .subtitle { font: var(--font-body-small); - color: var(--color-outline); + color: var(--color-text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; diff --git a/packages/console/src/components/Markdown/index.module.scss b/packages/console/src/components/Markdown/index.module.scss index 41f7efdd8..86e0e268a 100644 --- a/packages/console/src/components/Markdown/index.module.scss +++ b/packages/console/src/components/Markdown/index.module.scss @@ -47,7 +47,7 @@ h2 { font: var(--font-title-medium); - color: var(--color-caption); + color: var(--color-text-secondary); margin: _.unit(6) 0 _.unit(3); } diff --git a/packages/console/src/components/ModalLayout/index.module.scss b/packages/console/src/components/ModalLayout/index.module.scss index 1e1abf9a5..76777ddbe 100644 --- a/packages/console/src/components/ModalLayout/index.module.scss +++ b/packages/console/src/components/ModalLayout/index.module.scss @@ -19,7 +19,7 @@ margin-bottom: _.unit(6); .closeIcon { - color: var(--color-icon); + color: var(--color-text-secondary); } } diff --git a/packages/console/src/components/MultiTextInput/index.module.scss b/packages/console/src/components/MultiTextInput/index.module.scss index 8e7cf2fb0..d70a04d68 100644 --- a/packages/console/src/components/MultiTextInput/index.module.scss +++ b/packages/console/src/components/MultiTextInput/index.module.scss @@ -17,7 +17,7 @@ } .minusIcon { - color: var(--color-icon); + color: var(--color-text-secondary); } .addAnother { diff --git a/packages/console/src/components/MultiTextInput/index.tsx b/packages/console/src/components/MultiTextInput/index.tsx index 67d2722bd..e3b28690b 100644 --- a/packages/console/src/components/MultiTextInput/index.tsx +++ b/packages/console/src/components/MultiTextInput/index.tsx @@ -1,12 +1,10 @@ import { AdminConsoleKey } from '@logto/phrases'; -import classNames from 'classnames'; import { KeyboardEvent, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import Minus from '@/assets/images/minus.svg'; -import * as textButtonStyles from '@/components/TextButton/index.module.scss'; -import { onKeyDownHandler } from '@/utilities/a11y'; +import Button from '../Button'; import ConfirmModal from '../ConfirmModal'; import IconButton from '../IconButton'; import TextInput from '../TextInput'; @@ -86,15 +84,13 @@ const MultiTextInput = ({ title, value, onChange, onKeyPress, error, placeholder )} ))} -
- {t('general.add_another')} -
+ />