From 2d069cbebfbce76c7e95e8074d12376e5b36bc39 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Tue, 27 Sep 2022 14:43:17 +0800 Subject: [PATCH 01/54] refactor(schemas): auto update next alteration scripts (#2008) * refactor(schemas): auto update next alteration scripts * refactor(schemas): update eslint config * refactor(core): log when alteration is done --- packages/core/src/alteration/index.ts | 2 ++ packages/schemas/package.json | 13 ++++++++++++- packages/schemas/update-next.sh | 7 +++++++ 3 files changed, 21 insertions(+), 1 deletion(-) create mode 100755 packages/schemas/update-next.sh diff --git a/packages/core/src/alteration/index.ts b/packages/core/src/alteration/index.ts index 4edfd3430..7880d3284 100644 --- a/packages/core/src/alteration/index.ts +++ b/packages/core/src/alteration/index.ts @@ -154,4 +154,6 @@ export const deployAlterations = async (pool: DatabasePool) => { // eslint-disable-next-line no-await-in-loop await deployAlteration(pool, alteration); } + + console.log(`${chalk.blue('[alteration]')} ✓ done`); }; diff --git a/packages/schemas/package.json b/packages/schemas/package.json index 3a8f5d9d7..6f4e687a9 100644 --- a/packages/schemas/package.json +++ b/packages/schemas/package.json @@ -11,6 +11,7 @@ "private": true, "scripts": { "precommit": "lint-staged", + "version": "./update-next.sh && git add alterations/", "generate": "rm -rf src/db-entries && ts-node src/gen/index.ts && eslint \"src/db-entries/**\" --fix", "build:alterations": "rm -rf alterations/*.d.ts alterations/*.js && tsc -p tsconfig.build.alterations.json", "build": "pnpm generate && rm -rf lib/ && tsc -p tsconfig.build.json && pnpm build:alterations", @@ -47,7 +48,17 @@ "extends": "@silverhand", "rules": { "@typescript-eslint/ban-types": "off" - } + }, + "overrides": [ + { + "files": [ + "alterations/*.ts" + ], + "rules": { + "unicorn/filename-case": "off" + } + } + ] }, "prettier": "@silverhand/eslint-config/.prettierrc", "dependencies": { diff --git a/packages/schemas/update-next.sh b/packages/schemas/update-next.sh new file mode 100755 index 000000000..95961d5c1 --- /dev/null +++ b/packages/schemas/update-next.sh @@ -0,0 +1,7 @@ +CURRENT_VERSION=$(node -p "require('./package.json').version.replaceAll('-', '_')") + +cd alterations + +for x in next-*.ts; + do mv $x $CURRENT_VERSION$(echo ${x#next}); +done From 7b60b3028dc8994259dc2e15895d6358515e6b55 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Tue, 27 Sep 2022 15:01:26 +0800 Subject: [PATCH 02/54] chore: sleep 10s before dev health check (#2014) --- .github/workflows/deploy-dev.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml index b1033d378..2726272d9 100644 --- a/.github/workflows/deploy-dev.yml +++ b/.github/workflows/deploy-dev.yml @@ -48,8 +48,8 @@ jobs: env: DEV_SERVER_IP: ${{ secrets.DEV_SERVER_IP }} - - name: Sleep for 5 seconds - run: sleep 5s + - name: Sleep for 10 seconds + run: sleep 10s - name: Health check run: curl $DEV_SERVER_URL/api/status -If From 5bd54484b461c18958fd3837717b183612839626 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Tue, 27 Sep 2022 15:52:39 +0800 Subject: [PATCH 03/54] refactor(core): update filename to alteration (#2015) --- .../{check-migration-state.ts => check-alteration-state.ts} | 2 +- packages/core/src/env-set/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename packages/core/src/env-set/{check-migration-state.ts => check-alteration-state.ts} (85%) diff --git a/packages/core/src/env-set/check-migration-state.ts b/packages/core/src/env-set/check-alteration-state.ts similarity index 85% rename from packages/core/src/env-set/check-migration-state.ts rename to packages/core/src/env-set/check-alteration-state.ts index 5bd1345e1..9ab15bc37 100644 --- a/packages/core/src/env-set/check-migration-state.ts +++ b/packages/core/src/env-set/check-alteration-state.ts @@ -13,7 +13,7 @@ export const checkAlterationState = async (pool: DatabasePool) => { } const error = new Error( - `Found undeployed database alterations, you must deploy them first by "pnpm alteration deploy" command, reference: https://docs.logto.io/docs/recipes/deployment/#database-alteration` + `Found undeployed database alterations, you must deploy them first by "npm alteration deploy" command, reference: https://docs.logto.io/docs/recipes/deployment/#database-alteration` ); if (allYes) { diff --git a/packages/core/src/env-set/index.ts b/packages/core/src/env-set/index.ts index 3ae4cb3ae..a121c9939 100644 --- a/packages/core/src/env-set/index.ts +++ b/packages/core/src/env-set/index.ts @@ -6,7 +6,7 @@ import { DatabasePool } from 'slonik'; import { appendPath } from '@/utils/url'; import { addConnectors } from './add-connectors'; -import { checkAlterationState } from './check-migration-state'; +import { checkAlterationState } from './check-alteration-state'; import createPoolByEnv from './create-pool-by-env'; import loadOidcValues from './oidc'; import { isTrue } from './parameters'; From df18d871033e27c96a1ee81618b87b62de242903 Mon Sep 17 00:00:00 2001 From: IceHe Date: Tue, 27 Sep 2022 17:15:56 +0800 Subject: [PATCH 04/54] chore(schemas): add custom phrases database alteration script (#2016) --- .../next-1663923211-machine-to-machine-app.ts | 7 ++---- .../next-1664265197-custom-phrases.ts | 22 +++++++++++++++++++ 2 files changed, 24 insertions(+), 5 deletions(-) create mode 100644 packages/schemas/alterations/next-1664265197-custom-phrases.ts diff --git a/packages/schemas/alterations/next-1663923211-machine-to-machine-app.ts b/packages/schemas/alterations/next-1663923211-machine-to-machine-app.ts index 5901bcb3e..bc1b8c694 100644 --- a/packages/schemas/alterations/next-1663923211-machine-to-machine-app.ts +++ b/packages/schemas/alterations/next-1663923211-machine-to-machine-app.ts @@ -1,9 +1,6 @@ -import { DatabasePool, sql } from 'slonik'; +import { sql } from 'slonik'; -export type AlterationScript = { - up: (pool: DatabasePool) => Promise; - down: (pool: DatabasePool) => Promise; -}; +import { AlterationScript } from '../lib/types/alteration'; const alteration: AlterationScript = { up: async (pool) => { diff --git a/packages/schemas/alterations/next-1664265197-custom-phrases.ts b/packages/schemas/alterations/next-1664265197-custom-phrases.ts new file mode 100644 index 000000000..846f8e0c4 --- /dev/null +++ b/packages/schemas/alterations/next-1664265197-custom-phrases.ts @@ -0,0 +1,22 @@ +import { sql } from 'slonik'; + +import { AlterationScript } from '../lib/types/alteration'; + +const alteration: AlterationScript = { + up: async (pool) => { + // [Pull] feat(core,schemas): add phrases schema and GET /custom-phrases/:languageKey route #1905 + await pool.query(sql` + create table custom_phrases ( + language_key varchar(16) not null, + translation jsonb not null, + primary key(language_key) + ); + `); + }, + down: async (pool) => { + // [Pull] feat(core,schemas): add phrases schema and GET /custom-phrases/:languageKey route #1905 + await pool.query(sql`drop table custom_phrases;`); + }, +}; + +export default alteration; From 05777812aeaa8aa7cdb852550d1838188fa6642a Mon Sep 17 00:00:00 2001 From: Charles Zhao Date: Tue, 27 Sep 2022 17:11:31 +0800 Subject: [PATCH 05/54] chore(console): replace icons in get-started page --- .../src/assets/images/check-demo-dark.svg | 22 ++++----- .../console/src/assets/images/check-demo.svg | 24 +++++----- .../src/assets/images/create-app-dark.svg | 19 ++++---- .../console/src/assets/images/create-app.svg | 34 ++++++++----- .../src/assets/images/customize-dark.svg | 34 ++++++------- .../console/src/assets/images/customize.svg | 48 +++++++------------ .../assets/images/further-readings-dark.svg | 31 ++++++------ .../src/assets/images/further-readings.svg | 43 ++++++----------- .../src/assets/images/one-click-dark.svg | 15 ------ .../console/src/assets/images/one-click.svg | 15 ------ .../src/assets/images/passwordless-dark.svg | 32 ++++++------- .../src/assets/images/passwordless.svg | 34 ++++++------- .../console/src/assets/images/social-dark.svg | 28 +++++++++++ packages/console/src/assets/images/social.svg | 28 +++++++++++ packages/console/src/pages/GetStarted/hook.ts | 6 +-- 15 files changed, 209 insertions(+), 204 deletions(-) delete mode 100644 packages/console/src/assets/images/one-click-dark.svg delete mode 100644 packages/console/src/assets/images/one-click.svg create mode 100644 packages/console/src/assets/images/social-dark.svg create mode 100644 packages/console/src/assets/images/social.svg diff --git a/packages/console/src/assets/images/check-demo-dark.svg b/packages/console/src/assets/images/check-demo-dark.svg index 4a94f08bd..074d3be73 100644 --- a/packages/console/src/assets/images/check-demo-dark.svg +++ b/packages/console/src/assets/images/check-demo-dark.svg @@ -1,18 +1,18 @@ - - + + - - - + + + - - - + + + - - - + + + diff --git a/packages/console/src/assets/images/check-demo.svg b/packages/console/src/assets/images/check-demo.svg index afcb36ba0..31c6fbb1f 100644 --- a/packages/console/src/assets/images/check-demo.svg +++ b/packages/console/src/assets/images/check-demo.svg @@ -1,18 +1,18 @@ - - - - - - + + + + + + - - - + + + - - - + + + diff --git a/packages/console/src/assets/images/create-app-dark.svg b/packages/console/src/assets/images/create-app-dark.svg index 4cc1e23f2..65f78c8e7 100644 --- a/packages/console/src/assets/images/create-app-dark.svg +++ b/packages/console/src/assets/images/create-app-dark.svg @@ -1,18 +1,17 @@ - - + + - - - - + + + + - - - - + + + diff --git a/packages/console/src/assets/images/create-app.svg b/packages/console/src/assets/images/create-app.svg index 287c529b3..521683ec5 100644 --- a/packages/console/src/assets/images/create-app.svg +++ b/packages/console/src/assets/images/create-app.svg @@ -1,18 +1,26 @@ - - - - - - - - - + + + + + + + + + + + + + + + + + + - - - - + + + diff --git a/packages/console/src/assets/images/customize-dark.svg b/packages/console/src/assets/images/customize-dark.svg index 0368b9ec0..a7f445dd4 100644 --- a/packages/console/src/assets/images/customize-dark.svg +++ b/packages/console/src/assets/images/customize-dark.svg @@ -1,23 +1,23 @@ - - - - - - - - - - - - - + + + + + + + + + + + + + - - - + + + - + diff --git a/packages/console/src/assets/images/customize.svg b/packages/console/src/assets/images/customize.svg index 1ba5f81eb..08c6e6d8f 100644 --- a/packages/console/src/assets/images/customize.svg +++ b/packages/console/src/assets/images/customize.svg @@ -1,44 +1,28 @@ - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + - - - + + - - - + + + - + - - - diff --git a/packages/console/src/assets/images/further-readings-dark.svg b/packages/console/src/assets/images/further-readings-dark.svg index ee429e0f0..f6bad1545 100644 --- a/packages/console/src/assets/images/further-readings-dark.svg +++ b/packages/console/src/assets/images/further-readings-dark.svg @@ -1,21 +1,20 @@ - - - - - - - - - + + + + + + + + + + + + - - - - - - - + + + diff --git a/packages/console/src/assets/images/further-readings.svg b/packages/console/src/assets/images/further-readings.svg index 1f10daddd..aa3a197e8 100644 --- a/packages/console/src/assets/images/further-readings.svg +++ b/packages/console/src/assets/images/further-readings.svg @@ -1,33 +1,20 @@ - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + - - - - - - - + + + diff --git a/packages/console/src/assets/images/one-click-dark.svg b/packages/console/src/assets/images/one-click-dark.svg deleted file mode 100644 index ad65ac764..000000000 --- a/packages/console/src/assets/images/one-click-dark.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/packages/console/src/assets/images/one-click.svg b/packages/console/src/assets/images/one-click.svg deleted file mode 100644 index bd9b1a311..000000000 --- a/packages/console/src/assets/images/one-click.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/packages/console/src/assets/images/passwordless-dark.svg b/packages/console/src/assets/images/passwordless-dark.svg index 13c19801a..6cb0d5c93 100644 --- a/packages/console/src/assets/images/passwordless-dark.svg +++ b/packages/console/src/assets/images/passwordless-dark.svg @@ -1,21 +1,21 @@ - - - - - - - - - - + + + + + + + + + - - - + + + + + + + - - - diff --git a/packages/console/src/assets/images/passwordless.svg b/packages/console/src/assets/images/passwordless.svg index 355d14aac..3481dea68 100644 --- a/packages/console/src/assets/images/passwordless.svg +++ b/packages/console/src/assets/images/passwordless.svg @@ -1,21 +1,23 @@ - - - - - - - - - - + + + + + + + + + + + - - - + + + + + + + - - - diff --git a/packages/console/src/assets/images/social-dark.svg b/packages/console/src/assets/images/social-dark.svg new file mode 100644 index 000000000..8bb54f1a1 --- /dev/null +++ b/packages/console/src/assets/images/social-dark.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/console/src/assets/images/social.svg b/packages/console/src/assets/images/social.svg new file mode 100644 index 000000000..752bd240b --- /dev/null +++ b/packages/console/src/assets/images/social.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/console/src/pages/GetStarted/hook.ts b/packages/console/src/pages/GetStarted/hook.ts index ce236bfbe..041576e5d 100644 --- a/packages/console/src/pages/GetStarted/hook.ts +++ b/packages/console/src/pages/GetStarted/hook.ts @@ -13,10 +13,10 @@ import CustomizeDark from '@/assets/images/customize-dark.svg'; import Customize from '@/assets/images/customize.svg'; import FurtherReadingsDark from '@/assets/images/further-readings-dark.svg'; import FurtherReadings from '@/assets/images/further-readings.svg'; -import OneClickDark from '@/assets/images/one-click-dark.svg'; -import OneClick from '@/assets/images/one-click.svg'; import PasswordlessDark from '@/assets/images/passwordless-dark.svg'; import Passwordless from '@/assets/images/passwordless.svg'; +import SocialDark from '@/assets/images/social-dark.svg'; +import Social from '@/assets/images/social.svg'; import { RequestError } from '@/hooks/use-api'; import useDocumentationUrl from '@/hooks/use-documentation-url'; import useSettings from '@/hooks/use-settings'; @@ -106,7 +106,7 @@ const useGetStartedMetadata = () => { id: 'configureSocialSignIn', title: 'get_started.card5_title', subtitle: 'get_started.card5_subtitle', - icon: isLightMode ? OneClick : OneClickDark, + icon: isLightMode ? Social : SocialDark, buttonText: 'general.add', isComplete: settings?.socialSignInConfigured, onClick: () => { From 2e2aa728e382d0687b285a0aa86c8fca4796d419 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Tue, 27 Sep 2022 19:26:06 +0800 Subject: [PATCH 06/54] refactor(console): open docs website for M2M app guide (#2011) --- .../src/pages/ApplicationDetails/index.tsx | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/packages/console/src/pages/ApplicationDetails/index.tsx b/packages/console/src/pages/ApplicationDetails/index.tsx index a8db900e3..9cea1141b 100644 --- a/packages/console/src/pages/ApplicationDetails/index.tsx +++ b/packages/console/src/pages/ApplicationDetails/index.tsx @@ -18,6 +18,7 @@ import Drawer from '@/components/Drawer'; import LinkButton from '@/components/LinkButton'; import TabNav, { TabNavItem } from '@/components/TabNav'; import useApi, { RequestError } from '@/hooks/use-api'; +import useDocumentationUrl from '@/hooks/use-documentation-url'; import Back from '@/icons/Back'; import Delete from '@/icons/Delete'; import More from '@/icons/More'; @@ -54,6 +55,7 @@ const ApplicationDetails = () => { const api = useApi(); const navigate = useNavigate(); const formMethods = useForm(); + const documentationUrl = useDocumentationUrl(); const { handleSubmit, @@ -145,15 +147,21 @@ const ApplicationDetails = () => {
{/* TODO: @Charles figure out a better way to check guide availability */} - {data.type !== ApplicationType.MachineToMachine && ( -
diff --git a/packages/ui/src/components/ConfirmModal/IframeConfirmModal.tsx b/packages/ui/src/components/ConfirmModal/IframeConfirmModal.tsx index 81affa085..0770ce1f3 100644 --- a/packages/ui/src/components/ConfirmModal/IframeConfirmModal.tsx +++ b/packages/ui/src/components/ConfirmModal/IframeConfirmModal.tsx @@ -1,6 +1,5 @@ import classNames from 'classnames'; import { useState } from 'react'; -import { useTranslation } from 'react-i18next'; import ReactModal from 'react-modal'; import Button from '@/components/Button'; @@ -21,7 +20,6 @@ const IframeConfirmModal = ({ onConfirm, onClose, }: Props) => { - const { t } = useTranslation(); const [isLoading, setIsLoading] = useState(true); return ( diff --git a/packages/ui/src/components/ConfirmModal/MobileModal.tsx b/packages/ui/src/components/ConfirmModal/MobileModal.tsx index 49ed98f14..093b50537 100644 --- a/packages/ui/src/components/ConfirmModal/MobileModal.tsx +++ b/packages/ui/src/components/ConfirmModal/MobileModal.tsx @@ -1,5 +1,4 @@ import classNames from 'classnames'; -import { useTranslation } from 'react-i18next'; import ReactModal from 'react-modal'; import Button from '@/components/Button'; @@ -17,8 +16,6 @@ const MobileModal = ({ onConfirm, onClose, }: ModalProps) => { - const { t } = useTranslation(); - return ( {children}
diff --git a/packages/ui/src/components/ConfirmModal/index.tsx b/packages/ui/src/components/ConfirmModal/index.tsx index 476172386..e9b809561 100644 --- a/packages/ui/src/components/ConfirmModal/index.tsx +++ b/packages/ui/src/components/ConfirmModal/index.tsx @@ -2,3 +2,5 @@ export { default as WebModal } from './AcModal'; export { default as MobileModal } from './MobileModal'; export { default as IframeModal } from './IframeConfirmModal'; export { modalPromisify } from './modalPromisify'; + +export type { ModalProps } from './type'; diff --git a/packages/ui/src/components/ConfirmModal/type.ts b/packages/ui/src/components/ConfirmModal/type.ts index e26f95f93..10489d884 100644 --- a/packages/ui/src/components/ConfirmModal/type.ts +++ b/packages/ui/src/components/ConfirmModal/type.ts @@ -1,4 +1,4 @@ -import { ReactNode } from 'react'; +import { ReactNode, MouseEventHandler } from 'react'; import { TFuncKey } from 'react-i18next'; export type ModalProps = { @@ -7,6 +7,6 @@ export type ModalProps = { children: ReactNode; cancelText?: TFuncKey; confirmText?: TFuncKey; - onConfirm?: () => void; - onClose: () => void; + onConfirm?: MouseEventHandler & MouseEventHandler; + onClose: MouseEventHandler & MouseEventHandler; }; diff --git a/packages/ui/src/containers/AppContent/index.tsx b/packages/ui/src/containers/AppContent/index.tsx index 674a7572f..862b0afca 100644 --- a/packages/ui/src/containers/AppContent/index.tsx +++ b/packages/ui/src/containers/AppContent/index.tsx @@ -2,6 +2,7 @@ import { conditionalString } from '@silverhand/essentials'; import { ReactNode, useEffect, useCallback, useContext } from 'react'; import Toast from '@/components/Toast'; +import ConfirmModalProvider from '@/containers/ConfirmModalProvider'; import useColorTheme from '@/hooks/use-color-theme'; import { PageContext } from '@/hooks/use-page-context'; import useTheme from '@/hooks/use-theme'; @@ -37,12 +38,14 @@ const AppContent = ({ children }: Props) => { }, [platform]); return ( -
- {platform === 'web' &&
} -
{children}
- {platform === 'web' &&
} - -
+ +
+ {platform === 'web' &&
} +
{children}
+ {platform === 'web' &&
} + +
+ ); }; diff --git a/packages/ui/src/containers/ConfirmModalProvider/index.tsx b/packages/ui/src/containers/ConfirmModalProvider/index.tsx new file mode 100644 index 000000000..1e38bf8e2 --- /dev/null +++ b/packages/ui/src/containers/ConfirmModalProvider/index.tsx @@ -0,0 +1,118 @@ +import { Nullable } from '@silverhand/essentials'; +import { useState, useRef, useMemo, createContext, useCallback } from 'react'; + +import { WebModal, MobileModal, ModalProps } from '@/components/ConfirmModal'; +import usePlatform from '@/hooks/use-platform'; + +export type ChildRenderProps = { + confirm: (data?: unknown) => void; + cancel: (data?: unknown) => void; +}; + +type ConfirmModalType = 'alert' | 'confirm'; + +type ConfirmModalState = Omit & { + ModalContent: string | ((props: ChildRenderProps) => Nullable); + type: ConfirmModalType; +}; + +type ConfirmModalProps = Omit & { type?: ConfirmModalType }; + +type ConfirmModalContextType = { + show: (props: ConfirmModalProps) => Promise<[boolean, unknown?]>; + confirm: (data?: unknown) => void; + cancel: (data?: unknown) => void; +}; + +const noop = () => { + throw new Error('Context provider not found'); +}; + +export const ConfirmModalContext = createContext({ + show: async () => [true], + confirm: noop, + cancel: noop, +}); + +type Props = { + children?: React.ReactNode; +}; + +const defaultModalState: ConfirmModalState = { + isOpen: false, + type: 'confirm', + ModalContent: () => null, +}; + +const ConfirmModalProvider = ({ children }: Props) => { + const [modalState, setModalState] = useState(defaultModalState); + + const resolver = useRef<(value: [result: boolean, data?: unknown]) => void>(); + + const { isMobile } = usePlatform(); + + const ConfirmModal = isMobile ? MobileModal : WebModal; + + const handleShow = useCallback(async ({ type = 'confirm', ...props }: ConfirmModalProps) => { + resolver.current?.([false]); + + setModalState({ + isOpen: true, + type, + ...props, + }); + + return new Promise<[result: boolean, data?: unknown]>((resolve) => { + // eslint-disable-next-line @silverhand/fp/no-mutation + resolver.current = resolve; + }); + }, []); + + const handleConfirm = useCallback((data?: unknown) => { + resolver.current?.([true, data]); + setModalState(defaultModalState); + }, []); + + const handleCancel = useCallback((data?: unknown) => { + resolver.current?.([false, data]); + setModalState(defaultModalState); + }, []); + + const contextValue = useMemo( + () => ({ + show: handleShow, + confirm: handleConfirm, + cancel: handleCancel, + }), + [handleCancel, handleConfirm, handleShow] + ); + + const { ModalContent, type, ...restProps } = modalState; + + return ( + + {children} + { + handleConfirm(); + } + : undefined + } + onClose={() => { + handleCancel(); + }} + > + {typeof ModalContent === 'string' ? ( + ModalContent + ) : ( + + )} + + + ); +}; + +export default ConfirmModalProvider; diff --git a/packages/ui/src/containers/ConfirmModalProvider/indext.test.tsx b/packages/ui/src/containers/ConfirmModalProvider/indext.test.tsx new file mode 100644 index 000000000..16072159e --- /dev/null +++ b/packages/ui/src/containers/ConfirmModalProvider/indext.test.tsx @@ -0,0 +1,107 @@ +import { render, fireEvent, waitFor } from '@testing-library/react'; +import { act } from 'react-dom/test-utils'; + +import { useConfirmModal } from '@/hooks/use-confirm-modal'; + +import ConfirmModalProvider from '.'; + +const confirmHandler = jest.fn(); +const cancelHandler = jest.fn(); + +const ConfirmModalTestComponent = () => { + const { show } = useConfirmModal(); + + const onClick = async () => { + const [result] = await show({ ModalContent: 'confirm modal content' }); + + if (result) { + confirmHandler(); + + return; + } + + cancelHandler(); + }; + + return ; +}; + +describe('confirm modal provider', () => { + it('render confirm modal', async () => { + const { queryByText, getByText } = render( + + + + ); + + const trigger = getByText('show modal'); + + act(() => { + fireEvent.click(trigger); + }); + + await waitFor(() => { + expect(queryByText('confirm modal content')).not.toBeNull(); + expect(queryByText('action.confirm')).not.toBeNull(); + expect(queryByText('action.cancel')).not.toBeNull(); + }); + }); + + it('confirm callback of confirm modal', async () => { + const { queryByText, getByText } = render( + + + + ); + + const trigger = getByText('show modal'); + + act(() => { + fireEvent.click(trigger); + }); + + await waitFor(() => { + expect(queryByText('confirm modal content')).not.toBeNull(); + expect(queryByText('action.confirm')).not.toBeNull(); + }); + + const confirm = getByText('action.confirm'); + + act(() => { + fireEvent.click(confirm); + }); + + await waitFor(() => { + expect(confirmHandler).toBeCalled(); + }); + }); + + it('cancel callback of confirm modal', async () => { + const { queryByText, getByText } = render( + + + + ); + + const trigger = getByText('show modal'); + + act(() => { + fireEvent.click(trigger); + }); + + await waitFor(() => { + expect(queryByText('confirm modal content')).not.toBeNull(); + expect(queryByText('action.cancel')).not.toBeNull(); + }); + + const cancel = getByText('action.cancel'); + + act(() => { + fireEvent.click(cancel); + }); + + await waitFor(() => { + expect(cancelHandler).toBeCalled(); + }); + }); +}); diff --git a/packages/ui/src/containers/PasscodeValidation/index.test.tsx b/packages/ui/src/containers/PasscodeValidation/index.test.tsx index 9502d77a0..73a478699 100644 --- a/packages/ui/src/containers/PasscodeValidation/index.test.tsx +++ b/packages/ui/src/containers/PasscodeValidation/index.test.tsx @@ -9,6 +9,13 @@ jest.useFakeTimers(); const sendPasscodeApi = jest.fn(); const verifyPasscodeApi = jest.fn(); +const mockedNavigate = jest.fn(); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: () => mockedNavigate, +})); + jest.mock('@/apis/utils', () => ({ getSendPasscodeApi: () => sendPasscodeApi, getVerifyPasscodeApi: () => verifyPasscodeApi, diff --git a/packages/ui/src/containers/PasscodeValidation/index.tsx b/packages/ui/src/containers/PasscodeValidation/index.tsx index f19aab4aa..bc677cd1b 100644 --- a/packages/ui/src/containers/PasscodeValidation/index.tsx +++ b/packages/ui/src/containers/PasscodeValidation/index.tsx @@ -1,12 +1,14 @@ import classNames from 'classnames'; import { useState, useEffect, useContext, useCallback, useMemo } from 'react'; import { useTranslation, Trans } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; import { useTimer } from 'react-timer-hook'; import { getSendPasscodeApi, getVerifyPasscodeApi } from '@/apis/utils'; import Passcode, { defaultLength } from '@/components/Passcode'; import TextLink from '@/components/TextLink'; import useApi, { ErrorHandlers } from '@/hooks/use-api'; +import { useConfirmModal } from '@/hooks/use-confirm-modal'; import { PageContext } from '@/hooks/use-page-context'; import { UserFlow, SearchParameters } from '@/types'; import { getSearchParameters } from '@/utils'; @@ -34,6 +36,8 @@ const PasscodeValidation = ({ type, method, className, target }: Props) => { const [error, setError] = useState(); const { setToast } = useContext(PageContext); const { t } = useTranslation(); + const { show } = useConfirmModal(); + const navigate = useNavigate(); const { seconds, isRunning, restart } = useTimer({ autoStart: true, @@ -45,6 +49,14 @@ const PasscodeValidation = ({ type, method, className, target }: Props) => { 'passcode.expired': (error) => { setError(error.message); }, + 'user.phone_not_exists': async (error) => { + await show({ type: 'alert', ModalContent: error.message, cancelText: 'action.got_it' }); + navigate(-1); + }, + 'user.email_not_exists': async (error) => { + await show({ type: 'alert', ModalContent: error.message, cancelText: 'action.got_it' }); + navigate(-1); + }, 'passcode.code_mismatch': (error) => { setError(error.message); }, @@ -52,7 +64,7 @@ const PasscodeValidation = ({ type, method, className, target }: Props) => { setCode([]); }, }), - [] + [navigate, show] ); const { result: verifyPasscodeResult, run: verifyPassCode } = useApi( diff --git a/packages/ui/src/containers/Passwordless/EmailPasswordless.test.tsx b/packages/ui/src/containers/Passwordless/EmailPasswordless.test.tsx index 6c438ae00..3146ee207 100644 --- a/packages/ui/src/containers/Passwordless/EmailPasswordless.test.tsx +++ b/packages/ui/src/containers/Passwordless/EmailPasswordless.test.tsx @@ -37,7 +37,7 @@ describe('', () => { expect(queryByText('description.terms_of_use')).not.toBeNull(); }); - test('ender with terms settings but hasTerms param set to false', () => { + test('render with terms settings but hasTerms param set to false', () => { const { queryByText } = renderWithPageContext( diff --git a/packages/ui/src/containers/Passwordless/PhonePasswordless.tsx b/packages/ui/src/containers/Passwordless/PhonePasswordless.tsx index 891dcc2fd..00a4987de 100644 --- a/packages/ui/src/containers/Passwordless/PhonePasswordless.tsx +++ b/packages/ui/src/containers/Passwordless/PhonePasswordless.tsx @@ -57,7 +57,7 @@ const PhonePasswordless = ({ 'user.phone_not_exists': (error) => { const socialToBind = getSearchParameters(location.search, SearchParameters.bindWithSocial); - // Directly display the error if user is trying to bind with social + // Directly display the error if user is trying to bind with social if (socialToBind) { setToast(error.message); diff --git a/packages/ui/src/containers/TermsOfUse/TermsOfUseConfirmModal/index.tsx b/packages/ui/src/containers/TermsOfUse/TermsOfUseConfirmModal/index.tsx index a3293f277..65ed8bb8c 100644 --- a/packages/ui/src/containers/TermsOfUse/TermsOfUseConfirmModal/index.tsx +++ b/packages/ui/src/containers/TermsOfUse/TermsOfUseConfirmModal/index.tsx @@ -47,7 +47,9 @@ const TermsOfUseConfirmModal = ({ isOpen = false, onConfirm, onClose }: Props) = setTermsAgreement(true); onConfirm(); }} - onClose={onClose} + onClose={() => { + onClose(); + }} > useContext(ConfirmModalContext); From 753e8ebdfd272995b05ec643df7d17159b004c85 Mon Sep 17 00:00:00 2001 From: Darcy Ye Date: Fri, 30 Sep 2022 11:48:12 +0800 Subject: [PATCH 15/54] refactor(core,schemas): refactor log types with zod (#2034) --- packages/core/jest.config.ts | 1 + packages/core/src/middleware/koa-log.ts | 12 +- packages/schemas/src/types/log.ts | 316 ++++++++++++++---------- 3 files changed, 191 insertions(+), 138 deletions(-) diff --git a/packages/core/jest.config.ts b/packages/core/jest.config.ts index 688a8d282..739dc928c 100644 --- a/packages/core/jest.config.ts +++ b/packages/core/jest.config.ts @@ -1,6 +1,7 @@ import { merge, Config } from '@silverhand/jest-config'; const config: Config.InitialOptions = merge({ + testPathIgnorePatterns: ['/core/connectors/'], setupFilesAfterEnv: ['jest-matcher-specific-error', './jest.setup.ts'], globalSetup: './jest.global-setup.ts', }); diff --git a/packages/core/src/middleware/koa-log.ts b/packages/core/src/middleware/koa-log.ts index d017ea950..59d8e0232 100644 --- a/packages/core/src/middleware/koa-log.ts +++ b/packages/core/src/middleware/koa-log.ts @@ -17,11 +17,13 @@ type SessionPayload = { type AddLogContext = (sessionPayload: SessionPayload) => void; -export type WithLogContext = - ContextT & { - addLogContext: AddLogContext; - log: MergeLog; - }; +export type LogContext = { + addLogContext: AddLogContext; + log: MergeLog; +}; + +export type WithLogContext = ContextT & + LogContext; type Logger = { type?: LogType; diff --git a/packages/schemas/src/types/log.ts b/packages/schemas/src/types/log.ts index 59cc8e572..ec2b69dd5 100644 --- a/packages/schemas/src/types/log.ts +++ b/packages/schemas/src/types/log.ts @@ -1,3 +1,5 @@ +import { z } from 'zod'; + import { Log } from '../db-entries'; export enum LogResult { @@ -5,122 +7,160 @@ export enum LogResult { Error = 'Error', } -export type BaseLogPayload = { - result?: LogResult; - error?: Record; - ip?: string; - userAgent?: string; - applicationId?: string; - sessionId?: string; -}; +export const logResultGuard = z.nativeEnum(LogResult); -type ArbitraryLogPayload = Record; +export const baseLogPayloadGuard = z.object({ + result: logResultGuard.optional(), + error: z.record(z.string(), z.unknown()).optional(), + ip: z.string().optional(), + userAgent: z.string().optional(), + applicationId: z.string().optional(), + sessionId: z.string().optional(), +}); -type RegisterUsernamePasswordLogPayload = ArbitraryLogPayload & { - userId?: string; - username?: string; -}; +export type BaseLogPayload = z.infer; -type RegisterEmailSendPasscodeLogPayload = ArbitraryLogPayload & { - email?: string; - connectorId?: string; -}; +const arbitraryLogPayloadGuard = z.record(z.string(), z.unknown()); -type RegisterEmailLogPayload = ArbitraryLogPayload & { - email?: string; - code?: string; - userId?: string; -}; +export type ArbitraryLogPayload = z.infer; -type RegisterSmsSendPasscodeLogPayload = { - phone?: string; - connectorId?: string; -} & ArbitraryLogPayload; +const registerUsernamePasswordLogPayloadGuard = arbitraryLogPayloadGuard.and( + z.object({ userId: z.string().optional(), username: z.string().optional() }) +); -type RegisterSmsLogPayload = ArbitraryLogPayload & { - phone?: string; - code?: string; - userId?: string; -}; +const registerEmailSendPasscodeLogPayloadGuard = arbitraryLogPayloadGuard.and( + z.object({ email: z.string().optional(), connectorId: z.string().optional() }) +); -type RegisterSocialBindLogPayload = ArbitraryLogPayload & { - connectorId?: string; - userInfo?: object; - userId?: string; -}; +const registerEmailLogPayloadGuard = arbitraryLogPayloadGuard.and( + z.object({ + email: z.string().optional(), + code: z.string().optional(), + userId: z.string().optional(), + }) +); -type RegisterSocialLogPayload = RegisterSocialBindLogPayload & { - code?: string; - state?: string; - redirectUri?: string; - redirectTo?: string; -}; +const registerSmsSendPasscodeLogPayloadGuard = arbitraryLogPayloadGuard.and( + z.object({ + phone: z.string().optional(), + connectorId: z.string().optional(), + }) +); -type SignInUsernamePasswordLogPayload = ArbitraryLogPayload & { - userId?: string; - username?: string; -}; +const registerSmsLogPayloadGuard = arbitraryLogPayloadGuard.and( + z.object({ + phone: z.string().optional(), + code: z.string().optional(), + userId: z.string().optional(), + }) +); -type SignInEmailSendPasscodeLogPayload = ArbitraryLogPayload & { - email?: string; - connectorId?: string; -}; +const registerSocialBindLogPayloadGuard = arbitraryLogPayloadGuard.and( + z.object({ + connectorId: z.string().optional(), + userId: z.string().optional(), + userInfo: z.unknown().optional(), + }) +); -type SignInEmailLogPayload = ArbitraryLogPayload & { - email?: string; - code?: string; - userId?: string; -}; +const registerSocialLogPayloadGuard = registerSocialBindLogPayloadGuard.and( + z.object({ + code: z.string().optional(), + state: z.string().optional(), + redirectUri: z.string().optional(), + redirectTo: z.string().optional(), + }) +); -type SignInSmsSendPasscodeLogPayload = ArbitraryLogPayload & { - phone?: string; - connectorId?: string; -}; +const signInUsernamePasswordLogPayloadGuard = arbitraryLogPayloadGuard.and( + z.object({ + userId: z.string().optional(), + username: z.string().optional(), + }) +); -type SignInSmsLogPayload = ArbitraryLogPayload & { - phone?: string; - code?: string; - userId?: string; -}; +const signInEmailSendPasscodeLogPayloadGuard = arbitraryLogPayloadGuard.and( + z.object({ + email: z.string().optional(), + connectorId: z.string().optional(), + }) +); -type SignInSocialBindLogPayload = ArbitraryLogPayload & { - connectorId?: string; - userInfo?: object; - userId?: string; -}; +const signInEmailLogPayloadGuard = arbitraryLogPayloadGuard.and( + z.object({ + email: z.string().optional(), + code: z.string().optional(), + userId: z.string().optional(), + }) +); -type SignInSocialLogPayload = SignInSocialBindLogPayload & { - code?: string; - state?: string; - redirectUri?: string; - redirectTo?: string; -}; +const signInSmsSendPasscodeLogPayloadGuard = arbitraryLogPayloadGuard.and( + z.object({ + phone: z.string().optional(), + connectorId: z.string().optional(), + }) +); -type ForgotPasswordSmsSendPasscodeLogPayload = ArbitraryLogPayload & { - phone?: string; - connectorId?: string; -}; +const signInSmsLogPayloadGuard = arbitraryLogPayloadGuard.and( + z.object({ + phone: z.string().optional(), + code: z.string().optional(), + userId: z.string().optional(), + }) +); -type ForgotPasswordSmsLogPayload = ArbitraryLogPayload & { - phone?: string; - code?: string; - userId?: string; -}; +const signInSocialBindLogPayloadGuard = arbitraryLogPayloadGuard.and( + z.object({ + connectorId: z.string().optional(), + userId: z.string().optional(), + userInfo: z.unknown().optional(), + }) +); -type ForgotPasswordEmailSendPasscodeLogPayload = ArbitraryLogPayload & { - email?: string; - connectorId?: string; -}; +const signInSocialLogPayloadGuard = signInSocialBindLogPayloadGuard.and( + z.object({ + code: z.string().optional(), + state: z.string().optional(), + redirectUri: z.string().optional(), + redirectTo: z.string().optional(), + }) +); -type ForgotPasswordEmailLogPayload = ArbitraryLogPayload & { - email?: string; - code?: string; - userId?: string; -}; +const forgotPasswordSmsSendPasscodeLogPayloadGuard = arbitraryLogPayloadGuard.and( + z.object({ + phone: z.string().optional(), + connectorId: z.string().optional(), + }) +); -type ForgotPasswordResetLogPayload = ArbitraryLogPayload & { - userId?: string; -}; +const forgotPasswordSmsLogPayloadGuard = arbitraryLogPayloadGuard.and( + z.object({ + phone: z.string().optional(), + code: z.string().optional(), + userId: z.string().optional(), + }) +); + +const forgotPasswordEmailSendPasscodeLogPayloadGuard = arbitraryLogPayloadGuard.and( + z.object({ + email: z.string().optional(), + connectorId: z.string().optional(), + }) +); + +const forgotPasswordEmailLogPayloadGuard = arbitraryLogPayloadGuard.and( + z.object({ + email: z.string().optional(), + code: z.string().optional(), + userId: z.string().optional(), + }) +); + +const forgotPasswordResetLogPayloadGuard = arbitraryLogPayloadGuard.and( + z.object({ + userId: z.string().optional(), + }) +); export enum TokenType { AccessToken = 'AccessToken', @@ -128,46 +168,56 @@ export enum TokenType { IdToken = 'IdToken', } -type ExchangeTokenLogPayload = ArbitraryLogPayload & { - userId?: string; - params?: Record; - issued?: TokenType[]; - scope?: string; -}; +export const tokenTypeGuard = z.nativeEnum(TokenType); -type RevokeTokenLogPayload = ArbitraryLogPayload & { - userId?: string; - params?: Record; - grantId?: string; - tokenType?: TokenType; -}; +const exchangeTokenLogPayloadGuard = arbitraryLogPayloadGuard.and( + z.object({ + userId: z.string().optional(), + params: z.record(z.string(), z.unknown()).optional(), + issued: tokenTypeGuard.array().optional(), + scope: z.string().optional(), + }) +); -export type LogPayloads = { - RegisterUsernamePassword: RegisterUsernamePasswordLogPayload; - RegisterEmailSendPasscode: RegisterEmailSendPasscodeLogPayload; - RegisterEmail: RegisterEmailLogPayload; - RegisterSmsSendPasscode: RegisterSmsSendPasscodeLogPayload; - RegisterSms: RegisterSmsLogPayload; - RegisterSocialBind: RegisterSocialBindLogPayload; - RegisterSocial: RegisterSocialLogPayload; - SignInUsernamePassword: SignInUsernamePasswordLogPayload; - SignInEmailSendPasscode: SignInEmailSendPasscodeLogPayload; - SignInEmail: SignInEmailLogPayload; - SignInSmsSendPasscode: SignInSmsSendPasscodeLogPayload; - SignInSms: SignInSmsLogPayload; - SignInSocialBind: SignInSocialBindLogPayload; - SignInSocial: SignInSocialLogPayload; - ForgotPasswordSmsSendPasscode: ForgotPasswordSmsSendPasscodeLogPayload; - ForgotPasswordSms: ForgotPasswordSmsLogPayload; - ForgotPasswordEmailSendPasscode: ForgotPasswordEmailSendPasscodeLogPayload; - ForgotPasswordEmail: ForgotPasswordEmailLogPayload; - ForgotPasswordReset: ForgotPasswordResetLogPayload; - CodeExchangeToken: ExchangeTokenLogPayload; - RefreshTokenExchangeToken: ExchangeTokenLogPayload; - RevokeToken: RevokeTokenLogPayload; -}; +const revokeTokenLogPayloadGuard = arbitraryLogPayloadGuard.and( + z.object({ + userId: z.string().optional(), + params: z.record(z.string(), z.unknown()).optional(), + tokenType: tokenTypeGuard.optional(), + grantId: z.string().optional(), + }) +); -export type LogType = keyof LogPayloads; +const logPayloadsGuard = z.object({ + RegisterUsernamePassword: registerUsernamePasswordLogPayloadGuard, + RegisterEmailSendPasscode: registerEmailSendPasscodeLogPayloadGuard, + RegisterEmail: registerEmailLogPayloadGuard, + RegisterSmsSendPasscode: registerSmsSendPasscodeLogPayloadGuard, + RegisterSms: registerSmsLogPayloadGuard, + RegisterSocialBind: registerSocialBindLogPayloadGuard, + RegisterSocial: registerSocialLogPayloadGuard, + SignInUsernamePassword: signInUsernamePasswordLogPayloadGuard, + SignInEmailSendPasscode: signInEmailSendPasscodeLogPayloadGuard, + SignInEmail: signInEmailLogPayloadGuard, + SignInSmsSendPasscode: signInSmsSendPasscodeLogPayloadGuard, + SignInSms: signInSmsLogPayloadGuard, + SignInSocialBind: signInSocialBindLogPayloadGuard, + SignInSocial: signInSocialLogPayloadGuard, + ForgotPasswordSmsSendPasscode: forgotPasswordSmsSendPasscodeLogPayloadGuard, + ForgotPasswordSms: forgotPasswordSmsLogPayloadGuard, + ForgotPasswordEmailSendPasscode: forgotPasswordEmailSendPasscodeLogPayloadGuard, + ForgotPasswordEmail: forgotPasswordEmailLogPayloadGuard, + ForgotPasswordReset: forgotPasswordResetLogPayloadGuard, + CodeExchangeToken: exchangeTokenLogPayloadGuard, + RefreshTokenExchangeToken: exchangeTokenLogPayloadGuard, + RevokeToken: revokeTokenLogPayloadGuard, +}); + +export type LogPayloads = z.infer; + +export const logTypeGuard = logPayloadsGuard.keyof(); + +export type LogType = z.infer; export type LogPayload = LogPayloads[LogType]; From e254339e8ef6bdab29175fdeb0852a06c7008e2e Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Sat, 1 Oct 2022 22:44:29 +0800 Subject: [PATCH 16/54] chore: test deployment (#2040) chore: update dev deployment --- .github/workflows/deploy-dev.yml | 57 -------------------------------- .github/workflows/release.yml | 20 +++++++++++ 2 files changed, 20 insertions(+), 57 deletions(-) delete mode 100644 .github/workflows/deploy-dev.yml diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml deleted file mode 100644 index 2726272d9..000000000 --- a/.github/workflows/deploy-dev.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: Deploy Dev - -on: - push: - branches: [master] - -concurrency: deploy-dev - -jobs: - deploy: - environment: dev - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - - name: Setup Node and pnpm - uses: silverhand-io/actions-node-pnpm-run-steps@v1.2.3 - - - name: Build - run: pnpm -- lerna run build --stream - - - name: Add official connectors - run: pnpm add-official-connectors - working-directory: packages/core - - # See warning in https://pnpm.io/cli/prune - - name: Prune - run: rm -rf node_modules packages/*/node_modules && pnpm i - env: - NODE_ENV: production - - - name: Setup env - working-directory: packages/core - run: echo "$DEV_CORE_ENV" >> .env - env: - DEV_CORE_ENV: ${{ secrets.DEV_CORE_ENV }} - - - name: Install SSH key - uses: shimataro/ssh-key-action@v2 - with: - key: ${{ secrets.DEV_SSH_KEY }} - known_hosts: ${{ secrets.DEV_SSH_KNOWN_HOSTS }} - config: ${{ secrets.DEV_SSH_CONFIG }} - - - name: Rsync folder - run: rsync --filter='exclude .git' -r -a --delete-before --ignore-times ./ $DEV_SERVER_IP:~/logto - env: - DEV_SERVER_IP: ${{ secrets.DEV_SERVER_IP }} - - - name: Sleep for 10 seconds - run: sleep 10s - - - name: Health check - run: curl $DEV_SERVER_URL/api/status -If - env: - DEV_SERVER_URL: ${{ secrets.DEV_SERVER_URL }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8f519bce1..1069085dc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -58,6 +58,26 @@ jobs: tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + deploy-dev: + runs-on: ubuntu-latest + needs: dockerize + environment: dev + if: ${{ !startsWith(github.ref, 'refs/tags/')' }} + + steps: + - name: Login via Azure CLI + uses: azure/login@v1 + with: + creds: ${{ secrets.AZURE_CREDENTIALS }} + + - name: Deploy to containerapp + uses: azure/CLI@v1 + with: + inlineScript: | + az config set extension.use_dynamic_install=yes_without_prompt + az containerapp update -n logto-dev -g LogtoDev --image svhd/logto:edge + + create-github-release: runs-on: ubuntu-latest if: startsWith(github.ref, 'refs/tags/') From 44d9177161aba59cc6bc057ee122dbf0adaf0981 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Sat, 1 Oct 2022 23:41:14 +0800 Subject: [PATCH 17/54] chore: fix typo in release workflow (#2041) --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1069085dc..39c4634d5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -62,7 +62,7 @@ jobs: runs-on: ubuntu-latest needs: dockerize environment: dev - if: ${{ !startsWith(github.ref, 'refs/tags/')' }} + if: ${{ !startsWith(github.ref, 'refs/tags/') }} steps: - name: Login via Azure CLI From 0c6462dbdae1b2518003e5cb3ea5604300200196 Mon Sep 17 00:00:00 2001 From: Olyno Date: Tue, 27 Sep 2022 11:15:01 +0800 Subject: [PATCH 18/54] feat(cli): init --- packages/create-logto/package.json | 51 ++++++++++++++ packages/create-logto/src/functions.ts | 31 +++++++++ packages/create-logto/src/index.ts | 95 ++++++++++++++++++++++++++ packages/create-logto/tsconfig.json | 10 +++ 4 files changed, 187 insertions(+) create mode 100644 packages/create-logto/package.json create mode 100644 packages/create-logto/src/functions.ts create mode 100644 packages/create-logto/src/index.ts create mode 100644 packages/create-logto/tsconfig.json diff --git a/packages/create-logto/package.json b/packages/create-logto/package.json new file mode 100644 index 000000000..12c5778b3 --- /dev/null +++ b/packages/create-logto/package.json @@ -0,0 +1,51 @@ +{ + "name": "create-logto", + "version": "1.0.0", + "description": "Logto creation to getting started.", + "author": "Silverhand Inc. ", + "homepage": "https://github.com/logto-io/logto#readme", + "license": "MPL-2.0", + "files": [ + "lib" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/logto-io/logto.git" + }, + "bin": "./lib/index.js", + "scripts": { + "precommit": "lint-staged", + "build": "rimraf lib && tsc", + "dev": "tsc --watch --preserveWatchOutput --incremental", + "lint": "eslint --ext .ts src", + "lint:report": "pnpm lint --format json --output-file report.json", + "prepack": "pnpm build" + }, + "engines": { + "node": "^16.0.0" + }, + "bugs": { + "url": "https://github.com/logto-io/logto/issues" + }, + "dependencies": { + "axios": "^0.27.2", + "decompress": "^4.2.1", + "prompts": "^2.4.2" + }, + "devDependencies": { + "@silverhand/eslint-config": "1.0.0", + "@silverhand/ts-config": "1.0.0", + "@types/axios": "^0.14.0", + "@types/decompress": "^4.2.4", + "@types/prompts": "^2.0.14", + "eslint": "^8.21.0", + "lint-staged": "^13.0.0", + "prettier": "^2.7.1", + "rimraf": "^3.0.2", + "typescript": "^4.7.4" + }, + "eslintConfig": { + "extends": "@silverhand" + }, + "prettier": "@silverhand/eslint-config/.prettierrc" +} diff --git a/packages/create-logto/src/functions.ts b/packages/create-logto/src/functions.ts new file mode 100644 index 000000000..b7c066879 --- /dev/null +++ b/packages/create-logto/src/functions.ts @@ -0,0 +1,31 @@ +import { execSync } from 'child_process'; +import { createWriteStream } from 'fs'; + +import axios from 'axios'; + +export const isVersionGreaterThan = (version: string, targetMajor: number) => + Number(version.split('.')[0]) >= targetMajor; + +export const trimV = (version: string) => (version.startsWith('v') ? version.slice(1) : version); + +export const safeExecSync = (command: string) => { + try { + return execSync(command, { encoding: 'utf8', stdio: 'pipe' }); + } catch {} +}; + +export const downloadFile = async (url: string, destination: string) => { + const file = createWriteStream(destination); + const response = await axios.get(url, { responseType: 'stream' }); + response.data.pipe(file); + + return new Promise((resolve, reject) => { + file.on('error', (error) => { + reject(error.message); + }); + file.on('finish', () => { + file.close(); + resolve(file); + }); + }); +}; diff --git a/packages/create-logto/src/index.ts b/packages/create-logto/src/index.ts new file mode 100644 index 000000000..07af84f90 --- /dev/null +++ b/packages/create-logto/src/index.ts @@ -0,0 +1,95 @@ +import { execSync } from 'child_process'; +import { existsSync } from 'fs'; +import { unlink } from 'fs/promises'; +import path from 'path'; + +import decompress from 'decompress'; +import prompt from 'prompts'; + +import { downloadFile, isVersionGreaterThan, safeExecSync, trimV } from './functions'; + +const DIRECTORY = 'logto'; +const NODE_MAJOR_VERSION = 16; +const POSTGRES_MAJOR_VERSION = 14; + +async function main() { + const nodeVersion = execSync('node -v', { encoding: 'utf8', stdio: 'pipe' }); + const pgOutput = safeExecSync('postgres --version') ?? ''; + const pgArray = pgOutput.split(' '); + const pgVersion = pgArray[pgArray.length - 1]!; + + if (!isVersionGreaterThan(trimV(nodeVersion), NODE_MAJOR_VERSION)) { + throw new Error(`Logto requires NodeJS >= ${NODE_MAJOR_VERSION}.0.0.`); + } + + let response; + + try { + response = await prompt( + [ + { + name: 'instancePath', + message: 'Where should we create your logto instance?', + type: 'text', + initial: './' + DIRECTORY, + format: (value: string) => path.resolve(value.trim()), + validate: (value: string) => + existsSync(value) ? 'That path already exists, please try another.' : true, + }, + { + name: 'hasPostgresUrl', + message: `Logto requires PostgreSQL >= ${POSTGRES_MAJOR_VERSION}.0.0 but cannot find in the current environment. Do you have a remote PostgreSQL instance ready?`, + type: !isVersionGreaterThan(trimV(pgVersion), POSTGRES_MAJOR_VERSION) ? 'confirm' : null, + initial: true, + }, + { + name: 'postgresUrl', + message: 'What is the URL of your PostgreSQL instance?', + type: (_, data) => (data.hasPostgresUrl ? 'text' : null), + format: (value: string) => value.trim(), + validate: (value: string) => + (value && + Boolean( + /^(?:([^\s#/:?]+):\/{2})?(?:([^\s#/?@]+)@)?([^\s#/?]+)?(?:\/([^\s#?]*))?(?:\?([^\s#]+))?\S*$/.test( + value + ) + )) || + 'Please enter a valid connection URL.', + }, + { + name: 'startInstance', + message: 'Would you like to start Logto now?', + type: 'confirm', + initial: true, + }, + ], + { + onCancel: () => { + throw new Error('Operation cancelled'); + }, + } + ); + } catch (error: any) { + console.log(error.message); + + return; + } + + const startCommand = `cd ${response.instancePath} && npm start`; + const tarFileLocation = path.resolve('./logto.tar.gz'); + + await downloadFile( + 'https://github.com/logto-io/logto/releases/latest/download/logto.tar.gz', + tarFileLocation + ); + await decompress(tarFileLocation, response.instancePath); + await unlink(tarFileLocation); + + if (response.startInstance) { + execSync(startCommand, { stdio: 'inherit' }); + } else { + console.log(`You can use ${startCommand} to start Logto. Happy hacking!`); + } +} + +main(); diff --git a/packages/create-logto/tsconfig.json b/packages/create-logto/tsconfig.json new file mode 100644 index 000000000..ec160f030 --- /dev/null +++ b/packages/create-logto/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@silverhand/ts-config/tsconfig.base", + "compilerOptions": { + "outDir": "lib", + "declaration": true + }, + "include": [ + "src" + ] +} From a67c23e4b08317aaa7d5f4a13d4d307c5b1115f1 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Tue, 27 Sep 2022 11:34:41 +0800 Subject: [PATCH 19/54] refactor(cli): use got and fix lint errors --- packages/{create-logto => cli}/package.json | 11 +- .../{create-logto => cli}/src/functions.ts | 8 +- packages/cli/src/index.ts | 89 +++++++ packages/{create-logto => cli}/tsconfig.json | 4 +- packages/create-logto/src/index.ts | 95 -------- pnpm-lock.yaml | 218 ++++++++++++++++-- 6 files changed, 300 insertions(+), 125 deletions(-) rename packages/{create-logto => cli}/package.json (86%) rename packages/{create-logto => cli}/src/functions.ts (75%) create mode 100644 packages/cli/src/index.ts rename packages/{create-logto => cli}/tsconfig.json (65%) delete mode 100644 packages/create-logto/src/index.ts diff --git a/packages/create-logto/package.json b/packages/cli/package.json similarity index 86% rename from packages/create-logto/package.json rename to packages/cli/package.json index 12c5778b3..ef79c57b2 100644 --- a/packages/create-logto/package.json +++ b/packages/cli/package.json @@ -1,10 +1,12 @@ { - "name": "create-logto", - "version": "1.0.0", - "description": "Logto creation to getting started.", + "name": "cli", + "version": "1.0.0-beta.9", + "description": "Logto CLI.", "author": "Silverhand Inc. ", "homepage": "https://github.com/logto-io/logto#readme", "license": "MPL-2.0", + "//": "Temporarily set to private until the package is ready.", + "private": true, "files": [ "lib" ], @@ -28,14 +30,13 @@ "url": "https://github.com/logto-io/logto/issues" }, "dependencies": { - "axios": "^0.27.2", "decompress": "^4.2.1", + "got": "^11.8.2", "prompts": "^2.4.2" }, "devDependencies": { "@silverhand/eslint-config": "1.0.0", "@silverhand/ts-config": "1.0.0", - "@types/axios": "^0.14.0", "@types/decompress": "^4.2.4", "@types/prompts": "^2.0.14", "eslint": "^8.21.0", diff --git a/packages/create-logto/src/functions.ts b/packages/cli/src/functions.ts similarity index 75% rename from packages/create-logto/src/functions.ts rename to packages/cli/src/functions.ts index b7c066879..7db852bde 100644 --- a/packages/create-logto/src/functions.ts +++ b/packages/cli/src/functions.ts @@ -1,12 +1,13 @@ import { execSync } from 'child_process'; import { createWriteStream } from 'fs'; -import axios from 'axios'; +import got from 'got'; export const isVersionGreaterThan = (version: string, targetMajor: number) => Number(version.split('.')[0]) >= targetMajor; -export const trimV = (version: string) => (version.startsWith('v') ? version.slice(1) : version); +export const trimVersion = (version: string) => + version.startsWith('v') ? version.slice(1) : version; export const safeExecSync = (command: string) => { try { @@ -16,8 +17,7 @@ export const safeExecSync = (command: string) => { export const downloadFile = async (url: string, destination: string) => { const file = createWriteStream(destination); - const response = await axios.get(url, { responseType: 'stream' }); - response.data.pipe(file); + got.stream(url).pipe(file); return new Promise((resolve, reject) => { file.on('error', (error) => { diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts new file mode 100644 index 000000000..01ad31b1f --- /dev/null +++ b/packages/cli/src/index.ts @@ -0,0 +1,89 @@ +import { execSync } from 'child_process'; +import { existsSync } from 'fs'; +import { unlink } from 'fs/promises'; +import path from 'path'; + +import decompress from 'decompress'; +import * as prompts from 'prompts'; + +import { downloadFile, isVersionGreaterThan, safeExecSync, trimVersion } from './functions'; + +const DIRECTORY = 'logto'; +const NODE_MAJOR_VERSION = 16; +const POSTGRES_MAJOR_VERSION = 14; + +const getPromptResponse = async (pgVersion: string) => + prompts.default( + [ + { + name: 'instancePath', + message: 'Where should we create your logto instance?', + type: 'text', + initial: './' + DIRECTORY, + format: (value: string) => path.resolve(value.trim()), + validate: (value: string) => + existsSync(value) ? 'That path already exists, please try another.' : true, + }, + { + name: 'hasPostgresUrl', + message: `Logto requires PostgreSQL >= ${POSTGRES_MAJOR_VERSION}.0.0 but cannot find in the current environment. Do you have a remote PostgreSQL instance ready?`, + type: !isVersionGreaterThan(trimVersion(pgVersion), POSTGRES_MAJOR_VERSION) && 'confirm', + initial: true, + }, + { + name: 'postgresUrl', + message: 'What is the URL of your PostgreSQL instance?', + type: (_, data) => (data.hasPostgresUrl ? 'text' : null), + format: (value: string) => value.trim(), + validate: (value: string) => + (value && + Boolean( + /^(?:([^\s#/:?]+):\/{2})?(?:([^\s#/?@]+)@)?([^\s#/?]+)?(?:\/([^\s#?]*))?(?:\?([^\s#]+))?\S*$/.test( + value + ) + )) || + 'Please enter a valid connection URL.', + }, + { + name: 'startInstance', + message: 'Would you like to start Logto now?', + type: 'confirm', + initial: true, + }, + ], + { + onCancel: () => { + throw new Error('Operation cancelled'); + }, + } + ); + +async function main() { + const nodeVersion = execSync('node -v', { encoding: 'utf8', stdio: 'pipe' }); + const pgOutput = safeExecSync('postgres --version') ?? ''; + const pgArray = pgOutput.split(' '); + const pgVersion = pgArray[pgArray.length - 1] ?? ''; + + if (!isVersionGreaterThan(trimVersion(nodeVersion), NODE_MAJOR_VERSION)) { + throw new Error(`Logto requires NodeJS >= ${NODE_MAJOR_VERSION}.0.0.`); + } + + const response = await getPromptResponse(pgVersion); + const startCommand = `cd ${String(response.instancePath)} && npm start`; + const tarFileLocation = path.resolve('./logto.tar.gz'); + + await downloadFile( + 'https://github.com/logto-io/logto/releases/latest/download/logto.tar.gz', + tarFileLocation + ); + await decompress(tarFileLocation, response.instancePath); + await unlink(tarFileLocation); + + if (response.startInstance) { + execSync(startCommand, { stdio: 'inherit' }); + } else { + console.log(`You can use ${startCommand} to start Logto. Happy hacking!`); + } +} + +await main(); diff --git a/packages/create-logto/tsconfig.json b/packages/cli/tsconfig.json similarity index 65% rename from packages/create-logto/tsconfig.json rename to packages/cli/tsconfig.json index ec160f030..747c9b09d 100644 --- a/packages/create-logto/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -2,7 +2,9 @@ "extends": "@silverhand/ts-config/tsconfig.base", "compilerOptions": { "outDir": "lib", - "declaration": true + "declaration": true, + "module": "node16", + "target": "es2022" }, "include": [ "src" diff --git a/packages/create-logto/src/index.ts b/packages/create-logto/src/index.ts deleted file mode 100644 index 07af84f90..000000000 --- a/packages/create-logto/src/index.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { execSync } from 'child_process'; -import { existsSync } from 'fs'; -import { unlink } from 'fs/promises'; -import path from 'path'; - -import decompress from 'decompress'; -import prompt from 'prompts'; - -import { downloadFile, isVersionGreaterThan, safeExecSync, trimV } from './functions'; - -const DIRECTORY = 'logto'; -const NODE_MAJOR_VERSION = 16; -const POSTGRES_MAJOR_VERSION = 14; - -async function main() { - const nodeVersion = execSync('node -v', { encoding: 'utf8', stdio: 'pipe' }); - const pgOutput = safeExecSync('postgres --version') ?? ''; - const pgArray = pgOutput.split(' '); - const pgVersion = pgArray[pgArray.length - 1]!; - - if (!isVersionGreaterThan(trimV(nodeVersion), NODE_MAJOR_VERSION)) { - throw new Error(`Logto requires NodeJS >= ${NODE_MAJOR_VERSION}.0.0.`); - } - - let response; - - try { - response = await prompt( - [ - { - name: 'instancePath', - message: 'Where should we create your logto instance?', - type: 'text', - initial: './' + DIRECTORY, - format: (value: string) => path.resolve(value.trim()), - validate: (value: string) => - existsSync(value) ? 'That path already exists, please try another.' : true, - }, - { - name: 'hasPostgresUrl', - message: `Logto requires PostgreSQL >= ${POSTGRES_MAJOR_VERSION}.0.0 but cannot find in the current environment. Do you have a remote PostgreSQL instance ready?`, - type: !isVersionGreaterThan(trimV(pgVersion), POSTGRES_MAJOR_VERSION) ? 'confirm' : null, - initial: true, - }, - { - name: 'postgresUrl', - message: 'What is the URL of your PostgreSQL instance?', - type: (_, data) => (data.hasPostgresUrl ? 'text' : null), - format: (value: string) => value.trim(), - validate: (value: string) => - (value && - Boolean( - /^(?:([^\s#/:?]+):\/{2})?(?:([^\s#/?@]+)@)?([^\s#/?]+)?(?:\/([^\s#?]*))?(?:\?([^\s#]+))?\S*$/.test( - value - ) - )) || - 'Please enter a valid connection URL.', - }, - { - name: 'startInstance', - message: 'Would you like to start Logto now?', - type: 'confirm', - initial: true, - }, - ], - { - onCancel: () => { - throw new Error('Operation cancelled'); - }, - } - ); - } catch (error: any) { - console.log(error.message); - - return; - } - - const startCommand = `cd ${response.instancePath} && npm start`; - const tarFileLocation = path.resolve('./logto.tar.gz'); - - await downloadFile( - 'https://github.com/logto-io/logto/releases/latest/download/logto.tar.gz', - tarFileLocation - ); - await decompress(tarFileLocation, response.instancePath); - await unlink(tarFileLocation); - - if (response.startInstance) { - execSync(startCommand, { stdio: 'inherit' }); - } else { - console.log(`You can use ${startCommand} to start Logto. Happy hacking!`); - } -} - -main(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d4d5008e4..d64cbbba4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,35 @@ importers: lerna: 5.0.0 typescript: 4.7.4 + packages/cli: + specifiers: + '@silverhand/eslint-config': 1.0.0 + '@silverhand/ts-config': 1.0.0 + '@types/decompress': ^4.2.4 + '@types/prompts': ^2.0.14 + decompress: ^4.2.1 + eslint: ^8.21.0 + got: ^11.8.2 + lint-staged: ^13.0.0 + prettier: ^2.7.1 + prompts: ^2.4.2 + rimraf: ^3.0.2 + typescript: ^4.7.4 + dependencies: + decompress: 4.2.1 + got: 11.8.3 + prompts: 2.4.2 + devDependencies: + '@silverhand/eslint-config': 1.0.0_swk2g7ygmfleszo5c33j4vooni + '@silverhand/ts-config': 1.0.0_typescript@4.7.4 + '@types/decompress': 4.2.4 + '@types/prompts': 2.0.14 + eslint: 8.21.0 + lint-staged: 13.0.0 + prettier: 2.7.1 + rimraf: 3.0.2 + typescript: 4.7.4 + packages/console: specifiers: '@fontsource/roboto-mono': ^4.5.7 @@ -4233,6 +4262,12 @@ 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: @@ -4504,6 +4539,12 @@ packages: resolution: {integrity: sha512-ekoj4qOQYp7CvjX8ZDBgN86w3MqQhLE1hczEJbEIjgFEumDy+na/4AJAbLXfgEWFNB2pKadM5rPFtuSGMWK7xA==} dev: true + /@types/prompts/2.0.14: + resolution: {integrity: sha512-HZBd99fKxRWpYCErtm2/yxUZv6/PBI9J7N4TNFffl5JbrYMHBwF25DjQGTW3b3jmXq+9P6/8fCIb2ee57BFfYA==} + dependencies: + '@types/node': 17.0.23 + dev: true + /@types/prop-types/15.7.4: resolution: {integrity: sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ==} dev: true @@ -5261,6 +5302,13 @@ packages: engines: {node: '>=8'} dev: true + /bl/1.2.3: + resolution: {integrity: sha512-pvcNpa0UU69UT341rO6AYy4FVAIkUHuZXRIWbq+zHnsVcRzDDjIAhGuuYoi0d//cwIwtt4pkpKycWEfjdV+vww==} + dependencies: + readable-stream: 2.3.7 + safe-buffer: 5.2.1 + dev: false + /bl/4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} dependencies: @@ -5326,9 +5374,23 @@ packages: node-int64: 0.4.0 dev: true + /buffer-alloc-unsafe/1.1.0: + resolution: {integrity: sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==} + dev: false + + /buffer-alloc/1.2.0: + resolution: {integrity: sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==} + dependencies: + buffer-alloc-unsafe: 1.1.0 + buffer-fill: 1.0.0 + dev: false + /buffer-crc32/0.2.13: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} - dev: true + + /buffer-fill/1.0.0: + resolution: {integrity: sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ==} + dev: false /buffer-from/1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -5795,7 +5857,6 @@ packages: /commander/2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} - dev: true /commander/5.1.0: resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==} @@ -5995,7 +6056,6 @@ packages: /core-util-is/1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} - dev: true /cosmiconfig-typescript-loader/2.0.0_bjctuninx3nzqxltyvshqte2ni: resolution: {integrity: sha512-2NlGul/E3vTQEANqPziqkA01vfiuUU8vT0jZAuUIjEW8u3eCcnCQWLggapCjhbF76s7KQF0fM0kXSKmzaDaG1g==} @@ -6305,6 +6365,59 @@ packages: dependencies: mimic-response: 3.1.0 + /decompress-tar/4.1.1: + resolution: {integrity: sha512-JdJMaCrGpB5fESVyxwpCx4Jdj2AagLmv3y58Qy4GE6HMVjWz1FeVQk1Ct4Kye7PftcdOo/7U7UKzYBJgqnGeUQ==} + engines: {node: '>=4'} + dependencies: + file-type: 5.2.0 + is-stream: 1.1.0 + tar-stream: 1.6.2 + dev: false + + /decompress-tarbz2/4.1.1: + resolution: {integrity: sha512-s88xLzf1r81ICXLAVQVzaN6ZmX4A6U4z2nMbOwobxkLoIIfjVMBg7TeguTUXkKeXni795B6y5rnvDw7rxhAq9A==} + engines: {node: '>=4'} + dependencies: + decompress-tar: 4.1.1 + file-type: 6.2.0 + is-stream: 1.1.0 + seek-bzip: 1.0.6 + unbzip2-stream: 1.4.3 + dev: false + + /decompress-targz/4.1.1: + resolution: {integrity: sha512-4z81Znfr6chWnRDNfFNqLwPvm4db3WuZkqV+UgXQzSngG3CEKdBkw5jrv3axjjL96glyiiKjsxJG3X6WBZwX3w==} + engines: {node: '>=4'} + dependencies: + decompress-tar: 4.1.1 + file-type: 5.2.0 + is-stream: 1.1.0 + dev: false + + /decompress-unzip/4.0.1: + resolution: {integrity: sha512-1fqeluvxgnn86MOh66u8FjbtJpAFv5wgCT9Iw8rcBqQcCo5tO8eiJw7NNTrvt9n4CRBVq7CstiS922oPgyGLrw==} + engines: {node: '>=4'} + dependencies: + file-type: 3.9.0 + get-stream: 2.3.1 + pify: 2.3.0 + yauzl: 2.10.0 + dev: false + + /decompress/4.2.1: + resolution: {integrity: sha512-e48kc2IjU+2Zw8cTb6VZcJQ3lgVbS4uuB1TfCHbiZIP/haNXm+SVyhu+87jts5/3ROpd82GSVCoNs/z8l4ZOaQ==} + engines: {node: '>=4'} + dependencies: + decompress-tar: 4.1.1 + decompress-tarbz2: 4.1.1 + decompress-targz: 4.1.1 + decompress-unzip: 4.0.1 + graceful-fs: 4.2.9 + make-dir: 1.3.0 + pify: 2.3.0 + strip-dirs: 2.1.0 + dev: false + /dedent/0.7.0: resolution: {integrity: sha1-JJXduvbrh0q7Dhvp3yLS5aVEMmw=} dev: true @@ -7305,7 +7418,6 @@ packages: resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} dependencies: pend: 1.2.0 - dev: true /figures/3.2.0: resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} @@ -7320,6 +7432,21 @@ packages: flat-cache: 3.0.4 dev: true + /file-type/3.9.0: + resolution: {integrity: sha512-RLoqTXE8/vPmMuTI88DAzhMYC99I8BWv7zYP4A1puo5HIjEJ5EX48ighy4ZyKMG9EDXxBgW6e++cn7d1xuFghA==} + engines: {node: '>=0.10.0'} + dev: false + + /file-type/5.2.0: + resolution: {integrity: sha512-Iq1nJ6D2+yIO4c8HHg4fyVb8mAJieo1Oloy1mLLaB2PvezNedhBVm+QU7g0qM42aiMbRXTxKKwGD17rjKNJYVQ==} + engines: {node: '>=4'} + dev: false + + /file-type/6.2.0: + resolution: {integrity: sha512-YPcTBDV+2Tm0VqjybVd32MHdlEGAtuxS3VAYsumFokDSMG+ROT5wawGlnHDoz7bfMcMDt9hxuXvXwoKUx2fkOg==} + engines: {node: '>=4'} + dev: false + /filelist/1.0.2: resolution: {integrity: sha512-z7O0IS8Plc39rTCq6i6iHxk43duYOn8uFJiWSewIq0Bww1RNybVHSCjahmcC87ZqAm4OTvFzlzeGu3XAzG1ctQ==} dependencies: @@ -7465,7 +7592,6 @@ packages: /fs-constants/1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} - dev: true /fs-exists-sync/0.1.0: resolution: {integrity: sha512-cR/vflFyPZtrN6b38ZyWxpWdhlXrzZEBawlpBQMq7033xVY7/kg0GDMBK5jg8lDYQckdJ5x/YC88lM3C7VMsLg==} @@ -7616,6 +7742,14 @@ packages: engines: {node: '>=10'} dev: true + /get-stream/2.3.1: + resolution: {integrity: sha512-AUGhbbemXxrZJRD5cDvKtQxLuYaIbNtDTK8YqupCI393Q2KSTreEsLUN3ZxAWFGiKTzL6nKuzfcIvieflUX9qA==} + engines: {node: '>=0.10.0'} + dependencies: + object-assign: 4.1.1 + pinkie-promise: 2.0.1 + dev: false + /get-stream/5.2.0: resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} engines: {node: '>=8'} @@ -8649,6 +8783,10 @@ packages: resolution: {integrity: sha1-PZh3iZ5qU+/AFgUEzeFfgubwYdU=} dev: true + /is-natural-number/4.0.1: + resolution: {integrity: sha512-Y4LTamMe0DDQIIAlaer9eKebAlDSV6huy+TWhJVPlzZh2o4tRP5SQWFlLn5N0To4mDD22/qdOq+veo1cSISLgQ==} + dev: false + /is-negative-zero/2.0.2: resolution: {integrity: sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==} engines: {node: '>= 0.4'} @@ -8738,6 +8876,11 @@ packages: protocols: 1.4.8 dev: true + /is-stream/1.1.0: + resolution: {integrity: sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==} + engines: {node: '>=0.10.0'} + dev: false + /is-stream/2.0.1: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} @@ -8809,7 +8952,6 @@ packages: /isarray/1.0.0: resolution: {integrity: sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=} - dev: true /isexe/2.0.0: resolution: {integrity: sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=} @@ -9858,7 +10000,6 @@ packages: /kleur/3.0.3: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} - dev: true /kleur/4.1.4: resolution: {integrity: sha512-8QADVssbrFjivHWQU7KkMgptGTl6WAcSdlbBPY4uNF+mWr6DGcKrvY2w4FQJoXch7+fKMjj0dRrL75vk3k23OA==} @@ -10319,6 +10460,13 @@ packages: hasBin: true dev: true + /make-dir/1.3.0: + resolution: {integrity: sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==} + engines: {node: '>=4'} + dependencies: + pify: 3.0.0 + dev: false + /make-dir/2.1.0: resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==} engines: {node: '>=6'} @@ -11582,7 +11730,6 @@ packages: /object-assign/4.1.1: resolution: {integrity: sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=} engines: {node: '>=0.10.0'} - dev: true /object-hash/2.2.0: resolution: {integrity: sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==} @@ -12140,7 +12287,6 @@ packages: /pend/1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} - dev: true /pg-connection-string/2.5.0: resolution: {integrity: sha512-r5o/V/ORTA6TmUnyWZR9nCj1klXCO2CEKNRlVuJptZe85QuhFayC7WeMic7ndayT5IRIR0S0xFxFi2ousartlQ==} @@ -12273,12 +12419,10 @@ packages: /pify/2.3.0: resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} engines: {node: '>=0.10.0'} - dev: true /pify/3.0.0: - resolution: {integrity: sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=} + resolution: {integrity: sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==} engines: {node: '>=4'} - dev: true /pify/4.0.1: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} @@ -12290,6 +12434,18 @@ packages: engines: {node: '>=10'} dev: true + /pinkie-promise/2.0.1: + resolution: {integrity: sha512-0Gni6D4UcLTbv9c57DfxDGdr41XfgUjqWZu492f0cIGr16zDU06BWP/RAEvOuo7CQ0CNjHaLlM59YJJFm3NWlw==} + engines: {node: '>=0.10.0'} + dependencies: + pinkie: 2.0.4 + dev: false + + /pinkie/2.0.4: + resolution: {integrity: sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg==} + engines: {node: '>=0.10.0'} + dev: false + /pirates/4.0.4: resolution: {integrity: sha512-ZIrVPH+A52Dw84R0L3/VS9Op04PuQ2SEoJL6bkshmiTic/HldyW9Tf7oH5mhJZBK7NmDx27vSMrYEXPXclpDKw==} engines: {node: '>= 6'} @@ -12601,7 +12757,6 @@ packages: /process-nextick-args/2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} - dev: true /process/0.11.10: resolution: {integrity: sha1-czIwDoQBYb2j5podHZGn1LwW8YI=} @@ -12639,7 +12794,6 @@ packages: dependencies: kleur: 3.0.3 sisteransi: 1.0.5 - dev: true /promzard/0.3.0: resolution: {integrity: sha1-JqXW7ox97kyxIggwWs+5O6OCqe4=} @@ -13225,7 +13379,6 @@ packages: safe-buffer: 5.1.2 string_decoder: 1.1.1 util-deprecate: 1.0.2 - dev: true /readable-stream/3.6.0: resolution: {integrity: sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==} @@ -13575,7 +13728,6 @@ packages: /safe-buffer/5.1.2: resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} - dev: true /safe-buffer/5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -13612,6 +13764,13 @@ packages: loose-envify: 1.4.0 dev: true + /seek-bzip/1.0.6: + resolution: {integrity: sha512-e1QtP3YL5tWww8uKaOCQ18UxIT2laNBXHjV/S2WYCiK4udiv8lkG89KRIoCjUagnAmCBurjF4zEVX2ByBbnCjQ==} + hasBin: true + dependencies: + commander: 2.20.3 + dev: false + /semver-compare/1.0.0: resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==} @@ -13718,7 +13877,6 @@ packages: /sisteransi/1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} - dev: true /slash/3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} @@ -14190,7 +14348,6 @@ packages: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} dependencies: safe-buffer: 5.1.2 - dev: true /string_decoder/1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} @@ -14234,6 +14391,12 @@ packages: engines: {node: '>=8'} dev: true + /strip-dirs/2.1.0: + resolution: {integrity: sha512-JOCxOeKLm2CAS73y/U4ZeZPTkE+gNVCzKt7Eox84Iej1LT/2pTWYpZKJuxwQpvX1LiZb1xokNR7RLfuBAa7T3g==} + dependencies: + is-natural-number: 4.0.1 + dev: false + /strip-final-newline/2.0.0: resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} engines: {node: '>=6'} @@ -14537,6 +14700,19 @@ packages: tar-stream: 2.2.0 dev: true + /tar-stream/1.6.2: + resolution: {integrity: sha512-rzS0heiNf8Xn7/mpdSVVSMAWAoy9bfb1WOTYC78Z0UQKeKa/CWS8FOq0lKGNa8DWKAn9gxjCvMLYc5PGXYlK2A==} + engines: {node: '>= 0.8.0'} + dependencies: + bl: 1.2.3 + buffer-alloc: 1.2.0 + end-of-stream: 1.4.4 + fs-constants: 1.0.0 + readable-stream: 2.3.7 + to-buffer: 1.1.1 + xtend: 4.0.2 + dev: false + /tar-stream/2.2.0: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} @@ -14652,6 +14828,10 @@ packages: resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} dev: true + /to-buffer/1.1.1: + resolution: {integrity: sha512-lx9B5iv7msuFYE3dytT+KE5tap+rNYw+K4jVkb9R/asAb+pbBSM17jtunHplhBe6RRJdZx3Pn2Jph24O32mOVg==} + dev: false + /to-fast-properties/2.0.0: resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} engines: {node: '>=4'} @@ -14981,7 +15161,6 @@ packages: dependencies: buffer: 5.7.1 through: 2.3.8 - dev: true /undefsafe/2.0.5: resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==} @@ -15624,7 +15803,6 @@ packages: dependencies: buffer-crc32: 0.2.13 fd-slicer: 1.1.0 - dev: true /ylru/1.2.1: resolution: {integrity: sha512-faQrqNMzcPCHGVC2aaOINk13K+aaBDUPjGWl0teOXywElLjyVAB6Oe2jj62jHYtwsU49jXhScYbvPENK+6zAvQ==} From a59ba6e3187b2bcb8c2ea59e7c51e5b80447d496 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Mon, 3 Oct 2022 17:52:28 +0800 Subject: [PATCH 20/54] refactor(cli): experience improvements --- packages/cli/package.json | 21 ++- packages/cli/src/functions.ts | 31 ---- packages/cli/src/index.ts | 151 ++++++++++++------- packages/cli/src/utilities.ts | 65 ++++++++ pnpm-lock.yaml | 272 ++++++++++++---------------------- 5 files changed, 267 insertions(+), 273 deletions(-) delete mode 100644 packages/cli/src/functions.ts create mode 100644 packages/cli/src/utilities.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index ef79c57b2..be4e299c4 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,12 +1,11 @@ { - "name": "cli", + "name": "@logto/cli", "version": "1.0.0-beta.9", "description": "Logto CLI.", "author": "Silverhand Inc. ", "homepage": "https://github.com/logto-io/logto#readme", "license": "MPL-2.0", - "//": "Temporarily set to private until the package is ready.", - "private": true, + "main": "lib/index.js", "files": [ "lib" ], @@ -14,11 +13,12 @@ "type": "git", "url": "git+https://github.com/logto-io/logto.git" }, - "bin": "./lib/index.js", + "bin": "lib/index.js", "scripts": { "precommit": "lint-staged", "build": "rimraf lib && tsc", - "dev": "tsc --watch --preserveWatchOutput --incremental", + "start": "node .", + "dev": "ts-node src/index.ts", "lint": "eslint --ext .ts src", "lint:report": "pnpm lint --format json --output-file report.json", "prepack": "pnpm build" @@ -30,19 +30,26 @@ "url": "https://github.com/logto-io/logto/issues" }, "dependencies": { - "decompress": "^4.2.1", + "chalk": "^4.1.2", "got": "^11.8.2", - "prompts": "^2.4.2" + "ora": "^5.0.0", + "prompts": "^2.4.2", + "semver": "^7.3.7", + "tar": "^6.1.11" }, "devDependencies": { "@silverhand/eslint-config": "1.0.0", "@silverhand/ts-config": "1.0.0", "@types/decompress": "^4.2.4", + "@types/node": "^16.0.0", "@types/prompts": "^2.0.14", + "@types/semver": "^7.3.12", + "@types/tar": "^6.1.2", "eslint": "^8.21.0", "lint-staged": "^13.0.0", "prettier": "^2.7.1", "rimraf": "^3.0.2", + "ts-node": "^10.9.1", "typescript": "^4.7.4" }, "eslintConfig": { diff --git a/packages/cli/src/functions.ts b/packages/cli/src/functions.ts deleted file mode 100644 index 7db852bde..000000000 --- a/packages/cli/src/functions.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { execSync } from 'child_process'; -import { createWriteStream } from 'fs'; - -import got from 'got'; - -export const isVersionGreaterThan = (version: string, targetMajor: number) => - Number(version.split('.')[0]) >= targetMajor; - -export const trimVersion = (version: string) => - version.startsWith('v') ? version.slice(1) : version; - -export const safeExecSync = (command: string) => { - try { - return execSync(command, { encoding: 'utf8', stdio: 'pipe' }); - } catch {} -}; - -export const downloadFile = async (url: string, destination: string) => { - const file = createWriteStream(destination); - got.stream(url).pipe(file); - - return new Promise((resolve, reject) => { - file.on('error', (error) => { - reject(error.message); - }); - file.on('finish', () => { - file.close(); - resolve(file); - }); - }); -}; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 01ad31b1f..1f3f6f4f9 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,89 +1,130 @@ import { execSync } from 'child_process'; import { existsSync } from 'fs'; -import { unlink } from 'fs/promises'; +import { mkdir } from 'fs/promises'; +import os from 'os'; import path from 'path'; -import decompress from 'decompress'; +import chalk from 'chalk'; +import ora from 'ora'; import * as prompts from 'prompts'; +import * as semver from 'semver'; +import tar from 'tar'; -import { downloadFile, isVersionGreaterThan, safeExecSync, trimVersion } from './functions'; +import { downloadFile, log, safeExecSync } from './utilities'; -const DIRECTORY = 'logto'; -const NODE_MAJOR_VERSION = 16; -const POSTGRES_MAJOR_VERSION = 14; +const pgRequired = new semver.SemVer('14.0.0'); -const getPromptResponse = async (pgVersion: string) => - prompts.default( +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 getInstancePath = async () => { + const response = await prompts.default( [ { name: 'instancePath', message: 'Where should we create your logto instance?', type: 'text', - initial: './' + DIRECTORY, + initial: './logto', format: (value: string) => path.resolve(value.trim()), validate: (value: string) => existsSync(value) ? 'That path already exists, please try another.' : true, }, { name: 'hasPostgresUrl', - message: `Logto requires PostgreSQL >= ${POSTGRES_MAJOR_VERSION}.0.0 but cannot find in the current environment. Do you have a remote PostgreSQL instance ready?`, - type: !isVersionGreaterThan(trimVersion(pgVersion), POSTGRES_MAJOR_VERSION) && 'confirm', - initial: true, - }, - { - name: 'postgresUrl', - message: 'What is the URL of your PostgreSQL instance?', - type: (_, data) => (data.hasPostgresUrl ? 'text' : null), - format: (value: string) => value.trim(), - validate: (value: string) => - (value && - Boolean( - /^(?:([^\s#/:?]+):\/{2})?(?:([^\s#/?@]+)@)?([^\s#/?]+)?(?:\/([^\s#?]*))?(?:\?([^\s#]+))?\S*$/.test( - value - ) - )) || - 'Please enter a valid connection URL.', - }, - { - name: 'startInstance', - message: 'Would you like to start Logto now?', - type: 'confirm', - initial: true, + message: `Logto requires PostgreSQL >=${pgRequired.version} but cannot find in the current environment.\n Do you have a remote PostgreSQL instance ready?`, + type: () => { + 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) && 'confirm'; + }, + format: (previous) => { + if (!previous) { + log.error('Logto requires a Postgres instance to run.'); + } + }, }, ], { onCancel: () => { - throw new Error('Operation cancelled'); + log.error('Operation cancelled'); }, } ); -async function main() { - const nodeVersion = execSync('node -v', { encoding: 'utf8', stdio: 'pipe' }); - const pgOutput = safeExecSync('postgres --version') ?? ''; - const pgArray = pgOutput.split(' '); - const pgVersion = pgArray[pgArray.length - 1] ?? ''; + return String(response.instancePath); +}; - if (!isVersionGreaterThan(trimVersion(nodeVersion), NODE_MAJOR_VERSION)) { - throw new Error(`Logto requires NodeJS >= ${NODE_MAJOR_VERSION}.0.0.`); - } +const tryStartInstance = async (instancePath: string) => { + const response = await prompts.default({ + name: 'startInstance', + message: 'Would you like to start Logto now?', + type: 'confirm', + initial: true, + }); - const response = await getPromptResponse(pgVersion); - const startCommand = `cd ${String(response.instancePath)} && npm start`; - const tarFileLocation = path.resolve('./logto.tar.gz'); + const yes = Boolean(response.startInstance); + const startCommand = `cd ${instancePath} && npm start`; - await downloadFile( - 'https://github.com/logto-io/logto/releases/latest/download/logto.tar.gz', - tarFileLocation - ); - await decompress(tarFileLocation, response.instancePath); - await unlink(tarFileLocation); - - if (response.startInstance) { + if (yes) { execSync(startCommand, { stdio: 'inherit' }); } else { - console.log(`You can use ${startCommand} to start Logto. Happy hacking!`); + log.info(`You can use ${startCommand} to start Logto. Happy hacking!`); } -} +}; -await main(); +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) => { + const decompressSpinner = ora({ + text: `Decompress to ${toPath}`, + prefixText: chalk.blue('[info]'), + }).start(); + + try { + await mkdir(toPath); + await tar.extract({ file: tarPath, cwd: toPath, strip: 1 }); + } catch { + decompressSpinner.fail(); + + return; + } + + decompressSpinner.succeed(); +}; + +const main = async () => { + validateNodeVersion(); + + const instancePath = await getInstancePath(); + const tarPath = await downloadRelease(); + + await decompress(instancePath, tarPath); + await tryStartInstance(instancePath); +}; + +void main(); diff --git a/packages/cli/src/utilities.ts b/packages/cli/src/utilities.ts new file mode 100644 index 000000000..eee72be13 --- /dev/null +++ b/packages/cli/src/utilities.ts @@ -0,0 +1,65 @@ +import { execSync } from 'child_process'; +import { createWriteStream } from 'fs'; + +import chalk from 'chalk'; +import got, { Progress } from 'got'; +import ora from 'ora'; + +export const safeExecSync = (command: string) => { + try { + return execSync(command, { encoding: 'utf8', stdio: 'pipe' }); + } catch {} +}; + +type Log = Readonly<{ + info: typeof console.log; + warn: typeof console.log; + error: typeof console.log; +}>; + +export const log: Log = Object.freeze({ + info: (...args) => { + console.log(chalk.blue('[info]'), ...args); + }, + warn: (...args) => { + console.log(chalk.yellow('[warn]'), ...args); + }, + error: (...args) => { + console.log(chalk.red('[error]'), ...args); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); + }, +}); + +export const downloadFile = async (url: string, destination: string) => { + const file = createWriteStream(destination); + const stream = got.stream(url); + const spinner = ora({ + text: 'Connecting', + prefixText: chalk.blue('[info]'), + }).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); + }); + }); +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d64cbbba4..299ca13b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,28 +23,42 @@ importers: '@silverhand/eslint-config': 1.0.0 '@silverhand/ts-config': 1.0.0 '@types/decompress': ^4.2.4 + '@types/node': ^16.0.0 '@types/prompts': ^2.0.14 - decompress: ^4.2.1 + '@types/semver': ^7.3.12 + '@types/tar': ^6.1.2 + chalk: ^4.1.2 eslint: ^8.21.0 got: ^11.8.2 lint-staged: ^13.0.0 + ora: ^5.0.0 prettier: ^2.7.1 prompts: ^2.4.2 rimraf: ^3.0.2 + semver: ^7.3.7 + tar: ^6.1.11 + ts-node: ^10.9.1 typescript: ^4.7.4 dependencies: - decompress: 4.2.1 + chalk: 4.1.2 got: 11.8.3 + ora: 5.4.1 prompts: 2.4.2 + semver: 7.3.7 + tar: 6.1.11 devDependencies: '@silverhand/eslint-config': 1.0.0_swk2g7ygmfleszo5c33j4vooni '@silverhand/ts-config': 1.0.0_typescript@4.7.4 '@types/decompress': 4.2.4 + '@types/node': 16.11.12 '@types/prompts': 2.0.14 + '@types/semver': 7.3.12 + '@types/tar': 6.1.2 eslint: 8.21.0 lint-staged: 13.0.0 prettier: 2.7.1 rimraf: 3.0.2 + ts-node: 10.9.1_ccwudyfw5se7hgalwgkzhn2yp4 typescript: 4.7.4 packages/console: @@ -1680,8 +1694,8 @@ packages: /@jridgewell/trace-mapping/0.3.9: resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} dependencies: - '@jridgewell/resolve-uri': 3.0.5 - '@jridgewell/sourcemap-codec': 1.4.11 + '@jridgewell/resolve-uri': 3.1.0 + '@jridgewell/sourcemap-codec': 1.4.14 dev: true /@koa/cors/3.1.0: @@ -3850,7 +3864,7 @@ packages: eslint-import-resolver-typescript: 3.4.0_jatgrcxl4x7ywe7ak6cnjca2ae eslint-plugin-consistent-default-export-name: 0.0.15 eslint-plugin-eslint-comments: 3.2.0_eslint@8.21.0 - eslint-plugin-import: 2.26.0_eslint@8.21.0 + eslint-plugin-import: 2.26.0_klqlxqqxnpnfpttri4irupweri eslint-plugin-no-use-extend-native: 0.5.0 eslint-plugin-node: 11.1.0_eslint@8.21.0 eslint-plugin-prettier: 4.2.1_h62lvancfh4b7r6zn2dgodrh5e @@ -3860,6 +3874,7 @@ packages: eslint-plugin-unused-imports: 2.0.0_i7ihj7mda6acsfp32zwgvvndem prettier: 2.7.1 transitivePeerDependencies: + - eslint-import-resolver-webpack - supports-color - typescript dev: true @@ -4624,6 +4639,10 @@ packages: resolution: {integrity: sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==} dev: true + /@types/semver/7.3.12: + resolution: {integrity: sha512-WwA1MW0++RfXmCr12xeYOOC5baSC9mSb0ZqCquFzKhcoF4TvHu5MKOuXsncgZcpVFhB1pXd5hZmM0ryAoCp12A==} + dev: true + /@types/serve-static/1.13.10: resolution: {integrity: sha512-nCkHGI4w7ZgAdNkrEu0bv+4xNV/XDqW+DydknebMOQwkpDGx8G+HTlj7R7ABI8i8nKxVw0wtKPi1D+lPOkh4YQ==} dependencies: @@ -4891,12 +4910,6 @@ packages: hasBin: true dev: true - /acorn/8.7.1: - resolution: {integrity: sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A==} - engines: {node: '>=0.4.0'} - hasBin: true - dev: true - /acorn/8.8.0: resolution: {integrity: sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w==} engines: {node: '>=0.4.0'} @@ -5302,13 +5315,6 @@ packages: engines: {node: '>=8'} dev: true - /bl/1.2.3: - resolution: {integrity: sha512-pvcNpa0UU69UT341rO6AYy4FVAIkUHuZXRIWbq+zHnsVcRzDDjIAhGuuYoi0d//cwIwtt4pkpKycWEfjdV+vww==} - dependencies: - readable-stream: 2.3.7 - safe-buffer: 5.2.1 - dev: false - /bl/4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} dependencies: @@ -5374,23 +5380,9 @@ packages: node-int64: 0.4.0 dev: true - /buffer-alloc-unsafe/1.1.0: - resolution: {integrity: sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==} - dev: false - - /buffer-alloc/1.2.0: - resolution: {integrity: sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==} - dependencies: - buffer-alloc-unsafe: 1.1.0 - buffer-fill: 1.0.0 - dev: false - /buffer-crc32/0.2.13: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} - - /buffer-fill/1.0.0: - resolution: {integrity: sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ==} - dev: false + dev: true /buffer-from/1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -5741,7 +5733,7 @@ packages: mimic-response: 1.0.1 /clone/1.0.4: - resolution: {integrity: sha1-2jCcwmPfFZlMaIypAheco8fNfH4=} + resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} /clone/2.1.2: @@ -5857,6 +5849,7 @@ packages: /commander/2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + dev: true /commander/5.1.0: resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==} @@ -5996,8 +5989,8 @@ packages: engines: {node: '>=10'} hasBin: true dependencies: - is-text-path: 1.0.1 JSONStream: 1.3.5 + is-text-path: 1.0.1 lodash: 4.17.21 meow: 8.1.2 split2: 3.2.2 @@ -6056,6 +6049,7 @@ packages: /core-util-is/1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + dev: true /cosmiconfig-typescript-loader/2.0.0_bjctuninx3nzqxltyvshqte2ni: resolution: {integrity: sha512-2NlGul/E3vTQEANqPziqkA01vfiuUU8vT0jZAuUIjEW8u3eCcnCQWLggapCjhbF76s7KQF0fM0kXSKmzaDaG1g==} @@ -6286,12 +6280,22 @@ packages: /debug/2.6.9: resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true dependencies: ms: 2.0.0 dev: true /debug/3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true dependencies: ms: 2.1.3 dev: true @@ -6365,59 +6369,6 @@ packages: dependencies: mimic-response: 3.1.0 - /decompress-tar/4.1.1: - resolution: {integrity: sha512-JdJMaCrGpB5fESVyxwpCx4Jdj2AagLmv3y58Qy4GE6HMVjWz1FeVQk1Ct4Kye7PftcdOo/7U7UKzYBJgqnGeUQ==} - engines: {node: '>=4'} - dependencies: - file-type: 5.2.0 - is-stream: 1.1.0 - tar-stream: 1.6.2 - dev: false - - /decompress-tarbz2/4.1.1: - resolution: {integrity: sha512-s88xLzf1r81ICXLAVQVzaN6ZmX4A6U4z2nMbOwobxkLoIIfjVMBg7TeguTUXkKeXni795B6y5rnvDw7rxhAq9A==} - engines: {node: '>=4'} - dependencies: - decompress-tar: 4.1.1 - file-type: 6.2.0 - is-stream: 1.1.0 - seek-bzip: 1.0.6 - unbzip2-stream: 1.4.3 - dev: false - - /decompress-targz/4.1.1: - resolution: {integrity: sha512-4z81Znfr6chWnRDNfFNqLwPvm4db3WuZkqV+UgXQzSngG3CEKdBkw5jrv3axjjL96glyiiKjsxJG3X6WBZwX3w==} - engines: {node: '>=4'} - dependencies: - decompress-tar: 4.1.1 - file-type: 5.2.0 - is-stream: 1.1.0 - dev: false - - /decompress-unzip/4.0.1: - resolution: {integrity: sha512-1fqeluvxgnn86MOh66u8FjbtJpAFv5wgCT9Iw8rcBqQcCo5tO8eiJw7NNTrvt9n4CRBVq7CstiS922oPgyGLrw==} - engines: {node: '>=4'} - dependencies: - file-type: 3.9.0 - get-stream: 2.3.1 - pify: 2.3.0 - yauzl: 2.10.0 - dev: false - - /decompress/4.2.1: - resolution: {integrity: sha512-e48kc2IjU+2Zw8cTb6VZcJQ3lgVbS4uuB1TfCHbiZIP/haNXm+SVyhu+87jts5/3ROpd82GSVCoNs/z8l4ZOaQ==} - engines: {node: '>=4'} - dependencies: - decompress-tar: 4.1.1 - decompress-tarbz2: 4.1.1 - decompress-targz: 4.1.1 - decompress-unzip: 4.0.1 - graceful-fs: 4.2.9 - make-dir: 1.3.0 - pify: 2.3.0 - strip-dirs: 2.1.0 - dev: false - /dedent/0.7.0: resolution: {integrity: sha1-JJXduvbrh0q7Dhvp3yLS5aVEMmw=} dev: true @@ -6434,7 +6385,7 @@ packages: engines: {node: '>=0.10.0'} /defaults/1.0.3: - resolution: {integrity: sha1-xlYFHpgX2f8I7YgUd/P+QBnz730=} + resolution: {integrity: sha512-s82itHOnYrN0Ib8r+z7laQz3sdE+4FP3d9Q7VLO7U+KRT+CR0GsWuyHxzdAY82I7cXv0G/twrqomTJLOssO5HA==} dependencies: clone: 1.0.4 @@ -6879,6 +6830,8 @@ packages: dependencies: debug: 3.2.7 resolve: 1.22.0 + transitivePeerDependencies: + - supports-color dev: true /eslint-import-resolver-typescript/3.4.0_jatgrcxl4x7ywe7ak6cnjca2ae: @@ -6891,7 +6844,7 @@ packages: debug: 4.3.4 enhanced-resolve: 5.10.0 eslint: 8.21.0 - eslint-plugin-import: 2.26.0_eslint@8.21.0 + eslint-plugin-import: 2.26.0_klqlxqqxnpnfpttri4irupweri get-tsconfig: 4.2.0 globby: 13.1.2 is-core-module: 2.9.0 @@ -6901,12 +6854,31 @@ packages: - supports-color dev: true - /eslint-module-utils/2.7.3: + /eslint-module-utils/2.7.3_dirjbmf3bsnpt3git34hjh5rju: resolution: {integrity: sha512-088JEC7O3lDZM9xGe0RerkOMd0EjFl+Yvd1jPWIkMT5u3H9+HC34mWWPnqPrN13gieT9pBOO+Qt07Nb/6TresQ==} engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint-import-resolver-node: '*' + eslint-import-resolver-typescript: '*' + eslint-import-resolver-webpack: '*' + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + eslint-import-resolver-node: + optional: true + eslint-import-resolver-typescript: + optional: true + eslint-import-resolver-webpack: + optional: true dependencies: + '@typescript-eslint/parser': 5.32.0_qugx7qdu5zevzvxaiqyxfiwquq debug: 3.2.7 + eslint-import-resolver-node: 0.3.6 + eslint-import-resolver-typescript: 3.4.0_jatgrcxl4x7ywe7ak6cnjca2ae find-up: 2.1.0 + transitivePeerDependencies: + - supports-color dev: true /eslint-plugin-consistent-default-export-name/0.0.15: @@ -6939,19 +6911,24 @@ packages: ignore: 5.2.0 dev: true - /eslint-plugin-import/2.26.0_eslint@8.21.0: + /eslint-plugin-import/2.26.0_klqlxqqxnpnfpttri4irupweri: resolution: {integrity: sha512-hYfi3FXaM8WPLf4S1cikh/r4IxnO6zrhZbEGz2b660EJRbuxgpDS5gkCuYgGWg2xxh2rBuIr4Pvhve/7c31koA==} engines: {node: '>=4'} peerDependencies: + '@typescript-eslint/parser': '*' eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true dependencies: + '@typescript-eslint/parser': 5.32.0_qugx7qdu5zevzvxaiqyxfiwquq array-includes: 3.1.4 array.prototype.flat: 1.2.5 debug: 2.6.9 doctrine: 2.1.0 eslint: 8.21.0 eslint-import-resolver-node: 0.3.6 - eslint-module-utils: 2.7.3 + eslint-module-utils: 2.7.3_dirjbmf3bsnpt3git34hjh5rju has: 1.0.3 is-core-module: 2.9.0 is-glob: 4.0.3 @@ -6959,6 +6936,10 @@ packages: object.values: 1.1.5 resolve: 1.22.0 tsconfig-paths: 3.14.1 + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color dev: true /eslint-plugin-no-use-extend-native/0.5.0: @@ -7418,6 +7399,7 @@ packages: resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} dependencies: pend: 1.2.0 + dev: true /figures/3.2.0: resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} @@ -7432,21 +7414,6 @@ packages: flat-cache: 3.0.4 dev: true - /file-type/3.9.0: - resolution: {integrity: sha512-RLoqTXE8/vPmMuTI88DAzhMYC99I8BWv7zYP4A1puo5HIjEJ5EX48ighy4ZyKMG9EDXxBgW6e++cn7d1xuFghA==} - engines: {node: '>=0.10.0'} - dev: false - - /file-type/5.2.0: - resolution: {integrity: sha512-Iq1nJ6D2+yIO4c8HHg4fyVb8mAJieo1Oloy1mLLaB2PvezNedhBVm+QU7g0qM42aiMbRXTxKKwGD17rjKNJYVQ==} - engines: {node: '>=4'} - dev: false - - /file-type/6.2.0: - resolution: {integrity: sha512-YPcTBDV+2Tm0VqjybVd32MHdlEGAtuxS3VAYsumFokDSMG+ROT5wawGlnHDoz7bfMcMDt9hxuXvXwoKUx2fkOg==} - engines: {node: '>=4'} - dev: false - /filelist/1.0.2: resolution: {integrity: sha512-z7O0IS8Plc39rTCq6i6iHxk43duYOn8uFJiWSewIq0Bww1RNybVHSCjahmcC87ZqAm4OTvFzlzeGu3XAzG1ctQ==} dependencies: @@ -7592,6 +7559,7 @@ packages: /fs-constants/1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + dev: true /fs-exists-sync/0.1.0: resolution: {integrity: sha512-cR/vflFyPZtrN6b38ZyWxpWdhlXrzZEBawlpBQMq7033xVY7/kg0GDMBK5jg8lDYQckdJ5x/YC88lM3C7VMsLg==} @@ -7742,14 +7710,6 @@ packages: engines: {node: '>=10'} dev: true - /get-stream/2.3.1: - resolution: {integrity: sha512-AUGhbbemXxrZJRD5cDvKtQxLuYaIbNtDTK8YqupCI393Q2KSTreEsLUN3ZxAWFGiKTzL6nKuzfcIvieflUX9qA==} - engines: {node: '>=0.10.0'} - dependencies: - object-assign: 4.1.1 - pinkie-promise: 2.0.1 - dev: false - /get-stream/5.2.0: resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} engines: {node: '>=8'} @@ -8783,10 +8743,6 @@ packages: resolution: {integrity: sha1-PZh3iZ5qU+/AFgUEzeFfgubwYdU=} dev: true - /is-natural-number/4.0.1: - resolution: {integrity: sha512-Y4LTamMe0DDQIIAlaer9eKebAlDSV6huy+TWhJVPlzZh2o4tRP5SQWFlLn5N0To4mDD22/qdOq+veo1cSISLgQ==} - dev: false - /is-negative-zero/2.0.2: resolution: {integrity: sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==} engines: {node: '>= 0.4'} @@ -8876,11 +8832,6 @@ packages: protocols: 1.4.8 dev: true - /is-stream/1.1.0: - resolution: {integrity: sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==} - engines: {node: '>=0.10.0'} - dev: false - /is-stream/2.0.1: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} @@ -8952,6 +8903,7 @@ packages: /isarray/1.0.0: resolution: {integrity: sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=} + dev: true /isexe/2.0.0: resolution: {integrity: sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=} @@ -10448,7 +10400,6 @@ packages: engines: {node: '>=10'} dependencies: yallist: 4.0.0 - dev: true /lru-cache/7.10.1: resolution: {integrity: sha512-BQuhQxPuRl79J5zSXRP+uNzPOyZw2oFI9JLRQ80XswSvg21KMKNtQza9eF42rfI/3Z40RvzBdXgziEkudzjo8A==} @@ -10460,13 +10411,6 @@ packages: hasBin: true dev: true - /make-dir/1.3.0: - resolution: {integrity: sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==} - engines: {node: '>=4'} - dependencies: - pify: 3.0.0 - dev: false - /make-dir/2.1.0: resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==} engines: {node: '>=6'} @@ -11197,6 +11141,7 @@ packages: engines: {node: '>=8'} dependencies: yallist: 4.0.0 + dev: true /minipass/3.3.5: resolution: {integrity: sha512-rQ/p+KfKBkeNwo04U15i+hOwoVBVmekmm/HcfTkTN2t9pbQKCMm4eN5gFeqgrrSp/kH/7BYYhTIHOxGqzbBPaA==} @@ -11730,6 +11675,7 @@ packages: /object-assign/4.1.1: resolution: {integrity: sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=} engines: {node: '>=0.10.0'} + dev: true /object-hash/2.2.0: resolution: {integrity: sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==} @@ -11918,7 +11864,7 @@ packages: dev: true /os-tmpdir/1.0.2: - resolution: {integrity: sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=} + resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==} engines: {node: '>=0.10.0'} /p-cancelable/2.1.1: @@ -12287,6 +12233,7 @@ packages: /pend/1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + dev: true /pg-connection-string/2.5.0: resolution: {integrity: sha512-r5o/V/ORTA6TmUnyWZR9nCj1klXCO2CEKNRlVuJptZe85QuhFayC7WeMic7ndayT5IRIR0S0xFxFi2ousartlQ==} @@ -12419,10 +12366,12 @@ packages: /pify/2.3.0: resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} engines: {node: '>=0.10.0'} + dev: true /pify/3.0.0: resolution: {integrity: sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==} engines: {node: '>=4'} + dev: true /pify/4.0.1: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} @@ -12434,18 +12383,6 @@ packages: engines: {node: '>=10'} dev: true - /pinkie-promise/2.0.1: - resolution: {integrity: sha512-0Gni6D4UcLTbv9c57DfxDGdr41XfgUjqWZu492f0cIGr16zDU06BWP/RAEvOuo7CQ0CNjHaLlM59YJJFm3NWlw==} - engines: {node: '>=0.10.0'} - dependencies: - pinkie: 2.0.4 - dev: false - - /pinkie/2.0.4: - resolution: {integrity: sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg==} - engines: {node: '>=0.10.0'} - dev: false - /pirates/4.0.4: resolution: {integrity: sha512-ZIrVPH+A52Dw84R0L3/VS9Op04PuQ2SEoJL6bkshmiTic/HldyW9Tf7oH5mhJZBK7NmDx27vSMrYEXPXclpDKw==} engines: {node: '>= 6'} @@ -12757,6 +12694,7 @@ packages: /process-nextick-args/2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + dev: true /process/0.11.10: resolution: {integrity: sha1-czIwDoQBYb2j5podHZGn1LwW8YI=} @@ -13379,6 +13317,7 @@ packages: safe-buffer: 5.1.2 string_decoder: 1.1.1 util-deprecate: 1.0.2 + dev: true /readable-stream/3.6.0: resolution: {integrity: sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==} @@ -13728,6 +13667,7 @@ packages: /safe-buffer/5.1.2: resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + dev: true /safe-buffer/5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -13764,13 +13704,6 @@ packages: loose-envify: 1.4.0 dev: true - /seek-bzip/1.0.6: - resolution: {integrity: sha512-e1QtP3YL5tWww8uKaOCQ18UxIT2laNBXHjV/S2WYCiK4udiv8lkG89KRIoCjUagnAmCBurjF4zEVX2ByBbnCjQ==} - hasBin: true - dependencies: - commander: 2.20.3 - dev: false - /semver-compare/1.0.0: resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==} @@ -13795,7 +13728,6 @@ packages: hasBin: true dependencies: lru-cache: 6.0.0 - dev: true /serialize-error/7.0.1: resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} @@ -14348,6 +14280,7 @@ packages: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} dependencies: safe-buffer: 5.1.2 + dev: true /string_decoder/1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} @@ -14391,12 +14324,6 @@ packages: engines: {node: '>=8'} dev: true - /strip-dirs/2.1.0: - resolution: {integrity: sha512-JOCxOeKLm2CAS73y/U4ZeZPTkE+gNVCzKt7Eox84Iej1LT/2pTWYpZKJuxwQpvX1LiZb1xokNR7RLfuBAa7T3g==} - dependencies: - is-natural-number: 4.0.1 - dev: false - /strip-final-newline/2.0.0: resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} engines: {node: '>=6'} @@ -14700,19 +14627,6 @@ packages: tar-stream: 2.2.0 dev: true - /tar-stream/1.6.2: - resolution: {integrity: sha512-rzS0heiNf8Xn7/mpdSVVSMAWAoy9bfb1WOTYC78Z0UQKeKa/CWS8FOq0lKGNa8DWKAn9gxjCvMLYc5PGXYlK2A==} - engines: {node: '>= 0.8.0'} - dependencies: - bl: 1.2.3 - buffer-alloc: 1.2.0 - end-of-stream: 1.4.4 - fs-constants: 1.0.0 - readable-stream: 2.3.7 - to-buffer: 1.1.1 - xtend: 4.0.2 - dev: false - /tar-stream/2.2.0: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} @@ -14730,7 +14644,7 @@ packages: dependencies: chownr: 2.0.0 fs-minipass: 2.1.0 - minipass: 3.1.6 + minipass: 3.3.5 minizlib: 2.1.2 mkdirp: 1.0.4 yallist: 4.0.0 @@ -14828,10 +14742,6 @@ packages: resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} dev: true - /to-buffer/1.1.1: - resolution: {integrity: sha512-lx9B5iv7msuFYE3dytT+KE5tap+rNYw+K4jVkb9R/asAb+pbBSM17jtunHplhBe6RRJdZx3Pn2Jph24O32mOVg==} - dev: false - /to-fast-properties/2.0.0: resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} engines: {node: '>=4'} @@ -15002,7 +14912,7 @@ packages: '@tsconfig/node14': 1.0.1 '@tsconfig/node16': 1.0.2 '@types/node': 16.11.12 - acorn: 8.7.1 + acorn: 8.8.0 acorn-walk: 8.2.0 arg: 4.1.3 create-require: 1.1.1 @@ -15161,6 +15071,7 @@ packages: dependencies: buffer: 5.7.1 through: 2.3.8 + dev: true /undefsafe/2.0.5: resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==} @@ -15506,7 +15417,7 @@ packages: dev: true /wcwidth/1.0.1: - resolution: {integrity: sha1-8LDc+RW8X/FSivrbLA4XtTLaL+g=} + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} dependencies: defaults: 1.0.3 @@ -15803,6 +15714,7 @@ packages: dependencies: buffer-crc32: 0.2.13 fd-slicer: 1.1.0 + dev: true /ylru/1.2.1: resolution: {integrity: sha512-faQrqNMzcPCHGVC2aaOINk13K+aaBDUPjGWl0teOXywElLjyVAB6Oe2jj62jHYtwsU49jXhScYbvPENK+6zAvQ==} From 50e5e5fa22b933add1d3e4790898a807f7bf80df Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Mon, 3 Oct 2022 19:00:44 +0800 Subject: [PATCH 21/54] refactor(cli): support proxy --- packages/cli/package.json | 5 +++-- packages/cli/src/utilities.ts | 7 ++++++- pnpm-lock.yaml | 7 +++++++ 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index be4e299c4..57fa62b20 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,11 +1,12 @@ { "name": "@logto/cli", - "version": "1.0.0-beta.9", + "version": "1.0.0-beta.10", "description": "Logto CLI.", "author": "Silverhand Inc. ", "homepage": "https://github.com/logto-io/logto#readme", "license": "MPL-2.0", "main": "lib/index.js", + "bin": "lib/index.js", "files": [ "lib" ], @@ -13,7 +14,6 @@ "type": "git", "url": "git+https://github.com/logto-io/logto.git" }, - "bin": "lib/index.js", "scripts": { "precommit": "lint-staged", "build": "rimraf lib && tsc", @@ -32,6 +32,7 @@ "dependencies": { "chalk": "^4.1.2", "got": "^11.8.2", + "hpagent": "^1.0.0", "ora": "^5.0.0", "prompts": "^2.4.2", "semver": "^7.3.7", diff --git a/packages/cli/src/utilities.ts b/packages/cli/src/utilities.ts index eee72be13..f5e60060d 100644 --- a/packages/cli/src/utilities.ts +++ b/packages/cli/src/utilities.ts @@ -3,6 +3,7 @@ import { createWriteStream } from 'fs'; import chalk from 'chalk'; import got, { Progress } from 'got'; +import { HttpsProxyAgent } from 'hpagent'; import ora from 'ora'; export const safeExecSync = (command: string) => { @@ -32,8 +33,12 @@ export const log: Log = Object.freeze({ }); export const downloadFile = async (url: string, destination: string) => { + const { HTTPS_PROXY, HTTP_PROXY, https_proxy, http_proxy } = process.env; const file = createWriteStream(destination); - const stream = got.stream(url); + const proxy = HTTPS_PROXY ?? https_proxy ?? HTTP_PROXY ?? http_proxy; + const stream = got.stream(url, { + ...(proxy && { agent: { https: new HttpsProxyAgent({ proxy }) } }), + }); const spinner = ora({ text: 'Connecting', prefixText: chalk.blue('[info]'), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 299ca13b7..415f161ab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,6 +30,7 @@ importers: chalk: ^4.1.2 eslint: ^8.21.0 got: ^11.8.2 + hpagent: ^1.0.0 lint-staged: ^13.0.0 ora: ^5.0.0 prettier: ^2.7.1 @@ -42,6 +43,7 @@ importers: dependencies: chalk: 4.1.2 got: 11.8.3 + hpagent: 1.0.0 ora: 5.4.1 prompts: 2.4.2 semver: 7.3.7 @@ -8110,6 +8112,11 @@ packages: lru-cache: 7.10.1 dev: true + /hpagent/1.0.0: + resolution: {integrity: sha512-SCleE2Uc1bM752ymxg8QXYGW0TWtAV4ZW3TqH1aOnyi6T6YW2xadCcclm5qeVjvMvfQ2RKNtZxO7uVb9CTPt1A==} + engines: {node: '>=14'} + dev: false + /html-encoding-sniffer/3.0.0: resolution: {integrity: sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==} engines: {node: '>=12'} From 8a0a46380d2cd15d78d3e6bc847c34f4cf40f37c Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Mon, 3 Oct 2022 20:27:06 +0800 Subject: [PATCH 22/54] refactor(cli): add bin file --- packages/cli/bin/logto | 2 ++ packages/cli/package.json | 6 +++++- 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100755 packages/cli/bin/logto diff --git a/packages/cli/bin/logto b/packages/cli/bin/logto new file mode 100755 index 000000000..527eddf67 --- /dev/null +++ b/packages/cli/bin/logto @@ -0,0 +1,2 @@ +#!/usr/bin/env node +require('../lib/index.js'); diff --git a/packages/cli/package.json b/packages/cli/package.json index 57fa62b20..df2c04f04 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -6,8 +6,12 @@ "homepage": "https://github.com/logto-io/logto#readme", "license": "MPL-2.0", "main": "lib/index.js", - "bin": "lib/index.js", + "bin": { + "logto": "bin/logto", + "lg": "bin/logto" + }, "files": [ + "bin", "lib" ], "repository": { From f05691b4319279a49bf0bc87ba656b7990d52e53 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Tue, 4 Oct 2022 13:45:27 +0800 Subject: [PATCH 23/54] feat(cli): command `init/i/install` --- packages/cli/package.json | 8 +- packages/cli/src/commands/install.ts | 125 ++++++++++++++++++++++ packages/cli/src/index.ts | 152 +++++---------------------- pnpm-lock.yaml | 53 +++++++--- 4 files changed, 198 insertions(+), 140 deletions(-) create mode 100644 packages/cli/src/commands/install.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index df2c04f04..56516eee8 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -37,19 +37,21 @@ "chalk": "^4.1.2", "got": "^11.8.2", "hpagent": "^1.0.0", + "inquirer": "^8.2.2", "ora": "^5.0.0", - "prompts": "^2.4.2", "semver": "^7.3.7", - "tar": "^6.1.11" + "tar": "^6.1.11", + "yargs": "^17.6.0" }, "devDependencies": { "@silverhand/eslint-config": "1.0.0", "@silverhand/ts-config": "1.0.0", "@types/decompress": "^4.2.4", + "@types/inquirer": "^8.2.1", "@types/node": "^16.0.0", - "@types/prompts": "^2.0.14", "@types/semver": "^7.3.12", "@types/tar": "^6.1.2", + "@types/yargs": "^17.0.13", "eslint": "^8.21.0", "lint-staged": "^13.0.0", "prettier": "^2.7.1", diff --git a/packages/cli/src/commands/install.ts b/packages/cli/src/commands/install.ts new file mode 100644 index 000000000..04602d718 --- /dev/null +++ b/packages/cli/src/commands/install.ts @@ -0,0 +1,125 @@ +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 inquirer from 'inquirer'; +import ora from 'ora'; +import * as semver from 'semver'; +import tar from 'tar'; + +import { downloadFile, log, safeExecSync } from '../utilities'; + +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 validatePath = (value: string) => + existsSync(path.resolve(value)) + ? `The path ${chalk.green(value)} already exists, please try another.` + : true; + +const getInstancePath = 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 { 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, + }); + + return instancePath; +}; + +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) => { + const decompressSpinner = ora({ + text: `Decompress to ${toPath}`, + prefixText: chalk.blue('[info]'), + }).start(); + + try { + await mkdir(toPath); + await tar.extract({ file: tarPath, cwd: toPath, strip: 1 }); + } catch (error: unknown) { + decompressSpinner.fail(); + log.error(error); + + return; + } + + decompressSpinner.succeed(); +}; + +const install = async ({ path: pathArgument = defaultPath, silent = false }: InstallArgs) => { + validateNodeVersion(); + + const instancePath = (!silent && (await getInstancePath())) || pathArgument; + const isValidPath = validatePath(instancePath); + + if (isValidPath !== true) { + log.error(isValidPath); + } + + const tarPath = await downloadRelease(); + + await decompress(instancePath, tarPath); + + const startCommand = `cd ${instancePath} && npm start`; + log.info( + `Use the command below to start Logto. Happy hacking!\n\n ${chalk.green(startCommand)}` + ); +}; + +export default install; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 1f3f6f4f9..7ddb8b74a 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,130 +1,32 @@ -import { execSync } from 'child_process'; -import { existsSync } from 'fs'; -import { mkdir } from 'fs/promises'; -import os from 'os'; -import path from 'path'; +import yargs from 'yargs'; +import { hideBin } from 'yargs/helpers'; -import chalk from 'chalk'; -import ora from 'ora'; -import * as prompts from 'prompts'; -import * as semver from 'semver'; -import tar from 'tar'; +import install from './commands/install'; -import { downloadFile, log, safeExecSync } from './utilities'; - -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 getInstancePath = async () => { - const response = await prompts.default( - [ - { - name: 'instancePath', - message: 'Where should we create your logto instance?', - type: 'text', - initial: './logto', - format: (value: string) => path.resolve(value.trim()), - validate: (value: string) => - existsSync(value) ? 'That path already exists, please try another.' : true, - }, - { - 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: () => { - 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) && 'confirm'; - }, - format: (previous) => { - if (!previous) { - log.error('Logto requires a Postgres instance to run.'); - } - }, - }, - ], +void yargs(hideBin(process.argv)) + .command( + ['init', 'i', 'install'], + 'Download and run the latest Logto release', { - onCancel: () => { - log.error('Operation cancelled'); + 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', + }, + }, + async ({ path, silent }) => { + await install({ path, silent }); } - ); - - return String(response.instancePath); -}; - -const tryStartInstance = async (instancePath: string) => { - const response = await prompts.default({ - name: 'startInstance', - message: 'Would you like to start Logto now?', - type: 'confirm', - initial: true, - }); - - const yes = Boolean(response.startInstance); - const startCommand = `cd ${instancePath} && npm start`; - - if (yes) { - execSync(startCommand, { stdio: 'inherit' }); - } else { - log.info(`You can use ${startCommand} to start Logto. Happy hacking!`); - } -}; - -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) => { - const decompressSpinner = ora({ - text: `Decompress to ${toPath}`, - prefixText: chalk.blue('[info]'), - }).start(); - - try { - await mkdir(toPath); - await tar.extract({ file: tarPath, cwd: toPath, strip: 1 }); - } catch { - decompressSpinner.fail(); - - return; - } - - decompressSpinner.succeed(); -}; - -const main = async () => { - validateNodeVersion(); - - const instancePath = await getInstancePath(); - const tarPath = await downloadRelease(); - - await decompress(instancePath, tarPath); - await tryStartInstance(instancePath); -}; - -void main(); + ) + .demandCommand(1) + .showHelpOnFail(true) + .strict() + .parserConfiguration({ + 'dot-notation': false, + }) + .parse(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 415f161ab..79e6f73c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,39 +23,43 @@ importers: '@silverhand/eslint-config': 1.0.0 '@silverhand/ts-config': 1.0.0 '@types/decompress': ^4.2.4 + '@types/inquirer': ^8.2.1 '@types/node': ^16.0.0 - '@types/prompts': ^2.0.14 '@types/semver': ^7.3.12 '@types/tar': ^6.1.2 + '@types/yargs': ^17.0.13 chalk: ^4.1.2 eslint: ^8.21.0 got: ^11.8.2 hpagent: ^1.0.0 + inquirer: ^8.2.2 lint-staged: ^13.0.0 ora: ^5.0.0 prettier: ^2.7.1 - prompts: ^2.4.2 rimraf: ^3.0.2 semver: ^7.3.7 tar: ^6.1.11 ts-node: ^10.9.1 typescript: ^4.7.4 + yargs: ^17.6.0 dependencies: chalk: 4.1.2 got: 11.8.3 hpagent: 1.0.0 + inquirer: 8.2.2 ora: 5.4.1 - prompts: 2.4.2 semver: 7.3.7 tar: 6.1.11 + yargs: 17.6.0 devDependencies: '@silverhand/eslint-config': 1.0.0_swk2g7ygmfleszo5c33j4vooni '@silverhand/ts-config': 1.0.0_typescript@4.7.4 '@types/decompress': 4.2.4 + '@types/inquirer': 8.2.1 '@types/node': 16.11.12 - '@types/prompts': 2.0.14 '@types/semver': 7.3.12 '@types/tar': 6.1.2 + '@types/yargs': 17.0.13 eslint: 8.21.0 lint-staged: 13.0.0 prettier: 2.7.1 @@ -4556,12 +4560,6 @@ packages: resolution: {integrity: sha512-ekoj4qOQYp7CvjX8ZDBgN86w3MqQhLE1hczEJbEIjgFEumDy+na/4AJAbLXfgEWFNB2pKadM5rPFtuSGMWK7xA==} dev: true - /@types/prompts/2.0.14: - resolution: {integrity: sha512-HZBd99fKxRWpYCErtm2/yxUZv6/PBI9J7N4TNFffl5JbrYMHBwF25DjQGTW3b3jmXq+9P6/8fCIb2ee57BFfYA==} - dependencies: - '@types/node': 17.0.23 - dev: true - /@types/prop-types/15.7.4: resolution: {integrity: sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ==} dev: true @@ -4706,6 +4704,12 @@ packages: '@types/yargs-parser': 20.2.1 dev: true + /@types/yargs/17.0.13: + resolution: {integrity: sha512-9sWaruZk2JGxIQU+IhI1fhPYRcQ0UuTNuKuCW9bR5fp7qi2Llf7WDzNa17Cy7TKnh3cdxDOiyTu6gaLS0eDatg==} + dependencies: + '@types/yargs-parser': 20.2.1 + dev: true + /@types/yauzl/2.10.0: resolution: {integrity: sha512-Cn6WYCm0tXv8p6k+A8PvbDG763EDpBoTzHdA+Q/MF6H3sapGjCm9NzoaJncJS9tUKSuCoDs9XHxYYsQDgxR6kw==} requiresBuild: true @@ -5701,6 +5705,15 @@ packages: string-width: 4.2.3 strip-ansi: 6.0.1 wrap-ansi: 7.0.0 + dev: true + + /cliui/8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 /clone-deep/0.2.4: resolution: {integrity: sha512-we+NuQo2DHhSl+DP6jlUiAhyAjBQrYnpOk15rN6c6JSPScjiCLh8IbSU+VTcph6YS3o7mASE8a0+gbZ7ChLpgg==} @@ -9959,6 +9972,7 @@ packages: /kleur/3.0.3: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} + dev: true /kleur/4.1.4: resolution: {integrity: sha512-8QADVssbrFjivHWQU7KkMgptGTl6WAcSdlbBPY4uNF+mWr6DGcKrvY2w4FQJoXch7+fKMjj0dRrL75vk3k23OA==} @@ -12279,7 +12293,7 @@ packages: hasBin: true dependencies: shell-quote: 1.7.3 - yargs: 17.4.1 + yargs: 17.6.0 /pg-int8/1.0.1: resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} @@ -12739,6 +12753,7 @@ packages: dependencies: kleur: 3.0.3 sisteransi: 1.0.5 + dev: true /promzard/0.3.0: resolution: {integrity: sha1-JqXW7ox97kyxIggwWs+5O6OCqe4=} @@ -13510,7 +13525,7 @@ packages: dev: true /require-directory/2.1.1: - resolution: {integrity: sha1-jGStX9MNqxyXbiNE/+f3kqam30I=} + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} /require-from-string/2.0.2: @@ -13816,6 +13831,7 @@ packages: /sisteransi/1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + dev: true /slash/3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} @@ -15715,6 +15731,19 @@ packages: string-width: 4.2.3 y18n: 5.0.8 yargs-parser: 21.0.1 + dev: true + + /yargs/17.6.0: + resolution: {integrity: sha512-8H/wTDqlSwoSnScvV2N/JHfLWOKuh5MVla9hqLjK3nsfyy6Y4kDSYSvkU5YCUEPOSnRXfIyx3Sq+B/IWudTo4g==} + engines: {node: '>=12'} + dependencies: + cliui: 8.0.1 + escalade: 3.1.1 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.0.1 /yauzl/2.10.0: resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} From 0eb306a61cf88b8be3be86852cb66b1d99ad713f Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Wed, 5 Oct 2022 02:30:37 +0800 Subject: [PATCH 24/54] feat(cli): database config command --- packages/cli/package.json | 4 ++- packages/cli/src/commands/database/index.ts | 13 ++++++++ packages/cli/src/commands/database/url.ts | 12 ++++++++ packages/cli/src/commands/install.ts | 24 ++++++++++++++- packages/cli/src/index.ts | 22 ++----------- packages/cli/src/utilities.ts | 34 +++++++++++++++++++++ pnpm-lock.yaml | 10 +++--- 7 files changed, 92 insertions(+), 27 deletions(-) create mode 100644 packages/cli/src/commands/database/index.ts create mode 100644 packages/cli/src/commands/database/url.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index 56516eee8..08cac6beb 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -35,13 +35,15 @@ }, "dependencies": { "chalk": "^4.1.2", + "find-up": "^5.0.0", "got": "^11.8.2", "hpagent": "^1.0.0", "inquirer": "^8.2.2", "ora": "^5.0.0", "semver": "^7.3.7", "tar": "^6.1.11", - "yargs": "^17.6.0" + "yargs": "^17.6.0", + "zod": "^3.18.0" }, "devDependencies": { "@silverhand/eslint-config": "1.0.0", diff --git a/packages/cli/src/commands/database/index.ts b/packages/cli/src/commands/database/index.ts new file mode 100644 index 000000000..5622a095a --- /dev/null +++ b/packages/cli/src/commands/database/index.ts @@ -0,0 +1,13 @@ +import { CommandModule } from 'yargs'; + +import { noop } from '../../utilities'; +import { getUrl } from './url'; + +const database: CommandModule = { + command: ['database ', 'db'], + describe: 'Commands for Logto database', + builder: (yargs) => yargs.command(getUrl), + handler: noop, +}; + +export default database; diff --git a/packages/cli/src/commands/database/url.ts b/packages/cli/src/commands/database/url.ts new file mode 100644 index 000000000..5f35fd2fc --- /dev/null +++ b/packages/cli/src/commands/database/url.ts @@ -0,0 +1,12 @@ +import { CommandModule } from 'yargs'; + +import { getConfig } from '../../utilities'; + +export const getUrl: CommandModule = { + command: 'get-url', + describe: 'Get database URL in Logto config file', + handler: async () => { + const { databaseUrl } = await getConfig(); + console.log(databaseUrl); + }, +}; diff --git a/packages/cli/src/commands/install.ts b/packages/cli/src/commands/install.ts index 04602d718..7d95d9be4 100644 --- a/packages/cli/src/commands/install.ts +++ b/packages/cli/src/commands/install.ts @@ -9,6 +9,7 @@ import inquirer from 'inquirer'; import ora from 'ora'; import * as semver from 'semver'; import tar from 'tar'; +import { CommandModule } from 'yargs'; import { downloadFile, log, safeExecSync } from '../utilities'; @@ -102,7 +103,7 @@ const decompress = async (toPath: string, tarPath: string) => { decompressSpinner.succeed(); }; -const install = async ({ path: pathArgument = defaultPath, silent = false }: InstallArgs) => { +const installLogto = async ({ path: pathArgument = defaultPath, silent = false }: InstallArgs) => { validateNodeVersion(); const instancePath = (!silent && (await getInstancePath())) || pathArgument; @@ -122,4 +123,25 @@ const install = async ({ path: pathArgument = defaultPath, silent = false }: Ins ); }; +const install: CommandModule, { 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; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 7ddb8b74a..4628e575f 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,28 +1,12 @@ import yargs from 'yargs'; import { hideBin } from 'yargs/helpers'; +import database from './commands/database'; import install from './commands/install'; void yargs(hideBin(process.argv)) - .command( - ['init', 'i', 'install'], - 'Download and run the latest Logto release', - { - 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', - }, - }, - async ({ path, silent }) => { - await install({ path, silent }); - } - ) + .command(install) + .command(database) .demandCommand(1) .showHelpOnFail(true) .strict() diff --git a/packages/cli/src/utilities.ts b/packages/cli/src/utilities.ts index f5e60060d..13d86abc4 100644 --- a/packages/cli/src/utilities.ts +++ b/packages/cli/src/utilities.ts @@ -1,10 +1,16 @@ import { execSync } from 'child_process'; import { createWriteStream } from 'fs'; +import { readFile } from 'fs/promises'; +import os from 'os'; +import path from 'path'; import chalk from 'chalk'; +import findUp from 'find-up'; import got, { Progress } from 'got'; import { HttpsProxyAgent } from 'hpagent'; import ora from 'ora'; +// eslint-disable-next-line id-length +import z from 'zod'; export const safeExecSync = (command: string) => { try { @@ -68,3 +74,31 @@ export const downloadFile = async (url: string, destination: string) => { }); }); }; + +// Intended +// eslint-disable-next-line @typescript-eslint/no-empty-function +export const noop = () => {}; + +// Logto config +const logtoConfig = '.logto.json'; + +const getConfigJson = async () => { + const configPath = (await findUp(logtoConfig)) ?? path.join(os.homedir(), logtoConfig); + + try { + const raw = await readFile(configPath, 'utf8'); + + // Prefer `unknown` over the original return type `any`, will guard later + // eslint-disable-next-line no-restricted-syntax + return JSON.parse(raw) as unknown; + } catch {} +}; + +export const getConfig = async () => { + return z + .object({ + databaseUrl: z.string().optional(), + }) + .default({}) + .parse(await getConfigJson()); +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 79e6f73c7..99e6ae017 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,6 +30,7 @@ importers: '@types/yargs': ^17.0.13 chalk: ^4.1.2 eslint: ^8.21.0 + find-up: ^5.0.0 got: ^11.8.2 hpagent: ^1.0.0 inquirer: ^8.2.2 @@ -42,8 +43,10 @@ importers: ts-node: ^10.9.1 typescript: ^4.7.4 yargs: ^17.6.0 + zod: ^3.18.0 dependencies: chalk: 4.1.2 + find-up: 5.0.0 got: 11.8.3 hpagent: 1.0.0 inquirer: 8.2.2 @@ -51,6 +54,7 @@ importers: semver: 7.3.7 tar: 6.1.11 yargs: 17.6.0 + zod: 3.18.0 devDependencies: '@silverhand/eslint-config': 1.0.0_swk2g7ygmfleszo5c33j4vooni '@silverhand/ts-config': 1.0.0_typescript@4.7.4 @@ -7493,7 +7497,6 @@ packages: dependencies: locate-path: 6.0.0 path-exists: 4.0.0 - dev: true /flat-cache/3.0.4: resolution: {integrity: sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==} @@ -10290,7 +10293,6 @@ packages: engines: {node: '>=10'} dependencies: p-locate: 5.0.0 - dev: true /lodash._reinterpolate/3.0.0: resolution: {integrity: sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0=} @@ -11920,7 +11922,6 @@ packages: engines: {node: '>=10'} dependencies: yocto-queue: 0.1.0 - dev: true /p-locate/2.0.0: resolution: {integrity: sha512-nQja7m7gSKuewoVRen45CtVfODR3crN3goVQ0DDZ9N3yHxgpkuBhZqsaiotSQRrADUrne346peY7kT3TSACykg==} @@ -11941,7 +11942,6 @@ packages: engines: {node: '>=10'} dependencies: p-limit: 3.1.0 - dev: true /p-map-series/2.1.0: resolution: {integrity: sha512-RpYIIK1zXSNEOdwxcfe7FdvGcs7+y5n8rifMhMNWvaxRNMPINJHF5GDeuVxWqnfrcHPSCnp7Oo5yNXHId9Av2Q==} @@ -12205,7 +12205,6 @@ packages: /path-exists/4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} - dev: true /path-is-absolute/1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} @@ -15764,7 +15763,6 @@ packages: /yocto-queue/0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - dev: true /zod/3.18.0: resolution: {integrity: sha512-gwTm8RfUCe8l9rDwN5r2A17DkAa8Ez4Yl4yXqc5VqeGaXaJahzYYXbTwvhroZi0SNBqTwh/bKm2N0mpCzuw4bA==} From 880d07ebf758bffcd0f84ebd8aeeec93bcf7298e Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Wed, 5 Oct 2022 15:21:37 +0800 Subject: [PATCH 25/54] refactor(cli): add `database set-url` command --- packages/cli/src/commands/database/index.ts | 6 +-- packages/cli/src/commands/database/url.ts | 16 +++++++- packages/cli/src/config.ts | 45 +++++++++++++++++++++ packages/cli/src/utilities.ts | 30 -------------- 4 files changed, 63 insertions(+), 34 deletions(-) create mode 100644 packages/cli/src/config.ts diff --git a/packages/cli/src/commands/database/index.ts b/packages/cli/src/commands/database/index.ts index 5622a095a..c81899e46 100644 --- a/packages/cli/src/commands/database/index.ts +++ b/packages/cli/src/commands/database/index.ts @@ -1,12 +1,12 @@ import { CommandModule } from 'yargs'; import { noop } from '../../utilities'; -import { getUrl } from './url'; +import { getUrl, setUrl } from './url'; const database: CommandModule = { - command: ['database ', 'db'], + command: ['database', 'db'], describe: 'Commands for Logto database', - builder: (yargs) => yargs.command(getUrl), + builder: (yargs) => yargs.command(getUrl).command(setUrl).strict(), handler: noop, }; diff --git a/packages/cli/src/commands/database/url.ts b/packages/cli/src/commands/database/url.ts index 5f35fd2fc..4d9751fa4 100644 --- a/packages/cli/src/commands/database/url.ts +++ b/packages/cli/src/commands/database/url.ts @@ -1,6 +1,6 @@ import { CommandModule } from 'yargs'; -import { getConfig } from '../../utilities'; +import { getConfig, patchConfig } from '../../config'; export const getUrl: CommandModule = { command: 'get-url', @@ -10,3 +10,17 @@ export const getUrl: CommandModule = { console.log(databaseUrl); }, }; + +export const setUrl: CommandModule, { url: string }> = { + command: 'set-url ', + describe: 'Set database URL and save to config file', + builder: (yargs) => + yargs.positional('url', { + describe: 'The database URL (DSN) to use, including database name', + type: 'string', + demandOption: true, + }), + handler: async (argv) => { + await patchConfig({ databaseUrl: String(argv.url) }); + }, +}; diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts new file mode 100644 index 000000000..e784a93ee --- /dev/null +++ b/packages/cli/src/config.ts @@ -0,0 +1,45 @@ +import { readFile, writeFile } from 'fs/promises'; +import os from 'os'; +import path from 'path'; + +import chalk from 'chalk'; +import findUp from 'find-up'; +// eslint-disable-next-line id-length +import z from 'zod'; + +import { log } from './utilities'; + +// Logto config +const logtoConfigFilename = '.logto.json'; +const getConfigPath = async () => + (await findUp(logtoConfigFilename)) ?? path.join(os.homedir(), logtoConfigFilename); + +const getConfigJson = async () => { + const configPath = await getConfigPath(); + + try { + const raw = await readFile(configPath, 'utf8'); + + // Prefer `unknown` over the original return type `any`, will guard later + // eslint-disable-next-line no-restricted-syntax + return JSON.parse(raw) as unknown; + } catch {} +}; + +const configGuard = z + .object({ + databaseUrl: z.string().optional(), + }) + .default({}); + +type LogtoConfig = z.infer; + +export const getConfig = async () => { + return configGuard.parse(await getConfigJson()); +}; + +export const patchConfig = async (config: LogtoConfig) => { + const configPath = await getConfigPath(); + await writeFile(configPath, JSON.stringify({ ...(await getConfig()), ...config }, undefined, 2)); + log.info(`Updated config in ${chalk.green(configPath)}`); +}; diff --git a/packages/cli/src/utilities.ts b/packages/cli/src/utilities.ts index 13d86abc4..aa8f47206 100644 --- a/packages/cli/src/utilities.ts +++ b/packages/cli/src/utilities.ts @@ -1,16 +1,10 @@ import { execSync } from 'child_process'; import { createWriteStream } from 'fs'; -import { readFile } from 'fs/promises'; -import os from 'os'; -import path from 'path'; import chalk from 'chalk'; -import findUp from 'find-up'; import got, { Progress } from 'got'; import { HttpsProxyAgent } from 'hpagent'; import ora from 'ora'; -// eslint-disable-next-line id-length -import z from 'zod'; export const safeExecSync = (command: string) => { try { @@ -78,27 +72,3 @@ export const downloadFile = async (url: string, destination: string) => { // Intended // eslint-disable-next-line @typescript-eslint/no-empty-function export const noop = () => {}; - -// Logto config -const logtoConfig = '.logto.json'; - -const getConfigJson = async () => { - const configPath = (await findUp(logtoConfig)) ?? path.join(os.homedir(), logtoConfig); - - try { - const raw = await readFile(configPath, 'utf8'); - - // Prefer `unknown` over the original return type `any`, will guard later - // eslint-disable-next-line no-restricted-syntax - return JSON.parse(raw) as unknown; - } catch {} -}; - -export const getConfig = async () => { - return z - .object({ - databaseUrl: z.string().optional(), - }) - .default({}) - .parse(await getConfigJson()); -}; From 0eff1e3591129802f3e9b3286652ef6fc8619cf5 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Wed, 5 Oct 2022 22:46:52 +0800 Subject: [PATCH 26/54] feat(cli): get/set db config key --- packages/cli/package.json | 5 +- packages/cli/src/commands/database/index.ts | 4 +- packages/cli/src/commands/database/key.ts | 96 +++++++++++++++++++ packages/cli/src/commands/database/url.ts | 2 +- packages/cli/src/commands/install.ts | 2 +- packages/cli/src/database.ts | 39 ++++++++ .../include.d/slonik-interceptor-preset.d.ts | 10 ++ packages/cli/src/index.ts | 2 +- packages/cli/src/queries/logto-config.ts | 26 +++++ packages/cli/src/utilities.ts | 4 +- packages/core/src/alteration/constants.ts | 4 +- packages/core/src/alteration/index.test.ts | 2 +- packages/core/src/alteration/index.ts | 10 +- packages/schemas/src/types/alteration.ts | 8 -- packages/schemas/src/types/index.ts | 1 + packages/schemas/src/types/logto-config.ts | 30 ++++++ pnpm-lock.yaml | 56 ++++++++++- 17 files changed, 277 insertions(+), 24 deletions(-) create mode 100644 packages/cli/src/commands/database/key.ts create mode 100644 packages/cli/src/database.ts create mode 100644 packages/cli/src/include.d/slonik-interceptor-preset.d.ts create mode 100644 packages/cli/src/queries/logto-config.ts create mode 100644 packages/schemas/src/types/logto-config.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index 08cac6beb..7f04774b8 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -22,7 +22,7 @@ "precommit": "lint-staged", "build": "rimraf lib && tsc", "start": "node .", - "dev": "ts-node src/index.ts", + "dev": "ts-node --files src/index.ts", "lint": "eslint --ext .ts src", "lint:report": "pnpm lint --format json --output-file report.json", "prepack": "pnpm build" @@ -34,6 +34,7 @@ "url": "https://github.com/logto-io/logto/issues" }, "dependencies": { + "@logto/schemas": "^1.0.0-beta.10", "chalk": "^4.1.2", "find-up": "^5.0.0", "got": "^11.8.2", @@ -41,6 +42,8 @@ "inquirer": "^8.2.2", "ora": "^5.0.0", "semver": "^7.3.7", + "slonik": "^30.0.0", + "slonik-interceptor-preset": "^1.2.10", "tar": "^6.1.11", "yargs": "^17.6.0", "zod": "^3.18.0" diff --git a/packages/cli/src/commands/database/index.ts b/packages/cli/src/commands/database/index.ts index c81899e46..dced88bc0 100644 --- a/packages/cli/src/commands/database/index.ts +++ b/packages/cli/src/commands/database/index.ts @@ -1,12 +1,14 @@ import { CommandModule } from 'yargs'; import { noop } from '../../utilities'; +import { getKey, setKey } from './key'; import { getUrl, setUrl } from './url'; const database: CommandModule = { command: ['database', 'db'], describe: 'Commands for Logto database', - builder: (yargs) => yargs.command(getUrl).command(setUrl).strict(), + builder: (yargs) => + yargs.command(getUrl).command(setUrl).command(getKey).command(setKey).demandCommand(1), handler: noop, }; diff --git a/packages/cli/src/commands/database/key.ts b/packages/cli/src/commands/database/key.ts new file mode 100644 index 000000000..ebf82f716 --- /dev/null +++ b/packages/cli/src/commands/database/key.ts @@ -0,0 +1,96 @@ +import { logtoConfigGuards, LogtoConfigKey, logtoConfigKeys } from '@logto/schemas'; +import chalk from 'chalk'; +import { CommandModule } from 'yargs'; + +import { createPoolFromConfig } from '../../database'; +import { getRowsByKeys, updateValueByKey } from '../../queries/logto-config'; +import { deduplicate, log } from '../../utilities'; + +const validKeysDisplay = chalk.green(logtoConfigKeys.join(', ')); + +type ValidateKeysFunction = { + (keys: string[]): asserts keys is LogtoConfigKey[]; + (key: string): asserts key is LogtoConfigKey; +}; + +const validateKeys: ValidateKeysFunction = (keys) => { + const invalidKey = (Array.isArray(keys) ? keys : [keys]).find( + // Using `.includes()` will result a type error + // eslint-disable-next-line unicorn/prefer-includes + (key) => !logtoConfigKeys.some((element) => element === key) + ); + + if (invalidKey) { + log.error( + `Invalid config key ${chalk.red(invalidKey)} found, expected one of ${validKeysDisplay}` + ); + } +}; + +export const getKey: CommandModule = { + command: 'get-key [keys...]', + describe: 'Get config value(s) of the given key(s) in Logto database', + builder: (yargs) => + yargs + .positional('key', { + describe: `The key to get from database, one of ${validKeysDisplay}`, + type: 'string', + demandOption: true, + }) + .positional('keys', { + describe: 'The additional keys to get from database', + type: 'string', + array: true, + default: [], + }), + handler: async ({ key, keys }) => { + const queryKeys = deduplicate([key, ...keys]); + validateKeys(queryKeys); + + const pool = await createPoolFromConfig(); + const { rows } = await getRowsByKeys(pool, queryKeys); + await pool.end(); + + console.log( + queryKeys + .map((currentKey) => { + const value = rows.find(({ key }) => currentKey === key)?.value; + + return ( + chalk.magenta(currentKey) + + '=' + + (value === undefined ? chalk.gray(value) : chalk.green(JSON.stringify(value))) + ); + }) + .join('\n') + ); + }, +}; + +export const setKey: CommandModule = { + command: 'set-key ', + describe: 'Set config value of the given key in Logto database', + builder: (yargs) => + yargs + .positional('key', { + describe: `The key to get from database, one of ${validKeysDisplay}`, + type: 'string', + demandOption: true, + }) + .positional('value', { + describe: 'The value to set, should be a valid JSON string', + type: 'string', + demandOption: true, + }), + handler: async ({ key, value }) => { + validateKeys(key); + + const guarded = logtoConfigGuards[key].parse(JSON.parse(value)); + + const pool = await createPoolFromConfig(); + await updateValueByKey(pool, key, guarded); + await pool.end(); + + log.info(`Update ${chalk.green(key)} succeeded`); + }, +}; diff --git a/packages/cli/src/commands/database/url.ts b/packages/cli/src/commands/database/url.ts index 4d9751fa4..f9ee95a18 100644 --- a/packages/cli/src/commands/database/url.ts +++ b/packages/cli/src/commands/database/url.ts @@ -11,7 +11,7 @@ export const getUrl: CommandModule = { }, }; -export const setUrl: CommandModule, { url: string }> = { +export const setUrl: CommandModule = { command: 'set-url ', describe: 'Set database URL and save to config file', builder: (yargs) => diff --git a/packages/cli/src/commands/install.ts b/packages/cli/src/commands/install.ts index 7d95d9be4..6c8a6d6e5 100644 --- a/packages/cli/src/commands/install.ts +++ b/packages/cli/src/commands/install.ts @@ -123,7 +123,7 @@ const installLogto = async ({ path: pathArgument = defaultPath, silent = false } ); }; -const install: CommandModule, { path?: string; silent?: boolean }> = { +const install: CommandModule = { command: ['init', 'i', 'install'], describe: 'Download and run the latest Logto release', builder: (yargs) => diff --git a/packages/cli/src/database.ts b/packages/cli/src/database.ts new file mode 100644 index 000000000..72661780d --- /dev/null +++ b/packages/cli/src/database.ts @@ -0,0 +1,39 @@ +import chalk from 'chalk'; +import { createPool, IdentifierSqlToken, sql } from 'slonik'; +import { createInterceptors } from 'slonik-interceptor-preset'; + +import { getConfig } from './config'; +import { log } from './utilities'; + +export const createPoolFromConfig = async () => { + const { databaseUrl } = await getConfig(); + + if (!databaseUrl) { + log.error( + `No database URL configured. Set one via ${chalk.green('database set-url')} command first.` + ); + } + + return createPool(databaseUrl, { + interceptors: createInterceptors(), + }); +}; + +// TODO: Move database utils to `core-kit` +export type Table = { table: string; fields: Record }; +export type FieldIdentifiers = { + [key in Key]: IdentifierSqlToken; +}; + +export const convertToIdentifiers = ({ table, fields }: T, withPrefix = false) => { + const fieldsIdentifiers = Object.entries(fields).map< + [keyof T['fields'], IdentifierSqlToken] + >(([key, value]) => [key, sql.identifier(withPrefix ? [table, value] : [value])]); + + return { + table: sql.identifier([table]), + // Key value inferred from the original fields directly + // eslint-disable-next-line no-restricted-syntax + fields: Object.fromEntries(fieldsIdentifiers) as FieldIdentifiers, + }; +}; diff --git a/packages/cli/src/include.d/slonik-interceptor-preset.d.ts b/packages/cli/src/include.d/slonik-interceptor-preset.d.ts new file mode 100644 index 000000000..5e24372aa --- /dev/null +++ b/packages/cli/src/include.d/slonik-interceptor-preset.d.ts @@ -0,0 +1,10 @@ +declare module 'slonik-interceptor-preset' { + import { Interceptor } from 'slonik'; + + export const createInterceptors: (config?: { + benchmarkQueries: boolean; + logQueries: boolean; + normaliseQueries: boolean; + transformFieldNames: boolean; + }) => readonly Interceptor[]; +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 4628e575f..e25fd5f84 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -8,7 +8,7 @@ void yargs(hideBin(process.argv)) .command(install) .command(database) .demandCommand(1) - .showHelpOnFail(true) + .showHelpOnFail(false) .strict() .parserConfiguration({ 'dot-notation': false, diff --git a/packages/cli/src/queries/logto-config.ts b/packages/cli/src/queries/logto-config.ts new file mode 100644 index 000000000..ae3553a8a --- /dev/null +++ b/packages/cli/src/queries/logto-config.ts @@ -0,0 +1,26 @@ +import { LogtoConfig, logtoConfigGuards, LogtoConfigKey, LogtoConfigs } from '@logto/schemas'; +import { DatabasePool, sql } from 'slonik'; +import { z } from 'zod'; + +import { convertToIdentifiers } from '../database'; + +const { table, fields } = convertToIdentifiers(LogtoConfigs); + +export const getRowsByKeys = async (pool: DatabasePool, keys: LogtoConfigKey[]) => + pool.query(sql` + select ${sql.join([fields.key, fields.value], sql`,`)} from ${table} + where ${fields.key} in (${sql.join(keys, sql`,`)}) + `); + +export const updateValueByKey = async ( + pool: DatabasePool, + key: T, + value: z.infer +) => + pool.query( + sql` + insert into ${table} (${fields.key}, ${fields.value}) + values (${key}, ${sql.jsonb(value)}) + on conflict (${fields.key}) do update set ${fields.value}=excluded.${fields.value} + ` + ); diff --git a/packages/cli/src/utilities.ts b/packages/cli/src/utilities.ts index aa8f47206..ba79fed67 100644 --- a/packages/cli/src/utilities.ts +++ b/packages/cli/src/utilities.ts @@ -15,7 +15,7 @@ export const safeExecSync = (command: string) => { type Log = Readonly<{ info: typeof console.log; warn: typeof console.log; - error: typeof console.log; + error: (...args: Parameters) => never; }>; export const log: Log = Object.freeze({ @@ -72,3 +72,5 @@ export const downloadFile = async (url: string, destination: string) => { // Intended // eslint-disable-next-line @typescript-eslint/no-empty-function export const noop = () => {}; + +export const deduplicate = (array: T[]) => [...new Set(array)]; diff --git a/packages/core/src/alteration/constants.ts b/packages/core/src/alteration/constants.ts index 4c419c472..76d18ba7d 100644 --- a/packages/core/src/alteration/constants.ts +++ b/packages/core/src/alteration/constants.ts @@ -1,4 +1,6 @@ -export const alterationStateKey = 'alterationState'; +import { LogtoConfigKey } from '@logto/schemas'; + +export const alterationStateKey: LogtoConfigKey = 'alterationState'; export const logtoConfigsTableFilePath = 'node_modules/@logto/schemas/tables/logto_configs.sql'; export const alterationFilesDirectorySource = 'node_modules/@logto/schemas/alterations'; export const alterationFilesDirectory = 'alterations/'; diff --git a/packages/core/src/alteration/index.test.ts b/packages/core/src/alteration/index.test.ts index bbc7432c4..6098fe329 100644 --- a/packages/core/src/alteration/index.test.ts +++ b/packages/core/src/alteration/index.test.ts @@ -115,7 +115,7 @@ describe('createLogtoConfigsTable()', () => { describe('updateDatabaseTimestamp()', () => { const expectSql = sql` insert into ${table} (${fields.key}, ${fields.value}) - values ($1, $2) + values ($1, $2::jsonb) on conflict (${fields.key}) do update set ${fields.value}=excluded.${fields.value} `; const updatedAt = '2022-09-21T06:32:46.583Z'; diff --git a/packages/core/src/alteration/index.ts b/packages/core/src/alteration/index.ts index ae6382ae2..e593faaf0 100644 --- a/packages/core/src/alteration/index.ts +++ b/packages/core/src/alteration/index.ts @@ -2,12 +2,8 @@ import { existsSync } from 'fs'; import { readdir, readFile } from 'fs/promises'; import path from 'path'; -import { LogtoConfig, LogtoConfigs } from '@logto/schemas'; -import { - AlterationScript, - AlterationState, - alterationStateGuard, -} from '@logto/schemas/lib/types/alteration'; +import { LogtoConfig, LogtoConfigs, AlterationState, alterationStateGuard } from '@logto/schemas'; +import { AlterationScript } from '@logto/schemas/lib/types/alteration'; import { conditionalString } from '@silverhand/essentials'; import chalk from 'chalk'; import { copy, remove } from 'fs-extra'; @@ -70,7 +66,7 @@ export const updateDatabaseTimestamp = async (pool: DatabasePool, timestamp?: nu await pool.query( sql` insert into ${table} (${fields.key}, ${fields.value}) - values (${alterationStateKey}, ${JSON.stringify(value)}) + values (${alterationStateKey}, ${sql.jsonb(value)}) on conflict (${fields.key}) do update set ${fields.value}=excluded.${fields.value} ` ); diff --git a/packages/schemas/src/types/alteration.ts b/packages/schemas/src/types/alteration.ts index 70c189ed0..7439b3eda 100644 --- a/packages/schemas/src/types/alteration.ts +++ b/packages/schemas/src/types/alteration.ts @@ -1,12 +1,4 @@ import type { DatabaseTransactionConnection } from 'slonik'; -import { z } from 'zod'; - -export const alterationStateGuard = z.object({ - timestamp: z.number(), - updatedAt: z.string().optional(), -}); - -export type AlterationState = z.infer; export type AlterationScript = { up: (connection: DatabaseTransactionConnection) => Promise; diff --git a/packages/schemas/src/types/index.ts b/packages/schemas/src/types/index.ts index c22dd0bc7..9bb6fd003 100644 --- a/packages/schemas/src/types/index.ts +++ b/packages/schemas/src/types/index.ts @@ -2,3 +2,4 @@ export * from './connector'; export * from './log'; export * from './oidc-config'; export * from './user'; +export * from './logto-config'; diff --git a/packages/schemas/src/types/logto-config.ts b/packages/schemas/src/types/logto-config.ts new file mode 100644 index 000000000..2cdff3f17 --- /dev/null +++ b/packages/schemas/src/types/logto-config.ts @@ -0,0 +1,30 @@ +import { z } from 'zod'; + +// Alteration state +export const alterationStateGuard = z.object({ + timestamp: z.number(), + updatedAt: z.string().optional(), +}); + +export type AlterationState = z.infer; + +// Logto OIDC config +export const logtoOidcConfigGuard = z.object({ + privateKeys: z.string().array().optional(), + cookieKeys: z.string().array().optional(), + refreshTokenReuseInterval: z.number().gte(3).optional(), +}); + +export type LogtoOidcConfig = z.infer; + +// Summary +export const logtoConfigGuards = Object.freeze({ + alterationState: alterationStateGuard, + oidcConfig: logtoOidcConfigGuard, +} as const); + +export type LogtoConfigKey = keyof typeof logtoConfigGuards; + +// `as` is intended since we'd like to keep `logtoConfigGuards` as the SSOT of keys +// eslint-disable-next-line no-restricted-syntax +export const logtoConfigKeys = Object.keys(logtoConfigGuards) as LogtoConfigKey[]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 99e6ae017..343cb6c9d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,7 @@ importers: packages/cli: specifiers: + '@logto/schemas': ^1.0.0-beta.10 '@silverhand/eslint-config': 1.0.0 '@silverhand/ts-config': 1.0.0 '@types/decompress': ^4.2.4 @@ -39,12 +40,15 @@ importers: prettier: ^2.7.1 rimraf: ^3.0.2 semver: ^7.3.7 + slonik: ^30.0.0 + slonik-interceptor-preset: ^1.2.10 tar: ^6.1.11 ts-node: ^10.9.1 typescript: ^4.7.4 yargs: ^17.6.0 zod: ^3.18.0 dependencies: + '@logto/schemas': link:../schemas chalk: 4.1.2 find-up: 5.0.0 got: 11.8.3 @@ -52,6 +56,8 @@ importers: inquirer: 8.2.2 ora: 5.4.1 semver: 7.3.7 + slonik: 30.1.2 + slonik-interceptor-preset: 1.2.10 tar: 6.1.11 yargs: 17.6.0 zod: 3.18.0 @@ -1730,6 +1736,7 @@ packages: pacote: 13.4.1 semver: 7.3.7 transitivePeerDependencies: + - bluebird - supports-color dev: true @@ -1760,6 +1767,7 @@ packages: p-waterfall: 2.1.1 semver: 7.3.7 transitivePeerDependencies: + - bluebird - supports-color dev: true @@ -1900,6 +1908,7 @@ packages: whatwg-url: 8.7.0 yargs-parser: 20.2.4 transitivePeerDependencies: + - bluebird - supports-color dev: true @@ -2097,6 +2106,7 @@ packages: npm-registry-fetch: 9.0.0 npmlog: 4.1.2 transitivePeerDependencies: + - bluebird - supports-color dev: true @@ -2126,6 +2136,7 @@ packages: pify: 5.0.0 read-package-json: 3.0.1 transitivePeerDependencies: + - bluebird - supports-color dev: true @@ -2164,6 +2175,7 @@ packages: npmlog: 4.1.2 tar: 6.1.11 transitivePeerDependencies: + - bluebird - supports-color dev: true @@ -2262,6 +2274,7 @@ packages: pacote: 13.4.1 semver: 7.3.7 transitivePeerDependencies: + - bluebird - encoding - supports-color dev: true @@ -2307,6 +2320,7 @@ packages: '@npmcli/run-script': 3.0.2 npmlog: 4.1.2 transitivePeerDependencies: + - bluebird - supports-color dev: true @@ -2408,6 +2422,7 @@ packages: slash: 3.0.0 write-json-file: 4.3.0 transitivePeerDependencies: + - bluebird - encoding - supports-color dev: true @@ -2663,6 +2678,7 @@ packages: treeverse: 2.0.0 walk-up-path: 1.0.0 transitivePeerDependencies: + - bluebird - supports-color dev: true @@ -2698,6 +2714,8 @@ packages: promise-retry: 2.0.1 semver: 7.3.7 which: 2.0.2 + transitivePeerDependencies: + - bluebird dev: true /@npmcli/installed-package-contents/1.0.7: @@ -2728,6 +2746,7 @@ packages: pacote: 13.4.1 semver: 7.3.7 transitivePeerDependencies: + - bluebird - supports-color dev: true @@ -2779,6 +2798,7 @@ packages: node-gyp: 9.0.0 read-package-json-fast: 2.0.3 transitivePeerDependencies: + - bluebird - supports-color dev: true @@ -3831,6 +3851,7 @@ packages: stylelint-config-xo-scss: 0.15.0_eqpuutlgonckfyjzwkrpusdvaa transitivePeerDependencies: - eslint + - eslint-import-resolver-webpack - postcss - prettier - supports-color @@ -3851,6 +3872,7 @@ packages: stylelint-config-xo-scss: 0.15.0_uyk3cwxn3favstz4untq233szu transitivePeerDependencies: - eslint + - eslint-import-resolver-webpack - postcss - prettier - supports-color @@ -5463,6 +5485,8 @@ packages: ssri: 8.0.1 tar: 6.1.11 unique-filename: 1.1.1 + transitivePeerDependencies: + - bluebird dev: true /cacache/16.1.0: @@ -5487,6 +5511,8 @@ packages: ssri: 9.0.1 tar: 6.1.11 unique-filename: 1.1.1 + transitivePeerDependencies: + - bluebird dev: true /cache-content-type/1.0.1: @@ -6319,6 +6345,18 @@ packages: ms: 2.1.3 dev: true + /debug/3.2.7_supports-color@5.5.0: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + dependencies: + ms: 2.1.3 + supports-color: 5.5.0 + dev: true + /debug/4.3.3: resolution: {integrity: sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q==} engines: {node: '>=6.0'} @@ -10126,6 +10164,7 @@ packages: import-local: 3.1.0 npmlog: 4.1.2 transitivePeerDependencies: + - bluebird - encoding - supports-color dev: true @@ -10160,6 +10199,7 @@ packages: npm-package-arg: 8.1.5 npm-registry-fetch: 11.0.0 transitivePeerDependencies: + - bluebird - supports-color dev: true @@ -10173,6 +10213,7 @@ packages: semver: 7.3.7 ssri: 8.0.1 transitivePeerDependencies: + - bluebird - supports-color dev: true @@ -10474,6 +10515,7 @@ packages: socks-proxy-agent: 6.1.1 ssri: 9.0.1 transitivePeerDependencies: + - bluebird - supports-color dev: true @@ -10497,6 +10539,7 @@ packages: socks-proxy-agent: 5.0.1 ssri: 8.0.1 transitivePeerDependencies: + - bluebird - supports-color dev: true @@ -10521,6 +10564,7 @@ packages: socks-proxy-agent: 6.1.1 ssri: 8.0.1 transitivePeerDependencies: + - bluebird - supports-color dev: true @@ -11421,6 +11465,7 @@ packages: tar: 6.1.11 which: 2.0.2 transitivePeerDependencies: + - bluebird - supports-color dev: true @@ -11455,7 +11500,7 @@ packages: requiresBuild: true dependencies: chokidar: 3.5.3 - debug: 3.2.7 + debug: 3.2.7_supports-color@5.5.0 ignore-by-default: 1.0.1 minimatch: 3.1.2 pstree.remy: 1.1.8 @@ -11604,6 +11649,7 @@ packages: minizlib: 2.1.2 npm-package-arg: 8.1.5 transitivePeerDependencies: + - bluebird - supports-color dev: true @@ -11619,6 +11665,7 @@ packages: npm-package-arg: 9.0.2 proc-log: 2.0.1 transitivePeerDependencies: + - bluebird - supports-color dev: true @@ -11635,6 +11682,7 @@ packages: minizlib: 2.1.2 npm-package-arg: 8.1.5 transitivePeerDependencies: + - bluebird - supports-color dev: true @@ -12035,6 +12083,7 @@ packages: ssri: 9.0.1 tar: 6.1.11 transitivePeerDependencies: + - bluebird - supports-color dev: true @@ -12736,6 +12785,11 @@ packages: /promise-inflight/1.0.1: resolution: {integrity: sha1-mEcocL8igTL8vdhoEputEsPAKeM=} + peerDependencies: + bluebird: '*' + peerDependenciesMeta: + bluebird: + optional: true dev: true /promise-retry/2.0.1: From 441e9e92620c3f017ab0ea2b095a4286072534ec Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Thu, 6 Oct 2022 17:34:21 +0800 Subject: [PATCH 27/54] chore: fix package scripts (#2044) --- package.json | 4 ++-- packages/core/package.json | 2 +- packages/schemas/package.json | 3 ++- packages/ui/package.json | 2 +- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 7a1901dce..7e25a31ce 100644 --- a/package.json +++ b/package.json @@ -9,13 +9,13 @@ "bootstrap": "lerna bootstrap", "prepare": "if test \"$NODE_ENV\" != \"production\" && test \"$CI\" != \"true\" ; then husky install ; fi", "prepack": "lerna run --stream prepack", - "dev": "lerna run --stream prepack -- --incremental && lerna --ignore=@logto/integration-test run --parallel dev", + "dev": "lerna run --stream prepack -- --incremental && lerna --ignore=@logto/integration-tests run --parallel dev", "start": "cd packages/core && NODE_ENV=production node . --from-root", "alteration": "cd packages/core && pnpm alteration", "ci:build": "lerna run --stream build", "ci:lint": "lerna run --parallel lint", "ci:stylelint": "lerna run --parallel stylelint", - "ci:test": "lerna run --parallel test:coverage" + "ci:test": "lerna run --parallel test:ci" }, "devDependencies": { "@commitlint/cli": "^17.0.0", diff --git a/packages/core/package.json b/packages/core/package.json index 140d2504b..89a1821c7 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -18,7 +18,7 @@ "add-official-connectors": "node build/cli/add-official-connectors.js", "alteration": "node build/cli/alteration.js", "test": "jest", - "test:coverage": "jest --coverage --silent", + "test:ci": "jest --coverage --silent", "test:report": "codecov -F core" }, "dependencies": { diff --git a/packages/schemas/package.json b/packages/schemas/package.json index 9db181dd1..c185c0c33 100644 --- a/packages/schemas/package.json +++ b/packages/schemas/package.json @@ -19,7 +19,8 @@ "lint": "eslint --ext .ts src", "lint:report": "pnpm lint --format json --output-file report.json", "prepack": "pnpm build", - "test": "jest" + "test": "jest", + "test:ci": "jest" }, "engines": { "node": "^16.0.0" diff --git a/packages/ui/package.json b/packages/ui/package.json index 6be0d40e3..2b31f0475 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -12,7 +12,7 @@ "lint": "eslint --ext .ts --ext .tsx src", "lint:report": "pnpm lint --format json --output-file report.json", "stylelint": "stylelint \"src/**/*.scss\"", - "test:coverage": "jest --coverage --silent", + "test:ci": "jest --coverage --silent", "test": "jest" }, "devDependencies": { From 5c7000ddc30e316bd17f34d71d51c17016efec76 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Fri, 7 Oct 2022 00:31:35 +0800 Subject: [PATCH 28/54] feat(cli): `db seed` command --- packages/cli/package.json | 12 +- packages/cli/src/commands/database/index.ts | 9 +- packages/cli/src/commands/database/seed.ts | 133 ++++++++++++++++++++ packages/cli/src/database.ts | 72 ++++++++++- packages/cli/src/utilities.ts | 8 ++ pnpm-lock.yaml | 8 ++ 6 files changed, 237 insertions(+), 5 deletions(-) create mode 100644 packages/cli/src/commands/database/seed.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index 7f04774b8..fd93001a6 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -36,14 +36,18 @@ "dependencies": { "@logto/schemas": "^1.0.0-beta.10", "chalk": "^4.1.2", + "decamelize": "^5.0.0", "find-up": "^5.0.0", "got": "^11.8.2", "hpagent": "^1.0.0", "inquirer": "^8.2.2", + "nanoid": "^3.3.4", "ora": "^5.0.0", + "roarr": "^7.11.0", "semver": "^7.3.7", "slonik": "^30.0.0", "slonik-interceptor-preset": "^1.2.10", + "slonik-sql-tag-raw": "^1.1.4", "tar": "^6.1.11", "yargs": "^17.6.0", "zod": "^3.18.0" @@ -65,7 +69,13 @@ "typescript": "^4.7.4" }, "eslintConfig": { - "extends": "@silverhand" + "extends": "@silverhand", + "rules": { + "complexity": [ + "error", + 7 + ] + } }, "prettier": "@silverhand/eslint-config/.prettierrc" } diff --git a/packages/cli/src/commands/database/index.ts b/packages/cli/src/commands/database/index.ts index dced88bc0..721c8dfe7 100644 --- a/packages/cli/src/commands/database/index.ts +++ b/packages/cli/src/commands/database/index.ts @@ -2,13 +2,20 @@ import { CommandModule } from 'yargs'; import { noop } from '../../utilities'; import { getKey, setKey } from './key'; +import seed from './seed'; import { getUrl, setUrl } from './url'; const database: CommandModule = { command: ['database', 'db'], describe: 'Commands for Logto database', builder: (yargs) => - yargs.command(getUrl).command(setUrl).command(getKey).command(setKey).demandCommand(1), + yargs + .command(getUrl) + .command(setUrl) + .command(getKey) + .command(setKey) + .command(seed) + .demandCommand(1), handler: noop, }; diff --git a/packages/cli/src/commands/database/seed.ts b/packages/cli/src/commands/database/seed.ts new file mode 100644 index 000000000..091e48885 --- /dev/null +++ b/packages/cli/src/commands/database/seed.ts @@ -0,0 +1,133 @@ +import { readdir, readFile } from 'fs/promises'; +import path from 'path'; + +import { seeds } from '@logto/schemas'; +import { + createPool, + DatabasePool, + DatabaseTransactionConnection, + parseDsn, + sql, + stringifyDsn, +} from 'slonik'; +import { raw } from 'slonik-sql-tag-raw'; +import { CommandModule } from 'yargs'; +import { z } from 'zod'; + +import { createPoolFromConfig, getDatabaseUrlFromConfig, insertInto } from '../../database'; +import { buildApplicationSecret, log } from '../../utilities'; + +/** + * Create a database pool with the database URL in config. + * 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. + */ +const createDatabasePool = async () => { + try { + return await createPoolFromConfig(); + } catch (error: unknown) { + const result = z.object({ code: z.string() }).safeParse(error); + + // Database does not exist, try to create one + // https://www.postgresql.org/docs/14/errcodes-appendix.html + if (!(result.success && result.data.code === '3D000')) { + log.error(error); + } + + 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 + // - It will throw error when creating database using '?' + const databaseName = dsn.databaseName ?? '?'; + const maintenancePool = await createPool(stringifyDsn({ ...dsn, databaseName: 'postgres' })); + await maintenancePool.query(sql` + create database ${sql.identifier([databaseName])} + with + encoding = 'UTF8' + connection_limit = -1; + `); + await maintenancePool.end(); + + log.info(`Database ${databaseName} successfully created.`); + + return createPoolFromConfig(); + } +}; + +const createTables = async (connection: DatabaseTransactionConnection) => { + // https://stackoverflow.com/a/49455609/12514940 + const tableDirectory = path.join( + // Until we migrate to ESM + // eslint-disable-next-line unicorn/prefer-module + path.dirname(require.resolve('@logto/schemas/package.json')), + 'tables' + ); + const directoryFiles = await readdir(tableDirectory); + const tableFiles = directoryFiles.filter((file) => file.endsWith('.sql')); + const queries = await Promise.all( + tableFiles.map>(async (file) => [ + file, + await readFile(path.join(tableDirectory, file), 'utf8'), + ]) + ); + + // Await in loop is intended for better error handling + for (const [file, query] of queries) { + // eslint-disable-next-line no-await-in-loop + await connection.query(sql`${raw(query)}`); + log.info(`Run ${file} succeeded.`); + } +}; + +const seedTables = async (connection: DatabaseTransactionConnection) => { + const { + managementResource, + defaultSignInExperience, + createDefaultSetting, + createDemoAppApplication, + defaultRole, + } = seeds; + + await Promise.all([ + connection.query(insertInto(managementResource, 'resources')), + connection.query(insertInto(createDefaultSetting(), 'settings')), + connection.query(insertInto(defaultSignInExperience, 'sign_in_experiences')), + connection.query( + insertInto(createDemoAppApplication(buildApplicationSecret()), 'applications') + ), + connection.query(insertInto(defaultRole, 'roles')), + ]); + log.info('Seed tables succeeded.'); +}; + +export const seedByPool = async (pool: DatabasePool) => { + await pool.transaction(async (connection) => { + await createTables(connection); + await seedTables(connection); + }); +}; + +const seed: CommandModule = { + command: 'seed', + describe: 'Create database and seed tables and data', + handler: async () => { + const pool = await createDatabasePool(); + + try { + await seedByPool(pool); + } catch (error: unknown) { + console.error(error); + console.log(); + log.warn( + 'Error ocurred during seeding your database.\n\n' + + ' Nothing has changed since the seeding process was in a transaction.\n' + + ' Try to fix the error and seed again.' + ); + } + await pool.end(); + }, +}; + +export default seed; diff --git a/packages/cli/src/database.ts b/packages/cli/src/database.ts index 72661780d..9ca907b74 100644 --- a/packages/cli/src/database.ts +++ b/packages/cli/src/database.ts @@ -1,19 +1,27 @@ +import { SchemaLike, SchemaValue, SchemaValuePrimitive } from '@logto/schemas'; import chalk from 'chalk'; -import { createPool, IdentifierSqlToken, sql } from 'slonik'; +import decamelize from 'decamelize'; +import { createPool, IdentifierSqlToken, sql, SqlToken } from 'slonik'; import { createInterceptors } from 'slonik-interceptor-preset'; import { getConfig } from './config'; import { log } from './utilities'; -export const createPoolFromConfig = async () => { +export const getDatabaseUrlFromConfig = async () => { const { databaseUrl } = await getConfig(); if (!databaseUrl) { log.error( - `No database URL configured. Set one via ${chalk.green('database set-url')} command first.` + `No database URL configured. Set it via ${chalk.green('database set-url')} command first.` ); } + return databaseUrl; +}; + +export const createPoolFromConfig = async () => { + const databaseUrl = await getDatabaseUrlFromConfig(); + return createPool(databaseUrl, { interceptors: createInterceptors(), }); @@ -37,3 +45,61 @@ export const convertToIdentifiers = ({ table, fields }: T, with fields: Object.fromEntries(fieldsIdentifiers) as FieldIdentifiers, }; }; + +/** + * Note `undefined` is removed from the acceptable list, + * since you should NOT call this function if ignoring the field is the desired behavior. + * Calling this function with `null` means an explicit `null` setting in database is expected. + * @param key The key of value. Will treat as `timestamp` if it ends with `_at` or 'At' AND value is a number; + * @param value The value to convert. + * @returns A primitive that can be saved into database. + */ +// eslint-disable-next-line complexity +export const convertToPrimitiveOrSql = ( + key: string, + // eslint-disable-next-line @typescript-eslint/ban-types + value: NonNullable | null + // eslint-disable-next-line @typescript-eslint/ban-types +): NonNullable | SqlToken | null => { + if (value === null) { + return null; + } + + if (typeof value === 'object') { + return JSON.stringify(value); + } + + if (['_at', 'At'].some((value) => key.endsWith(value)) && typeof value === 'number') { + return sql`to_timestamp(${value}::double precision / 1000)`; + } + + if (typeof value === 'number' || typeof value === 'boolean') { + return value; + } + + if (typeof value === 'string') { + if (value === '') { + return null; + } + + return value; + } + + throw new Error(`Cannot convert ${key} to primitive`); +}; + +export const insertInto = (object: T, table: string) => { + const keys = Object.keys(object); + + return sql` + insert into ${sql.identifier([table])} + (${sql.join( + keys.map((key) => sql.identifier([decamelize(key)])), + sql`, ` + )}) + values (${sql.join( + keys.map((key) => convertToPrimitiveOrSql(key, object[key] ?? null)), + sql`, ` + )}) + `; +}; diff --git a/packages/cli/src/utilities.ts b/packages/cli/src/utilities.ts index ba79fed67..28c8c800a 100644 --- a/packages/cli/src/utilities.ts +++ b/packages/cli/src/utilities.ts @@ -4,6 +4,7 @@ import { createWriteStream } from 'fs'; import chalk from 'chalk'; import got, { Progress } from 'got'; import { HttpsProxyAgent } from 'hpagent'; +import { customAlphabet } from 'nanoid'; import ora from 'ora'; export const safeExecSync = (command: string) => { @@ -69,8 +70,15 @@ export const downloadFile = async (url: string, destination: string) => { }); }; +// TODO: Move to `@silverhand/essentials` // Intended // eslint-disable-next-line @typescript-eslint/no-empty-function export const noop = () => {}; export const deduplicate = (array: T[]) => [...new Set(array)]; + +export const alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; + +export const buildIdGenerator = (size: number) => customAlphabet(alphabet, size); + +export const buildApplicationSecret = buildIdGenerator(21); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 343cb6c9d..2213350ae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,18 +30,22 @@ importers: '@types/tar': ^6.1.2 '@types/yargs': ^17.0.13 chalk: ^4.1.2 + decamelize: ^5.0.0 eslint: ^8.21.0 find-up: ^5.0.0 got: ^11.8.2 hpagent: ^1.0.0 inquirer: ^8.2.2 lint-staged: ^13.0.0 + nanoid: ^3.3.4 ora: ^5.0.0 prettier: ^2.7.1 rimraf: ^3.0.2 + roarr: ^7.11.0 semver: ^7.3.7 slonik: ^30.0.0 slonik-interceptor-preset: ^1.2.10 + slonik-sql-tag-raw: ^1.1.4 tar: ^6.1.11 ts-node: ^10.9.1 typescript: ^4.7.4 @@ -50,14 +54,18 @@ importers: dependencies: '@logto/schemas': link:../schemas chalk: 4.1.2 + decamelize: 5.0.1 find-up: 5.0.0 got: 11.8.3 hpagent: 1.0.0 inquirer: 8.2.2 + nanoid: 3.3.4 ora: 5.4.1 + roarr: 7.11.0 semver: 7.3.7 slonik: 30.1.2 slonik-interceptor-preset: 1.2.10 + slonik-sql-tag-raw: 1.1.4_roarr@7.11.0+slonik@30.1.2 tar: 6.1.11 yargs: 17.6.0 zod: 3.18.0 From 2c159a06d26837be37910d76b17338f2e0ac9882 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Fri, 7 Oct 2022 00:37:39 +0800 Subject: [PATCH 29/54] refactor(cli): add todo for alteration state --- packages/cli/src/commands/database/seed.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/cli/src/commands/database/seed.ts b/packages/cli/src/commands/database/seed.ts index 091e48885..4188e2b1f 100644 --- a/packages/cli/src/commands/database/seed.ts +++ b/packages/cli/src/commands/database/seed.ts @@ -90,6 +90,8 @@ const seedTables = async (connection: DatabaseTransactionConnection) => { defaultRole, } = seeds; + // TODO: update database alteration timestamp when migrate alteration process from core + await Promise.all([ connection.query(insertInto(managementResource, 'resources')), connection.query(insertInto(createDefaultSetting(), 'settings')), From a5280a2afd3d5822e78d1f115ab6f6fdbb993261 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Fri, 7 Oct 2022 19:48:17 +0800 Subject: [PATCH 30/54] feat(cli): `db alteration deploy` command --- packages/cli/.gitignore | 1 + packages/cli/package.json | 3 + .../cli/src/commands/database/alteration.ts | 134 ++++++++++++++++++ packages/cli/src/commands/database/index.ts | 2 + packages/cli/src/commands/database/seed.ts | 15 +- packages/cli/src/queries/logto-config.ts | 46 +++++- packages/cli/src/utilities.ts | 10 ++ packages/cli/tsconfig.json | 3 +- .../1.0.0_beta.10-1-logto-config.ts | 20 +++ packages/schemas/src/types/logto-config.ts | 17 +-- pnpm-lock.yaml | 6 + 11 files changed, 235 insertions(+), 22 deletions(-) create mode 100644 packages/cli/.gitignore create mode 100644 packages/cli/src/commands/database/alteration.ts create mode 100644 packages/schemas/alterations/1.0.0_beta.10-1-logto-config.ts diff --git a/packages/cli/.gitignore b/packages/cli/.gitignore new file mode 100644 index 000000000..3e3a1fa6e --- /dev/null +++ b/packages/cli/.gitignore @@ -0,0 +1 @@ +alteration-scripts/ diff --git a/packages/cli/package.json b/packages/cli/package.json index fd93001a6..a48154548 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -35,9 +35,11 @@ }, "dependencies": { "@logto/schemas": "^1.0.0-beta.10", + "@silverhand/essentials": "^1.2.1", "chalk": "^4.1.2", "decamelize": "^5.0.0", "find-up": "^5.0.0", + "fs-extra": "^10.1.0", "got": "^11.8.2", "hpagent": "^1.0.0", "inquirer": "^8.2.2", @@ -56,6 +58,7 @@ "@silverhand/eslint-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/node": "^16.0.0", "@types/semver": "^7.3.12", diff --git a/packages/cli/src/commands/database/alteration.ts b/packages/cli/src/commands/database/alteration.ts new file mode 100644 index 000000000..b0a7d3af3 --- /dev/null +++ b/packages/cli/src/commands/database/alteration.ts @@ -0,0 +1,134 @@ +import path from 'path'; + +import { AlterationScript } from '@logto/schemas/lib/types/alteration'; +import { conditionalString } from '@silverhand/essentials'; +import chalk from 'chalk'; +import { copy, existsSync, remove, readdir } from 'fs-extra'; +import { DatabasePool } from 'slonik'; +import { CommandModule } from 'yargs'; + +import { createPoolFromConfig } from '../../database'; +import { + getCurrentDatabaseAlterationTimestamp, + updateDatabaseTimestamp, +} from '../../queries/logto-config'; +import { getPathInModule, log } from '../../utilities'; + +const alterationFileNameRegex = /-(\d+)-?.*\.js$/; + +const getTimestampFromFileName = (fileName: string) => { + const match = alterationFileNameRegex.exec(fileName); + + if (!match?.[1]) { + throw new Error(`Can not get timestamp: ${fileName}`); + } + + return Number(match[1]); +}; + +const importAlterationScript = async (filePath: string): Promise => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const module = await import(filePath); + + // eslint-disable-next-line no-restricted-syntax + return module.default as AlterationScript; +}; + +type AlterationFile = { path: string; filename: string }; + +const getAlterationFiles = async (): Promise => { + const alterationDirectory = getPathInModule('@logto/schemas', 'alterations'); + // Until we migrate to ESM + // eslint-disable-next-line unicorn/prefer-module + const localAlterationDirectory = path.resolve(__dirname, './alteration-scripts'); + + if (!existsSync(alterationDirectory)) { + return []; + } + + // We need to copy alteration files to execute in the CLI context to make `slonik` available + await remove(localAlterationDirectory); + await copy(alterationDirectory, localAlterationDirectory); + + const directory = await readdir(localAlterationDirectory); + const files = directory.filter((file) => alterationFileNameRegex.test(file)); + + return files + .slice() + .sort((file1, file2) => getTimestampFromFileName(file1) - getTimestampFromFileName(file2)) + .map((filename) => ({ path: path.join(localAlterationDirectory, filename), filename })); +}; + +export const getLatestAlterationTimestamp = async () => { + const files = await getAlterationFiles(); + const lastFile = files[files.length - 1]; + + if (!lastFile) { + return 0; + } + + return getTimestampFromFileName(lastFile.filename); +}; + +const getUndeployedAlterations = async (pool: DatabasePool) => { + const databaseTimestamp = await getCurrentDatabaseAlterationTimestamp(pool); + const files = await getAlterationFiles(); + + return files.filter(({ filename }) => getTimestampFromFileName(filename) > databaseTimestamp); +}; + +const deployAlteration = async ( + pool: DatabasePool, + { path: filePath, filename }: AlterationFile +) => { + const { up } = await importAlterationScript(filePath); + + try { + await pool.transaction(async (connection) => { + await up(connection); + await updateDatabaseTimestamp(connection, getTimestampFromFileName(filename)); + }); + } catch (error: unknown) { + console.error(error); + + await pool.end(); + log.error( + `Error ocurred during running alteration ${chalk.green(filename)}.\n\n` + + " This alteration didn't change anything since it was in a transaction.\n" + + ' Try to fix the error and deploy again.' + ); + } + + log.info(`Run alteration ${filename} succeeded`); +}; + +const alteration: CommandModule = { + command: ['alteration ', '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 () => { + const pool = await createPoolFromConfig(); + const alterations = await getUndeployedAlterations(pool); + + log.info( + `Found ${alterations.length} alteration${conditionalString( + alterations.length > 1 && 's' + )} to deploy` + ); + + // The await inside the loop is intended, alterations should run in order + for (const alteration of alterations) { + // eslint-disable-next-line no-await-in-loop + await deployAlteration(pool, alteration); + } + + await pool.end(); + }, +}; + +export default alteration; diff --git a/packages/cli/src/commands/database/index.ts b/packages/cli/src/commands/database/index.ts index 721c8dfe7..f7da167e9 100644 --- a/packages/cli/src/commands/database/index.ts +++ b/packages/cli/src/commands/database/index.ts @@ -1,6 +1,7 @@ import { CommandModule } from 'yargs'; import { noop } from '../../utilities'; +import alteration from './alteration'; import { getKey, setKey } from './key'; import seed from './seed'; import { getUrl, setUrl } from './url'; @@ -15,6 +16,7 @@ const database: CommandModule = { .command(getKey) .command(setKey) .command(seed) + .command(alteration) .demandCommand(1), handler: noop, }; diff --git a/packages/cli/src/commands/database/seed.ts b/packages/cli/src/commands/database/seed.ts index 4188e2b1f..5ae8985dd 100644 --- a/packages/cli/src/commands/database/seed.ts +++ b/packages/cli/src/commands/database/seed.ts @@ -15,7 +15,9 @@ import { CommandModule } from 'yargs'; import { z } from 'zod'; import { createPoolFromConfig, getDatabaseUrlFromConfig, insertInto } from '../../database'; -import { buildApplicationSecret, log } from '../../utilities'; +import { updateDatabaseTimestamp } from '../../queries/logto-config'; +import { buildApplicationSecret, getPathInModule, log } from '../../utilities'; +import { getLatestAlterationTimestamp } from './alteration'; /** * Create a database pool with the database URL in config. @@ -57,13 +59,7 @@ const createDatabasePool = async () => { }; const createTables = async (connection: DatabaseTransactionConnection) => { - // https://stackoverflow.com/a/49455609/12514940 - const tableDirectory = path.join( - // Until we migrate to ESM - // eslint-disable-next-line unicorn/prefer-module - path.dirname(require.resolve('@logto/schemas/package.json')), - 'tables' - ); + const tableDirectory = getPathInModule('@logto/schemas', 'tables'); const directoryFiles = await readdir(tableDirectory); const tableFiles = directoryFiles.filter((file) => file.endsWith('.sql')); const queries = await Promise.all( @@ -90,8 +86,6 @@ const seedTables = async (connection: DatabaseTransactionConnection) => { defaultRole, } = seeds; - // TODO: update database alteration timestamp when migrate alteration process from core - await Promise.all([ connection.query(insertInto(managementResource, 'resources')), connection.query(insertInto(createDefaultSetting(), 'settings')), @@ -100,6 +94,7 @@ const seedTables = async (connection: DatabaseTransactionConnection) => { insertInto(createDemoAppApplication(buildApplicationSecret()), 'applications') ), connection.query(insertInto(defaultRole, 'roles')), + updateDatabaseTimestamp(connection, await getLatestAlterationTimestamp()), ]); log.info('Seed tables succeeded.'); }; diff --git a/packages/cli/src/queries/logto-config.ts b/packages/cli/src/queries/logto-config.ts index ae3553a8a..c151aba70 100644 --- a/packages/cli/src/queries/logto-config.ts +++ b/packages/cli/src/queries/logto-config.ts @@ -1,5 +1,12 @@ -import { LogtoConfig, logtoConfigGuards, LogtoConfigKey, LogtoConfigs } from '@logto/schemas'; -import { DatabasePool, sql } from 'slonik'; +import { + AlterationState, + alterationStateGuard, + LogtoConfig, + logtoConfigGuards, + LogtoConfigKey, + LogtoConfigs, +} from '@logto/schemas'; +import { DatabasePool, DatabaseTransactionConnection, sql } from 'slonik'; import { z } from 'zod'; import { convertToIdentifiers } from '../database'; @@ -13,7 +20,7 @@ export const getRowsByKeys = async (pool: DatabasePool, keys: LogtoConfigKey[]) `); export const updateValueByKey = async ( - pool: DatabasePool, + pool: DatabasePool | DatabaseTransactionConnection, key: T, value: z.infer ) => @@ -24,3 +31,36 @@ export const updateValueByKey = async ( on conflict (${fields.key}) do update set ${fields.value}=excluded.${fields.value} ` ); + +export const getCurrentDatabaseAlterationTimestamp = async (pool: DatabasePool) => { + try { + const result = await pool.maybeOne( + sql`select * from ${table} where ${fields.key}=${LogtoConfigKey.AlterationState}` + ); + const parsed = alterationStateGuard.safeParse(result?.value); + + return (parsed.success && parsed.data.timestamp) || 0; + } catch (error: unknown) { + const result = z.object({ code: z.string() }).safeParse(error); + + // Relation does not exist, treat as 0 + // https://www.postgresql.org/docs/14/errcodes-appendix.html + if (result.success && result.data.code === '42P01') { + return 0; + } + + throw error; + } +}; + +export const updateDatabaseTimestamp = async ( + connection: DatabaseTransactionConnection, + timestamp: number +) => { + const value: AlterationState = { + timestamp, + updatedAt: new Date().toISOString(), + }; + + return updateValueByKey(connection, LogtoConfigKey.AlterationState, value); +}; diff --git a/packages/cli/src/utilities.ts b/packages/cli/src/utilities.ts index 28c8c800a..df0d71640 100644 --- a/packages/cli/src/utilities.ts +++ b/packages/cli/src/utilities.ts @@ -1,5 +1,6 @@ import { execSync } from 'child_process'; import { createWriteStream } from 'fs'; +import path from 'path'; import chalk from 'chalk'; import got, { Progress } from 'got'; @@ -70,6 +71,15 @@ export const downloadFile = async (url: string, destination: string) => { }); }; +export const getPathInModule = (moduleName: string, relativePath = '/') => + // https://stackoverflow.com/a/49455609/12514940 + path.join( + // Until we migrate to ESM + // eslint-disable-next-line unicorn/prefer-module + path.dirname(require.resolve(`${moduleName}/package.json`)), + relativePath + ); + // TODO: Move to `@silverhand/essentials` // Intended // eslint-disable-next-line @typescript-eslint/no-empty-function diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index 747c9b09d..1f1fc18e2 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -8,5 +8,6 @@ }, "include": [ "src" - ] + ], + "exclude": ["**/alteration-scripts"] } diff --git a/packages/schemas/alterations/1.0.0_beta.10-1-logto-config.ts b/packages/schemas/alterations/1.0.0_beta.10-1-logto-config.ts new file mode 100644 index 000000000..2d4a21f19 --- /dev/null +++ b/packages/schemas/alterations/1.0.0_beta.10-1-logto-config.ts @@ -0,0 +1,20 @@ +import { sql } from 'slonik'; + +import { AlterationScript } from '../lib/types/alteration'; + +const alteration: AlterationScript = { + up: async (pool) => { + await pool.query(sql` + create table _logto_configs ( + key varchar(256) not null, + value jsonb /* @use ArbitraryObject */ not null default '{}'::jsonb, + primary key (key) + ); + `); + }, + down: async (pool) => { + await pool.query(sql`drop table _logto_configs;`); + }, +}; + +export default alteration; diff --git a/packages/schemas/src/types/logto-config.ts b/packages/schemas/src/types/logto-config.ts index 2cdff3f17..dd0c52e23 100644 --- a/packages/schemas/src/types/logto-config.ts +++ b/packages/schemas/src/types/logto-config.ts @@ -18,13 +18,14 @@ export const logtoOidcConfigGuard = z.object({ export type LogtoOidcConfig = z.infer; // Summary +export enum LogtoConfigKey { + AlterationState = 'alterationState', + OidcConfig = 'oidcConfig', +} + +export const logtoConfigKeys = Object.values(LogtoConfigKey); + export const logtoConfigGuards = Object.freeze({ - alterationState: alterationStateGuard, - oidcConfig: logtoOidcConfigGuard, + [LogtoConfigKey.AlterationState]: alterationStateGuard, + [LogtoConfigKey.OidcConfig]: logtoOidcConfigGuard, } as const); - -export type LogtoConfigKey = keyof typeof logtoConfigGuards; - -// `as` is intended since we'd like to keep `logtoConfigGuards` as the SSOT of keys -// eslint-disable-next-line no-restricted-syntax -export const logtoConfigKeys = Object.keys(logtoConfigGuards) as LogtoConfigKey[]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2213350ae..d93110b1d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,8 +22,10 @@ importers: specifiers: '@logto/schemas': ^1.0.0-beta.10 '@silverhand/eslint-config': 1.0.0 + '@silverhand/essentials': ^1.2.1 '@silverhand/ts-config': 1.0.0 '@types/decompress': ^4.2.4 + '@types/fs-extra': ^9.0.13 '@types/inquirer': ^8.2.1 '@types/node': ^16.0.0 '@types/semver': ^7.3.12 @@ -33,6 +35,7 @@ importers: decamelize: ^5.0.0 eslint: ^8.21.0 find-up: ^5.0.0 + fs-extra: ^10.1.0 got: ^11.8.2 hpagent: ^1.0.0 inquirer: ^8.2.2 @@ -53,9 +56,11 @@ importers: zod: ^3.18.0 dependencies: '@logto/schemas': link:../schemas + '@silverhand/essentials': 1.2.1 chalk: 4.1.2 decamelize: 5.0.1 find-up: 5.0.0 + fs-extra: 10.1.0 got: 11.8.3 hpagent: 1.0.0 inquirer: 8.2.2 @@ -73,6 +78,7 @@ importers: '@silverhand/eslint-config': 1.0.0_swk2g7ygmfleszo5c33j4vooni '@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/node': 16.11.12 '@types/semver': 7.3.12 From c324e29df3b4b80b6487839fd4ed63b0c9db70d6 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Fri, 7 Oct 2022 20:26:50 +0800 Subject: [PATCH 31/54] refactor(core): use config key enum --- packages/core/src/alteration/constants.ts | 3 --- packages/core/src/alteration/index.test.ts | 14 ++++++++------ packages/core/src/alteration/index.ts | 13 +++++++++---- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/packages/core/src/alteration/constants.ts b/packages/core/src/alteration/constants.ts index 76d18ba7d..077a1b68b 100644 --- a/packages/core/src/alteration/constants.ts +++ b/packages/core/src/alteration/constants.ts @@ -1,6 +1,3 @@ -import { LogtoConfigKey } from '@logto/schemas'; - -export const alterationStateKey: LogtoConfigKey = 'alterationState'; export const logtoConfigsTableFilePath = 'node_modules/@logto/schemas/tables/logto_configs.sql'; export const alterationFilesDirectorySource = 'node_modules/@logto/schemas/alterations'; export const alterationFilesDirectory = 'alterations/'; diff --git a/packages/core/src/alteration/index.test.ts b/packages/core/src/alteration/index.test.ts index 6098fe329..651736ff7 100644 --- a/packages/core/src/alteration/index.test.ts +++ b/packages/core/src/alteration/index.test.ts @@ -1,11 +1,10 @@ -import { LogtoConfigs } from '@logto/schemas'; +import { LogtoConfigKey, LogtoConfigs } from '@logto/schemas'; import { createMockPool, createMockQueryResult, sql } from 'slonik'; import { convertToIdentifiers } from '@/database/utils'; import { QueryType, expectSqlAssert } from '@/utils/test-utils'; import * as functions from '.'; -import { alterationStateKey } from './constants'; const mockQuery: jest.MockedFunction = jest.fn(); const { @@ -59,7 +58,7 @@ describe('getCurrentDatabaseTimestamp()', () => { mockQuery.mockImplementationOnce(async (sql, values) => { expectSqlAssert(sql, expectSql.sql); - expect(values).toEqual([alterationStateKey]); + expect(values).toEqual([LogtoConfigKey.AlterationState]); return createMockQueryResult([]); }); @@ -74,7 +73,7 @@ describe('getCurrentDatabaseTimestamp()', () => { mockQuery.mockImplementationOnce(async (sql, values) => { expectSqlAssert(sql, expectSql.sql); - expect(values).toEqual([alterationStateKey]); + expect(values).toEqual([LogtoConfigKey.AlterationState]); return createMockQueryResult([{ value: 'some_value' }]); }); @@ -89,7 +88,7 @@ describe('getCurrentDatabaseTimestamp()', () => { mockQuery.mockImplementationOnce(async (sql, values) => { expectSqlAssert(sql, expectSql.sql); - expect(values).toEqual([alterationStateKey]); + expect(values).toEqual([LogtoConfigKey.AlterationState]); // @ts-expect-error createMockQueryResult doesn't support jsonb return createMockQueryResult([{ value: { timestamp, updatedAt: 'now' } }]); @@ -148,7 +147,10 @@ describe('updateDatabaseTimestamp()', () => { it('sends upsert sql with timestamp and updatedAt', async () => { mockQuery.mockImplementationOnce(async (sql, values) => { expectSqlAssert(sql, expectSql.sql); - expect(values).toEqual([alterationStateKey, JSON.stringify({ timestamp, updatedAt })]); + expect(values).toEqual([ + LogtoConfigKey.AlterationState, + JSON.stringify({ timestamp, updatedAt }), + ]); return createMockQueryResult([]); }); diff --git a/packages/core/src/alteration/index.ts b/packages/core/src/alteration/index.ts index e593faaf0..bb9468b94 100644 --- a/packages/core/src/alteration/index.ts +++ b/packages/core/src/alteration/index.ts @@ -2,7 +2,13 @@ import { existsSync } from 'fs'; import { readdir, readFile } from 'fs/promises'; import path from 'path'; -import { LogtoConfig, LogtoConfigs, AlterationState, alterationStateGuard } from '@logto/schemas'; +import { + LogtoConfig, + LogtoConfigs, + AlterationState, + alterationStateGuard, + LogtoConfigKey, +} from '@logto/schemas'; import { AlterationScript } from '@logto/schemas/lib/types/alteration'; import { conditionalString } from '@silverhand/essentials'; import chalk from 'chalk'; @@ -14,7 +20,6 @@ import { convertToIdentifiers } from '@/database/utils'; import { logtoConfigsTableFilePath, - alterationStateKey, alterationFilesDirectory, alterationFilesDirectorySource, } from './constants'; @@ -38,7 +43,7 @@ export const isLogtoConfigsTableExists = async (pool: DatabasePool) => { export const getCurrentDatabaseTimestamp = async (pool: DatabasePool) => { try { const query = await pool.maybeOne( - sql`select * from ${table} where ${fields.key}=${alterationStateKey}` + sql`select * from ${table} where ${fields.key}=${LogtoConfigKey.AlterationState}` ); const { timestamp } = alterationStateGuard.parse(query?.value); @@ -66,7 +71,7 @@ export const updateDatabaseTimestamp = async (pool: DatabasePool, timestamp?: nu await pool.query( sql` insert into ${table} (${fields.key}, ${fields.value}) - values (${alterationStateKey}, ${sql.jsonb(value)}) + values (${LogtoConfigKey.AlterationState}, ${sql.jsonb(value)}) on conflict (${fields.key}) do update set ${fields.value}=excluded.${fields.value} ` ); From a3dc96744249cb523ff94a7680a804abf8a61893 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Fri, 7 Oct 2022 23:08:25 +0800 Subject: [PATCH 32/54] refactor: remove database seed from core --- .../cli/src/commands/database/alteration.ts | 2 +- packages/cli/src/commands/database/seed.ts | 74 ++++-------- packages/cli/src/commands/install.ts | 92 ++++++++++---- packages/cli/src/config.ts | 2 +- packages/cli/src/database.ts | 73 ++++++++++- packages/core/package.json | 2 + packages/core/src/database/seed.ts | 100 ---------------- .../src/env-set/check-alteration-state.ts | 2 +- .../core/src/env-set/create-pool-by-env.ts | 113 +++--------------- pnpm-lock.yaml | 2 + 10 files changed, 183 insertions(+), 279 deletions(-) delete mode 100644 packages/core/src/database/seed.ts diff --git a/packages/cli/src/commands/database/alteration.ts b/packages/cli/src/commands/database/alteration.ts index b0a7d3af3..6b20e7b5e 100644 --- a/packages/cli/src/commands/database/alteration.ts +++ b/packages/cli/src/commands/database/alteration.ts @@ -93,7 +93,7 @@ const deployAlteration = async ( await pool.end(); log.error( - `Error ocurred during running alteration ${chalk.green(filename)}.\n\n` + + `Error ocurred during running alteration ${chalk.blue(filename)}.\n\n` + " This alteration didn't change anything since it was in a transaction.\n" + ' Try to fix the error and deploy again.' ); diff --git a/packages/cli/src/commands/database/seed.ts b/packages/cli/src/commands/database/seed.ts index 5ae8985dd..26bd5411d 100644 --- a/packages/cli/src/commands/database/seed.ts +++ b/packages/cli/src/commands/database/seed.ts @@ -2,63 +2,22 @@ import { readdir, readFile } from 'fs/promises'; import path from 'path'; import { seeds } from '@logto/schemas'; -import { - createPool, - DatabasePool, - DatabaseTransactionConnection, - parseDsn, - sql, - stringifyDsn, -} from 'slonik'; +import chalk from 'chalk'; +import ora from 'ora'; +import { DatabasePool, DatabaseTransactionConnection, sql } from 'slonik'; import { raw } from 'slonik-sql-tag-raw'; import { CommandModule } from 'yargs'; -import { z } from 'zod'; -import { createPoolFromConfig, getDatabaseUrlFromConfig, insertInto } from '../../database'; +import { createPoolAndDatabaseIfNeeded, insertInto } from '../../database'; import { updateDatabaseTimestamp } from '../../queries/logto-config'; import { buildApplicationSecret, getPathInModule, log } from '../../utilities'; import { getLatestAlterationTimestamp } from './alteration'; -/** - * Create a database pool with the database URL in config. - * 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. - */ -const createDatabasePool = async () => { - try { - return await createPoolFromConfig(); - } catch (error: unknown) { - const result = z.object({ code: z.string() }).safeParse(error); - - // Database does not exist, try to create one - // https://www.postgresql.org/docs/14/errcodes-appendix.html - if (!(result.success && result.data.code === '3D000')) { - log.error(error); - } - - 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 - // - It will throw error when creating database using '?' - const databaseName = dsn.databaseName ?? '?'; - const maintenancePool = await createPool(stringifyDsn({ ...dsn, databaseName: 'postgres' })); - await maintenancePool.query(sql` - create database ${sql.identifier([databaseName])} - with - encoding = 'UTF8' - connection_limit = -1; - `); - await maintenancePool.end(); - - log.info(`Database ${databaseName} successfully created.`); - - return createPoolFromConfig(); - } -}; - const createTables = async (connection: DatabaseTransactionConnection) => { + const spinner = ora({ + text: 'Create tables', + prefixText: chalk.blue('[info]'), + }).start(); const tableDirectory = getPathInModule('@logto/schemas', 'tables'); const directoryFiles = await readdir(tableDirectory); const tableFiles = directoryFiles.filter((file) => file.endsWith('.sql')); @@ -69,12 +28,17 @@ const createTables = async (connection: DatabaseTransactionConnection) => { ]) ); + // Disable for spinner + /* eslint-disable @silverhand/fp/no-mutation */ // Await in loop is intended for better error handling for (const [file, query] of queries) { // eslint-disable-next-line no-await-in-loop await connection.query(sql`${raw(query)}`); - log.info(`Run ${file} succeeded.`); + spinner.text = `Run ${file} succeeded`; } + + spinner.succeed(`Created ${queries.length} tables`); + /* eslint-enable @silverhand/fp/no-mutation */ }; const seedTables = async (connection: DatabaseTransactionConnection) => { @@ -86,6 +50,11 @@ const seedTables = async (connection: DatabaseTransactionConnection) => { defaultRole, } = seeds; + const spinner = ora({ + text: 'Seed data', + prefixText: chalk.blue('[info]'), + }).start(); + await Promise.all([ connection.query(insertInto(managementResource, 'resources')), connection.query(insertInto(createDefaultSetting(), 'settings')), @@ -96,7 +65,8 @@ const seedTables = async (connection: DatabaseTransactionConnection) => { connection.query(insertInto(defaultRole, 'roles')), updateDatabaseTimestamp(connection, await getLatestAlterationTimestamp()), ]); - log.info('Seed tables succeeded.'); + + spinner.succeed(); }; export const seedByPool = async (pool: DatabasePool) => { @@ -110,7 +80,7 @@ const seed: CommandModule = { command: 'seed', describe: 'Create database and seed tables and data', handler: async () => { - const pool = await createDatabasePool(); + const pool = await createPoolAndDatabaseIfNeeded(); try { await seedByPool(pool); diff --git a/packages/cli/src/commands/install.ts b/packages/cli/src/commands/install.ts index 6c8a6d6e5..9382becfd 100644 --- a/packages/cli/src/commands/install.ts +++ b/packages/cli/src/commands/install.ts @@ -4,14 +4,18 @@ 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 ora from 'ora'; import * as semver from 'semver'; import tar from 'tar'; import { CommandModule } from 'yargs'; +import { createPoolAndDatabaseIfNeeded, getDatabaseUrlFromConfig } from '../database'; import { downloadFile, log, safeExecSync } from '../utilities'; +import { seedByPool } from './database/seed'; export type InstallArgs = { path?: string; @@ -36,12 +40,26 @@ const validateNodeVersion = () => { } }; -const validatePath = (value: string) => - existsSync(path.resolve(value)) - ? `The path ${chalk.green(value)} already exists, please try another.` - : true; +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 } + ); -const getInstancePath = async () => { + 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?`, @@ -59,17 +77,6 @@ const getInstancePath = async () => { if (hasPostgresUrl === false) { log.error('Logto requires a Postgres instance to run.'); } - - 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, - }); - - return instancePath; }; const downloadRelease = async () => { @@ -96,8 +103,6 @@ const decompress = async (toPath: string, tarPath: string) => { } catch (error: unknown) { decompressSpinner.fail(); log.error(error); - - return; } decompressSpinner.succeed(); @@ -106,17 +111,56 @@ const decompress = async (toPath: string, tarPath: string) => { const installLogto = async ({ path: pathArgument = defaultPath, silent = false }: InstallArgs) => { validateNodeVersion(); - const instancePath = (!silent && (await getInstancePath())) || pathArgument; - const isValidPath = validatePath(instancePath); + // Get instance path + const instancePath = await inquireInstancePath(conditional(silent && pathArgument)); - if (isValidPath !== true) { - log.error(isValidPath); - } + // Validate database URL + await validateDatabase(); + // Download and decompress const tarPath = await downloadRelease(); - await decompress(instancePath, tarPath); + try { + // Seed database + const pool = await createPoolAndDatabaseIfNeeded(); // It will ask for database URL and save to config + await seedByPool(pool); + 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. Would you like to continue without seed?', + default: false, + }); + + if (!value) { + const spinner = ora({ + text: 'Clean up', + prefixText: chalk.blue('[info]'), + }).start(); + + await remove(instancePath); + spinner.succeed(); + // 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 getDatabaseUrlFromConfig(); + 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)}` diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts index e784a93ee..5b345f3d0 100644 --- a/packages/cli/src/config.ts +++ b/packages/cli/src/config.ts @@ -41,5 +41,5 @@ export const getConfig = async () => { export const patchConfig = async (config: LogtoConfig) => { const configPath = await getConfigPath(); await writeFile(configPath, JSON.stringify({ ...(await getConfig()), ...config }, undefined, 2)); - log.info(`Updated config in ${chalk.green(configPath)}`); + log.info(`Updated config in ${chalk.blue(configPath)}`); }; diff --git a/packages/cli/src/database.ts b/packages/cli/src/database.ts index 9ca907b74..7620a2f91 100644 --- a/packages/cli/src/database.ts +++ b/packages/cli/src/database.ts @@ -1,19 +1,43 @@ import { SchemaLike, SchemaValue, SchemaValuePrimitive } from '@logto/schemas'; import chalk from 'chalk'; import decamelize from 'decamelize'; -import { createPool, IdentifierSqlToken, sql, SqlToken } from 'slonik'; +import inquirer from 'inquirer'; +import { createPool, IdentifierSqlToken, parseDsn, sql, SqlToken, stringifyDsn } from 'slonik'; import { createInterceptors } from 'slonik-interceptor-preset'; +import { z } from 'zod'; -import { getConfig } from './config'; +import { getConfig, patchConfig } from './config'; import { log } from './utilities'; +export const defaultDatabaseUrl = 'postgresql://localhost:5432/logto'; + export const getDatabaseUrlFromConfig = async () => { const { databaseUrl } = await getConfig(); if (!databaseUrl) { - log.error( - `No database URL configured. Set it via ${chalk.green('database set-url')} command first.` - ); + const { value } = await inquirer + .prompt<{ value: string }>({ + type: 'input', + name: 'value', + message: 'Enter your Logto database URL', + default: defaultDatabaseUrl, + }) + .catch(async (error) => { + if (error.isTtyError) { + log.error( + `No database URL configured. Set it via ${chalk.green( + 'database set-url' + )} command first.` + ); + } + + // 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; + }); + await patchConfig({ databaseUrl: value }); + + return value; } return databaseUrl; @@ -27,6 +51,45 @@ export const createPoolFromConfig = async () => { }); }; +/** + * Create a database pool with the database URL in config. + * 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 createPoolFromConfig(); + } catch (error: unknown) { + const result = z.object({ code: z.string() }).safeParse(error); + + // Database does not exist, try to create one + // https://www.postgresql.org/docs/14/errcodes-appendix.html + if (!(result.success && result.data.code === '3D000')) { + log.error(error); + } + + 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 + // - It will throw error when creating database using '?' + const databaseName = dsn.databaseName ?? '?'; + const maintenancePool = await createPool(stringifyDsn({ ...dsn, databaseName: 'postgres' })); + await maintenancePool.query(sql` + create database ${sql.identifier([databaseName])} + with + encoding = 'UTF8' + connection_limit = -1; + `); + await maintenancePool.end(); + + log.info(`${chalk.green('✔')} Created database ${databaseName}`); + + return createPoolFromConfig(); + } +}; + // TODO: Move database utils to `core-kit` export type Table = { table: string; fields: Record }; export type FieldIdentifiers = { diff --git a/packages/core/package.json b/packages/core/package.json index 140d2504b..95536ff4b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -17,11 +17,13 @@ "add-connector": "node build/cli/add-connector.js", "add-official-connectors": "node build/cli/add-official-connectors.js", "alteration": "node build/cli/alteration.js", + "cli": "logto", "test": "jest", "test:coverage": "jest --coverage --silent", "test:report": "codecov -F core" }, "dependencies": { + "@logto/cli": "^1.0.0-beta.10", "@logto/connector-kit": "^1.0.0-beta.13", "@logto/core-kit": "^1.0.0-beta.13", "@logto/phrases": "^1.0.0-beta.10", diff --git a/packages/core/src/database/seed.ts b/packages/core/src/database/seed.ts deleted file mode 100644 index df9d8acb6..000000000 --- a/packages/core/src/database/seed.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { readdir, readFile } from 'fs/promises'; -import path from 'path'; - -import { SchemaLike, seeds } from '@logto/schemas'; -import chalk from 'chalk'; -import decamelize from 'decamelize'; -import { createPool, parseDsn, sql, stringifyDsn } from 'slonik'; -import { createInterceptors } from 'slonik-interceptor-preset'; -import { raw } from 'slonik-sql-tag-raw'; - -import { updateDatabaseTimestamp } from '@/alteration'; -import { buildApplicationSecret } from '@/utils/id'; - -import { convertToPrimitiveOrSql } from './utils'; - -const { - managementResource, - defaultSignInExperience, - createDefaultSetting, - createDemoAppApplication, - defaultRole, -} = seeds; -const tableDirectory = 'node_modules/@logto/schemas/tables'; - -export const replaceDsnDatabase = (dsn: string, databaseName: string): string => - stringifyDsn({ ...parseDsn(dsn), databaseName }); - -/** - * Create a database. - * @returns DSN with the created database name. - */ -export const createDatabase = async (dsn: string, databaseName: string): Promise => { - const pool = await createPool(replaceDsnDatabase(dsn, 'postgres')); - - await pool.query(sql` - create database ${sql.identifier([databaseName])} - with - encoding = 'UTF8' - connection_limit = -1; - `); - await pool.end(); - - console.log(`${chalk.blue('[create]')} Database ${databaseName} successfully created.`); - - return replaceDsnDatabase(dsn, databaseName); -}; - -export const insertInto = (object: T, table: string) => { - const keys = Object.keys(object); - - return sql` - insert into ${sql.identifier([table])} - (${sql.join( - keys.map((key) => sql.identifier([decamelize(key)])), - sql`, ` - )}) - values (${sql.join( - keys.map((key) => convertToPrimitiveOrSql(key, object[key] ?? null)), - sql`, ` - )}) - `; -}; - -export const createDatabaseCli = async (dsn: string) => { - const pool = await createPool(dsn, { interceptors: createInterceptors() }); - - const createTables = async () => { - const directory = await readdir(tableDirectory); - const tableFiles = directory.filter((file) => file.endsWith('.sql')); - const queries = await Promise.all( - tableFiles.map>(async (file) => [ - file, - await readFile(path.join(tableDirectory, file), 'utf8'), - ]) - ); - - // Await in loop is intended for better error handling - for (const [file, query] of queries) { - // eslint-disable-next-line no-await-in-loop - await pool.query(sql`${raw(query)}`); - console.log(`${chalk.blue('[create-tables]')} Run ${file} succeeded.`); - } - - await updateDatabaseTimestamp(pool); - console.log(`${chalk.blue('[create-tables]')} Update alteration state succeeded.`); - }; - - const seedTables = async () => { - await Promise.all([ - pool.query(insertInto(managementResource, 'resources')), - pool.query(insertInto(createDefaultSetting(), 'settings')), - pool.query(insertInto(defaultSignInExperience, 'sign_in_experiences')), - pool.query(insertInto(createDemoAppApplication(buildApplicationSecret()), 'applications')), - pool.query(insertInto(defaultRole, 'roles')), - ]); - console.log(`${chalk.blue('[seed-tables]')} Seed tables succeeded.`); - }; - - return { createTables, seedTables, pool }; -}; diff --git a/packages/core/src/env-set/check-alteration-state.ts b/packages/core/src/env-set/check-alteration-state.ts index 9ab15bc37..6b975a6ed 100644 --- a/packages/core/src/env-set/check-alteration-state.ts +++ b/packages/core/src/env-set/check-alteration-state.ts @@ -13,7 +13,7 @@ export const checkAlterationState = async (pool: DatabasePool) => { } const error = new Error( - `Found undeployed database alterations, you must deploy them first by "npm alteration deploy" command, reference: https://docs.logto.io/docs/recipes/deployment/#database-alteration` + `Found undeployed database alterations, you must deploy them first by "npm run alteration deploy" command, reference: https://docs.logto.io/docs/recipes/deployment/#database-alteration` ); if (allYes) { diff --git a/packages/core/src/env-set/create-pool-by-env.ts b/packages/core/src/env-set/create-pool-by-env.ts index cf2bfc7ee..9ddda4f3c 100644 --- a/packages/core/src/env-set/create-pool-by-env.ts +++ b/packages/core/src/env-set/create-pool-by-env.ts @@ -1,82 +1,7 @@ -import { assertEnv, conditional, getEnv, Optional } from '@silverhand/essentials'; -import inquirer from 'inquirer'; +import { assertEnv } from '@silverhand/essentials'; +import chalk from 'chalk'; import { createPool } from 'slonik'; import { createInterceptors } from 'slonik-interceptor-preset'; -import { z } from 'zod'; - -import { createDatabase, createDatabaseCli, replaceDsnDatabase } from '@/database/seed'; - -import { appendDotEnv } from './dot-env'; -import { allYes, noInquiry } from './parameters'; - -const defaultDatabaseUrl = getEnv('DB_URL_DEFAULT', 'postgres://@localhost:5432'); -const defaultDatabaseName = 'logto'; - -const initDatabase = async (dsn: string): Promise<[string, boolean]> => { - try { - return [await createDatabase(dsn, defaultDatabaseName), true]; - } catch (error: unknown) { - const result = z.object({ code: z.string() }).safeParse(error); - - // https://www.postgresql.org/docs/12/errcodes-appendix.html - const databaseExists = result.success && result.data.code === '42P04'; - - if (!databaseExists) { - throw error; - } - - if (allYes) { - return [replaceDsnDatabase(dsn, defaultDatabaseName), false]; - } - - const useCurrent = await inquirer.prompt({ - type: 'confirm', - name: 'value', - message: `A database named "${defaultDatabaseName}" already exists. Would you like to use it without filling the initial data?`, - }); - - if (useCurrent.value) { - return [replaceDsnDatabase(dsn, defaultDatabaseName), false]; - } - - throw error; - } -}; - -const inquireForLogtoDsn = async (key: string): Promise<[Optional, boolean]> => { - if (allYes) { - return initDatabase(defaultDatabaseUrl); - } - - const setUp = await inquirer.prompt({ - type: 'confirm', - name: 'value', - message: `No Postgres DSN (${key}) found in env variables. Would you like to set up a new Logto database?`, - }); - - if (!setUp.value) { - const dsn = await inquirer.prompt({ - name: 'value', - default: new URL(defaultDatabaseName, defaultDatabaseUrl).href, - message: 'Please input the DSN which points to an existing Logto database:', - }); - - return [conditional(dsn.value && String(dsn.value)), false]; - } - - const dsnAnswer = await inquirer.prompt({ - name: 'value', - default: new URL(defaultDatabaseUrl).href, - message: `Please input the DSN _WITHOUT_ database name:`, - }); - const dsn = conditional(dsnAnswer.value && String(dsnAnswer.value)); - - if (!dsn) { - return [dsn, false]; - } - - return initDatabase(dsn); -}; const createPoolByEnv = async (isTest: boolean) => { // Database connection is disabled in unit test environment @@ -92,26 +17,24 @@ const createPoolByEnv = async (isTest: boolean) => { return await createPool(databaseDsn, { interceptors }); } catch (error: unknown) { - if (noInquiry) { - throw error; + if (error instanceof Error && error.message === `env variable ${key} not found`) { + console.error( + `${chalk.red('[error]')} No Postgres DSN (${chalk.green( + key + )}) found in env variables.\n\n` + + ` Either provide it in your env, or add it to the ${chalk.blue( + '.env' + )} file in the Logto project root.\n\n` + + ` If you want to set up a new Logto database, run ${chalk.green( + 'npm run cli db seed' + )} before setting env ${chalk.green(key)}.\n\n` + + ` Visit ${chalk.blue( + 'https://docs.logto.io/docs/references/core/configuration' + )} for more info about setting up env.\n` + ); } - const [dsn, needsSeed] = await inquireForLogtoDsn(key); - - if (!dsn) { - throw error; - } - - const cli = await createDatabaseCli(dsn); - - if (needsSeed) { - await cli.createTables(); - await cli.seedTables(); - } - - appendDotEnv(key, dsn); - - return cli.pool; + throw error; } }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d93110b1d..ae6923110 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -223,6 +223,7 @@ importers: packages/core: specifiers: + '@logto/cli': ^1.0.0-beta.10 '@logto/connector-kit': ^1.0.0-beta.13 '@logto/core-kit': ^1.0.0-beta.13 '@logto/phrases': ^1.0.0-beta.10 @@ -301,6 +302,7 @@ importers: typescript: ^4.7.4 zod: ^3.18.0 dependencies: + '@logto/cli': link:../cli '@logto/connector-kit': 1.0.0-beta.13 '@logto/core-kit': 1.0.0-beta.13 '@logto/phrases': link:../phrases From aba872ea4b3d1b34b01a136af627e48f6e0ec383 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Fri, 7 Oct 2022 23:31:13 +0800 Subject: [PATCH 33/54] refactor(cli): add `oraPromise()` util --- packages/cli/src/commands/database/seed.ts | 32 +++++++--------------- packages/cli/src/commands/install.ts | 26 ++++++++---------- packages/cli/src/utilities.ts | 23 ++++++++++++++++ 3 files changed, 44 insertions(+), 37 deletions(-) diff --git a/packages/cli/src/commands/database/seed.ts b/packages/cli/src/commands/database/seed.ts index 26bd5411d..7f7611a9d 100644 --- a/packages/cli/src/commands/database/seed.ts +++ b/packages/cli/src/commands/database/seed.ts @@ -3,21 +3,16 @@ import path from 'path'; import { seeds } from '@logto/schemas'; import chalk from 'chalk'; -import ora from 'ora'; import { DatabasePool, DatabaseTransactionConnection, sql } from 'slonik'; import { raw } from 'slonik-sql-tag-raw'; import { CommandModule } from 'yargs'; import { createPoolAndDatabaseIfNeeded, insertInto } from '../../database'; import { updateDatabaseTimestamp } from '../../queries/logto-config'; -import { buildApplicationSecret, getPathInModule, log } from '../../utilities'; +import { buildApplicationSecret, getPathInModule, log, oraPromise } from '../../utilities'; import { getLatestAlterationTimestamp } from './alteration'; const createTables = async (connection: DatabaseTransactionConnection) => { - const spinner = ora({ - text: 'Create tables', - prefixText: chalk.blue('[info]'), - }).start(); const tableDirectory = getPathInModule('@logto/schemas', 'tables'); const directoryFiles = await readdir(tableDirectory); const tableFiles = directoryFiles.filter((file) => file.endsWith('.sql')); @@ -28,17 +23,11 @@ const createTables = async (connection: DatabaseTransactionConnection) => { ]) ); - // Disable for spinner - /* eslint-disable @silverhand/fp/no-mutation */ // Await in loop is intended for better error handling - for (const [file, query] of queries) { + for (const [, query] of queries) { // eslint-disable-next-line no-await-in-loop await connection.query(sql`${raw(query)}`); - spinner.text = `Run ${file} succeeded`; } - - spinner.succeed(`Created ${queries.length} tables`); - /* eslint-enable @silverhand/fp/no-mutation */ }; const seedTables = async (connection: DatabaseTransactionConnection) => { @@ -50,11 +39,6 @@ const seedTables = async (connection: DatabaseTransactionConnection) => { defaultRole, } = seeds; - const spinner = ora({ - text: 'Seed data', - prefixText: chalk.blue('[info]'), - }).start(); - await Promise.all([ connection.query(insertInto(managementResource, 'resources')), connection.query(insertInto(createDefaultSetting(), 'settings')), @@ -65,14 +49,18 @@ const seedTables = async (connection: DatabaseTransactionConnection) => { connection.query(insertInto(defaultRole, 'roles')), updateDatabaseTimestamp(connection, await getLatestAlterationTimestamp()), ]); - - spinner.succeed(); }; export const seedByPool = async (pool: DatabasePool) => { await pool.transaction(async (connection) => { - await createTables(connection); - await seedTables(connection); + await oraPromise(createTables(connection), { + text: 'Create tables', + prefixText: chalk.blue('[info]'), + }); + await oraPromise(seedTables(connection), { + text: 'Seed data', + prefixText: chalk.blue('[info]'), + }); }); }; diff --git a/packages/cli/src/commands/install.ts b/packages/cli/src/commands/install.ts index 9382becfd..67ca4b6e1 100644 --- a/packages/cli/src/commands/install.ts +++ b/packages/cli/src/commands/install.ts @@ -8,13 +8,12 @@ import { conditional } from '@silverhand/essentials'; import chalk from 'chalk'; import { remove, writeFile } from 'fs-extra'; import inquirer from 'inquirer'; -import ora from 'ora'; import * as semver from 'semver'; import tar from 'tar'; import { CommandModule } from 'yargs'; import { createPoolAndDatabaseIfNeeded, getDatabaseUrlFromConfig } from '../database'; -import { downloadFile, log, safeExecSync } from '../utilities'; +import { downloadFile, log, oraPromise, safeExecSync } from '../utilities'; import { seedByPool } from './database/seed'; export type InstallArgs = { @@ -92,20 +91,12 @@ const downloadRelease = async () => { }; const decompress = async (toPath: string, tarPath: string) => { - const decompressSpinner = ora({ - text: `Decompress to ${toPath}`, - prefixText: chalk.blue('[info]'), - }).start(); - try { await mkdir(toPath); await tar.extract({ file: tarPath, cwd: toPath, strip: 1 }); } catch (error: unknown) { - decompressSpinner.fail(); log.error(error); } - - decompressSpinner.succeed(); }; const installLogto = async ({ path: pathArgument = defaultPath, silent = false }: InstallArgs) => { @@ -119,7 +110,14 @@ const installLogto = async ({ path: pathArgument = defaultPath, silent = false } // Download and decompress const tarPath = await downloadRelease(); - await decompress(instancePath, tarPath); + await oraPromise( + decompress(instancePath, tarPath), + { + text: `Decompress to ${instancePath}`, + prefixText: chalk.blue('[info]'), + }, + true + ); try { // Seed database @@ -138,13 +136,11 @@ const installLogto = async ({ path: pathArgument = defaultPath, silent = false } }); if (!value) { - const spinner = ora({ + await oraPromise(remove(instancePath), { text: 'Clean up', prefixText: chalk.blue('[info]'), - }).start(); + }); - await remove(instancePath); - spinner.succeed(); // eslint-disable-next-line unicorn/no-process-exit process.exit(1); } diff --git a/packages/cli/src/utilities.ts b/packages/cli/src/utilities.ts index df0d71640..72f6257f0 100644 --- a/packages/cli/src/utilities.ts +++ b/packages/cli/src/utilities.ts @@ -80,6 +80,29 @@ export const getPathInModule = (moduleName: string, relativePath = '/') => relativePath ); +export const oraPromise = async ( + promise: PromiseLike, + options?: ora.Options, + exitOnError = false +) => { + const spinner = ora(options).start(); + + try { + const result = await promise; + spinner.succeed(); + + return result; + } catch (error: unknown) { + spinner.fail(); + + if (exitOnError) { + log.error(error); + } + + throw error; + } +}; + // TODO: Move to `@silverhand/essentials` // Intended // eslint-disable-next-line @typescript-eslint/no-empty-function From 11b605a3e7bcef5ecbe24c5a39b8a1a081a54e88 Mon Sep 17 00:00:00 2001 From: simeng-li Date: Sat, 8 Oct 2022 14:47:30 +0800 Subject: [PATCH 34/54] fix(core): fix deletePasscodeByIds bug (#2049) --- packages/core/src/queries/passcode.test.ts | 8 ++++---- packages/core/src/queries/passcode.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/core/src/queries/passcode.test.ts b/packages/core/src/queries/passcode.test.ts index 9afbada45..135355a4e 100644 --- a/packages/core/src/queries/passcode.test.ts +++ b/packages/core/src/queries/passcode.test.ts @@ -133,12 +133,12 @@ describe('passcode query', () => { const ids = ['foo', 'foo2']; const expectSql = sql` delete from ${table} - where ${fields.id} in (${ids.join(',')}) + where ${fields.id} in (${sql.join(ids, sql`,`)}) `; mockQuery.mockImplementationOnce(async (sql, values) => { expectSqlAssert(sql, expectSql.sql); - expect(values).toEqual([ids.join(',')]); + expect(values).toEqual(ids); return createMockQueryResult([mockPasscode, mockPasscode]); }); @@ -150,12 +150,12 @@ describe('passcode query', () => { const ids = ['foo', 'foo2']; const expectSql = sql` delete from ${table} - where ${fields.id} in (${ids.join(',')}) + where ${fields.id} in (${sql.join(ids, sql`,`)}) `; mockQuery.mockImplementationOnce(async (sql, values) => { expectSqlAssert(sql, expectSql.sql); - expect(values).toEqual([ids.join(',')]); + expect(values).toEqual(ids); return createMockQueryResult([mockPasscode]); }); diff --git a/packages/core/src/queries/passcode.ts b/packages/core/src/queries/passcode.ts index 29f9ae69b..3dc2f0be7 100644 --- a/packages/core/src/queries/passcode.ts +++ b/packages/core/src/queries/passcode.ts @@ -56,7 +56,7 @@ export const deletePasscodeById = async (id: string) => { export const deletePasscodesByIds = async (ids: string[]) => { const { rowCount } = await envSet.pool.query(sql` delete from ${table} - where ${fields.id} in (${ids.join(',')}) + where ${fields.id} in (${sql.join(ids, sql`,`)}) `); if (rowCount !== ids.length) { From 0570d5eef17470f60f043cc58696a4218ae3df1a Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Sat, 8 Oct 2022 17:12:17 +0800 Subject: [PATCH 35/54] chore: update cli script (#2059) `dev` -> `start:dev` to avoid unnecessary runs during root `pnpm dev` script --- packages/cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index a48154548..1045e2498 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -22,7 +22,7 @@ "precommit": "lint-staged", "build": "rimraf lib && tsc", "start": "node .", - "dev": "ts-node --files src/index.ts", + "start:dev": "ts-node --files src/index.ts", "lint": "eslint --ext .ts src", "lint:report": "pnpm lint --format json --output-file report.json", "prepack": "pnpm build" From 3e24e3b404b626b5cde043a632c27e1ebd7fe1f1 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Sat, 8 Oct 2022 17:12:19 +0800 Subject: [PATCH 36/54] refactor: fix integration test --- .github/workflows/integration-test.yml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 113c2a46f..562ed98ed 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -83,13 +83,19 @@ jobs: - name: Extract run: tar -xzf logto.tar.gz - - name: Run Logto - run: node . --from-root --all-yes & + - name: Seed database working-directory: logto/packages/core + run: | + npm run cli db set-url postgres://postgres:postgres@localhost:5432 + npm run cli db seed + + - name: Run Logto + working-directory: logto/packages/core + run: node . --from-root --all-yes & env: INTEGRATION_TEST: true NODE_ENV: production - DB_URL_DEFAULT: postgres://postgres:postgres@localhost:5432 + DB_URL: postgres://postgres:postgres@localhost:5432 - name: Sleep for 5 seconds run: sleep 5 From 9696060997e49c22044c4d771e073ff4d26e9646 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Sat, 8 Oct 2022 00:32:15 +0800 Subject: [PATCH 37/54] refactor: remove alteration from core --- package.json | 1 + packages/cli/jest.config.ts | 1 + packages/cli/package.json | 10 +- .../src/commands/database/alteration.test.ts | 40 ++++ .../cli/src/commands/database/alteration.ts | 10 +- packages/cli/src/queries/logto-config.test.ts | 102 ++++++++++ packages/cli/src/test-utilities.ts | 26 +++ packages/cli/tsconfig.build.json | 4 + packages/cli/tsconfig.json | 3 +- packages/cli/tsconfig.test.json | 6 + packages/core/package.json | 2 +- packages/core/src/alteration/constants.ts | 3 - packages/core/src/alteration/index.test.ts | 192 ------------------ packages/core/src/alteration/index.ts | 163 --------------- packages/core/src/alteration/utils.test.ts | 15 -- packages/core/src/alteration/utils.ts | 11 - packages/core/src/cli/alteration.ts | 23 --- .../src/env-set/check-alteration-state.ts | 24 +-- packages/schemas/tsconfig.json | 2 +- pnpm-lock.yaml | 53 ++--- 20 files changed, 217 insertions(+), 474 deletions(-) create mode 100644 packages/cli/jest.config.ts create mode 100644 packages/cli/src/commands/database/alteration.test.ts create mode 100644 packages/cli/src/queries/logto-config.test.ts create mode 100644 packages/cli/src/test-utilities.ts create mode 100644 packages/cli/tsconfig.build.json create mode 100644 packages/cli/tsconfig.test.json delete mode 100644 packages/core/src/alteration/constants.ts delete mode 100644 packages/core/src/alteration/index.test.ts delete mode 100644 packages/core/src/alteration/index.ts delete mode 100644 packages/core/src/alteration/utils.test.ts delete mode 100644 packages/core/src/alteration/utils.ts delete mode 100644 packages/core/src/cli/alteration.ts diff --git a/package.json b/package.json index 7e25a31ce..96439c4e2 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "prepack": "lerna run --stream prepack", "dev": "lerna run --stream prepack -- --incremental && lerna --ignore=@logto/integration-tests run --parallel dev", "start": "cd packages/core && NODE_ENV=production node . --from-root", + "cli": "cd packages/core && logto", "alteration": "cd packages/core && pnpm alteration", "ci:build": "lerna run --stream build", "ci:lint": "lerna run --parallel lint", diff --git a/packages/cli/jest.config.ts b/packages/cli/jest.config.ts new file mode 100644 index 000000000..0a9aa1b2e --- /dev/null +++ b/packages/cli/jest.config.ts @@ -0,0 +1 @@ +export { default } from '@silverhand/jest-config'; diff --git a/packages/cli/package.json b/packages/cli/package.json index 1045e2498..757a466c6 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -6,6 +6,9 @@ "homepage": "https://github.com/logto-io/logto#readme", "license": "MPL-2.0", "main": "lib/index.js", + "exports": { + ".": "./lib" + }, "bin": { "logto": "bin/logto", "lg": "bin/logto" @@ -20,11 +23,13 @@ }, "scripts": { "precommit": "lint-staged", - "build": "rimraf lib && tsc", + "build": "rimraf lib && tsc -p tsconfig.build.json", "start": "node .", "start:dev": "ts-node --files src/index.ts", "lint": "eslint --ext .ts src", "lint:report": "pnpm lint --format json --output-file report.json", + "test": "jest", + "test:ci": "jest", "prepack": "pnpm build" }, "engines": { @@ -56,15 +61,18 @@ }, "devDependencies": { "@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", "@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", "lint-staged": "^13.0.0", "prettier": "^2.7.1", "rimraf": "^3.0.2", diff --git a/packages/cli/src/commands/database/alteration.test.ts b/packages/cli/src/commands/database/alteration.test.ts new file mode 100644 index 000000000..91f2a997e --- /dev/null +++ b/packages/cli/src/commands/database/alteration.test.ts @@ -0,0 +1,40 @@ +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.ts b/packages/cli/src/commands/database/alteration.ts index 6b20e7b5e..19311a8c9 100644 --- a/packages/cli/src/commands/database/alteration.ts +++ b/packages/cli/src/commands/database/alteration.ts @@ -36,7 +36,7 @@ const importAlterationScript = async (filePath: string): Promise => { +export const getAlterationFiles = async (): Promise => { const alterationDirectory = getPathInModule('@logto/schemas', 'alterations'); // Until we migrate to ESM // eslint-disable-next-line unicorn/prefer-module @@ -70,7 +70,7 @@ export const getLatestAlterationTimestamp = async () => { return getTimestampFromFileName(lastFile.filename); }; -const getUndeployedAlterations = async (pool: DatabasePool) => { +export const getUndeployedAlterations = async (pool: DatabasePool) => { const databaseTimestamp = await getCurrentDatabaseAlterationTimestamp(pool); const files = await getAlterationFiles(); @@ -111,7 +111,11 @@ const alteration: CommandModule = { type: 'string', demandOption: true, }), - handler: async () => { + handler: async ({ action }) => { + if (action !== 'deploy') { + log.error('Unsupported action'); + } + const pool = await createPoolFromConfig(); const alterations = await getUndeployedAlterations(pool); diff --git a/packages/cli/src/queries/logto-config.test.ts b/packages/cli/src/queries/logto-config.test.ts new file mode 100644 index 000000000..41a6455ab --- /dev/null +++ b/packages/cli/src/queries/logto-config.test.ts @@ -0,0 +1,102 @@ +import { LogtoConfigKey, LogtoConfigs } from '@logto/schemas'; +import { createMockPool, createMockQueryResult, sql } from 'slonik'; + +import { convertToIdentifiers } from '../database'; +import { expectSqlAssert, QueryType } from '../test-utilities'; +import { updateDatabaseTimestamp, getCurrentDatabaseAlterationTimestamp } from './logto-config'; + +const mockQuery: jest.MockedFunction = jest.fn(); + +const pool = createMockPool({ + query: async (sql, values) => { + return mockQuery(sql, values); + }, +}); +const { table, fields } = convertToIdentifiers(LogtoConfigs); +const timestamp = 1_663_923_776; + +describe('getCurrentDatabaseAlterationTimestamp()', () => { + it('returns 0 if query failed (table not found)', async () => { + mockQuery.mockRejectedValueOnce({ code: '42P01' }); + + await expect(getCurrentDatabaseAlterationTimestamp(pool)).resolves.toBe(0); + }); + + it('returns 0 if the row is not found', async () => { + const expectSql = sql` + select * from ${table} where ${fields.key}=$1 + `; + + mockQuery.mockImplementationOnce(async (sql, values) => { + expectSqlAssert(sql, expectSql.sql); + expect(values).toEqual([LogtoConfigKey.AlterationState]); + + return createMockQueryResult([]); + }); + + await expect(getCurrentDatabaseAlterationTimestamp(pool)).resolves.toBe(0); + }); + + it('returns 0 if the value is in bad format', async () => { + const expectSql = sql` + select * from ${table} where ${fields.key}=$1 + `; + + mockQuery.mockImplementationOnce(async (sql, values) => { + expectSqlAssert(sql, expectSql.sql); + expect(values).toEqual([LogtoConfigKey.AlterationState]); + + return createMockQueryResult([{ value: 'some_value' }]); + }); + + await expect(getCurrentDatabaseAlterationTimestamp(pool)).resolves.toBe(0); + }); + + it('returns the timestamp from database', async () => { + const expectSql = sql` + select * from ${table} where ${fields.key}=$1 + `; + + mockQuery.mockImplementationOnce(async (sql, values) => { + expectSqlAssert(sql, expectSql.sql); + expect(values).toEqual([LogtoConfigKey.AlterationState]); + + // @ts-expect-error createMockQueryResult doesn't support jsonb + return createMockQueryResult([{ value: { timestamp, updatedAt: 'now' } }]); + }); + + await expect(getCurrentDatabaseAlterationTimestamp(pool)).resolves.toEqual(timestamp); + }); +}); + +describe('updateDatabaseTimestamp()', () => { + const expectSql = sql` + insert into ${table} (${fields.key}, ${fields.value}) + values ($1, $2::jsonb) + on conflict (${fields.key}) do update set ${fields.value}=excluded.${fields.value} + `; + const updatedAt = '2022-09-21T06:32:46.583Z'; + + beforeAll(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date(updatedAt)); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + it('sends upsert sql with timestamp and updatedAt', async () => { + mockQuery.mockImplementationOnce(async (sql, values) => { + expectSqlAssert(sql, expectSql.sql); + expect(values).toEqual([ + LogtoConfigKey.AlterationState, + JSON.stringify({ timestamp, updatedAt }), + ]); + + return createMockQueryResult([]); + }); + + await updateDatabaseTimestamp(pool, timestamp); + }); +}); diff --git a/packages/cli/src/test-utilities.ts b/packages/cli/src/test-utilities.ts new file mode 100644 index 000000000..e03d395e8 --- /dev/null +++ b/packages/cli/src/test-utilities.ts @@ -0,0 +1,26 @@ +// Copied from core + +import { QueryResult, QueryResultRow } from 'slonik'; +import { PrimitiveValueExpression } from 'slonik/dist/src/types.d'; + +export type QueryType = ( + sql: string, + values: readonly PrimitiveValueExpression[] +) => Promise>; + +/** + * Slonik Query Mock Utils + **/ +export const expectSqlAssert = (sql: string, expectSql: string) => { + expect( + sql + .split('\n') + .map((row) => row.trim()) + .filter(Boolean) + ).toEqual( + expectSql + .split('\n') + .map((row) => row.trim()) + .filter(Boolean) + ); +}; diff --git a/packages/cli/tsconfig.build.json b/packages/cli/tsconfig.build.json new file mode 100644 index 000000000..b2142cfd9 --- /dev/null +++ b/packages/cli/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig", + "include": ["src"], +} diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index 1f1fc18e2..3d69fefb3 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -7,7 +7,8 @@ "target": "es2022" }, "include": [ - "src" + "src", + "jest.config.ts" ], "exclude": ["**/alteration-scripts"] } diff --git a/packages/cli/tsconfig.test.json b/packages/cli/tsconfig.test.json new file mode 100644 index 000000000..c68416b04 --- /dev/null +++ b/packages/cli/tsconfig.test.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig", + "compilerOptions": { + "allowJs": true + } +} diff --git a/packages/core/package.json b/packages/core/package.json index fc0b23be7..f563d67c8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -16,7 +16,7 @@ "start": "NODE_ENV=production node build/index.js", "add-connector": "node build/cli/add-connector.js", "add-official-connectors": "node build/cli/add-official-connectors.js", - "alteration": "node build/cli/alteration.js", + "alteration": "logto db alt", "cli": "logto", "test": "jest", "test:ci": "jest --coverage --silent", diff --git a/packages/core/src/alteration/constants.ts b/packages/core/src/alteration/constants.ts deleted file mode 100644 index 077a1b68b..000000000 --- a/packages/core/src/alteration/constants.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const logtoConfigsTableFilePath = 'node_modules/@logto/schemas/tables/logto_configs.sql'; -export const alterationFilesDirectorySource = 'node_modules/@logto/schemas/alterations'; -export const alterationFilesDirectory = 'alterations/'; diff --git a/packages/core/src/alteration/index.test.ts b/packages/core/src/alteration/index.test.ts deleted file mode 100644 index 651736ff7..000000000 --- a/packages/core/src/alteration/index.test.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { LogtoConfigKey, LogtoConfigs } from '@logto/schemas'; -import { createMockPool, createMockQueryResult, sql } from 'slonik'; - -import { convertToIdentifiers } from '@/database/utils'; -import { QueryType, expectSqlAssert } from '@/utils/test-utils'; - -import * as functions from '.'; - -const mockQuery: jest.MockedFunction = jest.fn(); -const { - createLogtoConfigsTable, - isLogtoConfigsTableExists, - updateDatabaseTimestamp, - getCurrentDatabaseTimestamp, - getUndeployedAlterations, -} = functions; -const pool = createMockPool({ - query: async (sql, values) => { - return mockQuery(sql, values); - }, -}); -const { table, fields } = convertToIdentifiers(LogtoConfigs); -const timestamp = 1_663_923_776; - -describe('isLogtoConfigsTableExists()', () => { - it('generates "select exists" sql and query for result', async () => { - const expectSql = sql` - select exists ( - select from - pg_tables - where - tablename = $1 - ); - `; - - mockQuery.mockImplementationOnce(async (sql, values) => { - expectSqlAssert(sql, expectSql.sql); - expect(values).toEqual([LogtoConfigs.table]); - - return createMockQueryResult([{ exists: true }]); - }); - - await expect(isLogtoConfigsTableExists(pool)).resolves.toEqual(true); - }); -}); - -describe('getCurrentDatabaseTimestamp()', () => { - it('returns null if query failed (table not found)', async () => { - mockQuery.mockRejectedValueOnce(new Error('table not found')); - - await expect(getCurrentDatabaseTimestamp(pool)).resolves.toBeNull(); - }); - - it('returns null if the row is not found', async () => { - const expectSql = sql` - select * from ${table} where ${fields.key}=$1 - `; - - mockQuery.mockImplementationOnce(async (sql, values) => { - expectSqlAssert(sql, expectSql.sql); - expect(values).toEqual([LogtoConfigKey.AlterationState]); - - return createMockQueryResult([]); - }); - - await expect(getCurrentDatabaseTimestamp(pool)).resolves.toBeNull(); - }); - - it('returns null if the value is in bad format', async () => { - const expectSql = sql` - select * from ${table} where ${fields.key}=$1 - `; - - mockQuery.mockImplementationOnce(async (sql, values) => { - expectSqlAssert(sql, expectSql.sql); - expect(values).toEqual([LogtoConfigKey.AlterationState]); - - return createMockQueryResult([{ value: 'some_value' }]); - }); - - await expect(getCurrentDatabaseTimestamp(pool)).resolves.toBeNull(); - }); - - it('returns the timestamp from database', async () => { - const expectSql = sql` - select * from ${table} where ${fields.key}=$1 - `; - - mockQuery.mockImplementationOnce(async (sql, values) => { - expectSqlAssert(sql, expectSql.sql); - expect(values).toEqual([LogtoConfigKey.AlterationState]); - - // @ts-expect-error createMockQueryResult doesn't support jsonb - return createMockQueryResult([{ value: { timestamp, updatedAt: 'now' } }]); - }); - - await expect(getCurrentDatabaseTimestamp(pool)).resolves.toEqual(timestamp); - }); -}); - -describe('createLogtoConfigsTable()', () => { - it('sends sql to create target table', async () => { - mockQuery.mockImplementationOnce(async (sql, values) => { - expect(sql).toContain(LogtoConfigs.table); - expect(sql).toContain('create table'); - - return createMockQueryResult([]); - }); - - await createLogtoConfigsTable(pool); - }); -}); - -describe('updateDatabaseTimestamp()', () => { - const expectSql = sql` - insert into ${table} (${fields.key}, ${fields.value}) - values ($1, $2::jsonb) - on conflict (${fields.key}) do update set ${fields.value}=excluded.${fields.value} - `; - const updatedAt = '2022-09-21T06:32:46.583Z'; - - beforeAll(() => { - jest.useFakeTimers(); - jest.setSystemTime(new Date(updatedAt)); - }); - - afterAll(() => { - jest.useRealTimers(); - }); - - it('calls createLogtoConfigsTable() if table does not exist', async () => { - mockQuery.mockImplementationOnce(async (sql, values) => { - expectSqlAssert(sql, expectSql.sql); - - return createMockQueryResult([]); - }); - - const mockCreateLogtoConfigsTable = jest - .spyOn(functions, 'createLogtoConfigsTable') - .mockImplementationOnce(jest.fn()); - jest.spyOn(functions, 'isLogtoConfigsTableExists').mockResolvedValueOnce(false); - - await updateDatabaseTimestamp(pool, timestamp); - expect(mockCreateLogtoConfigsTable).toHaveBeenCalled(); - }); - - it('sends upsert sql with timestamp and updatedAt', async () => { - mockQuery.mockImplementationOnce(async (sql, values) => { - expectSqlAssert(sql, expectSql.sql); - expect(values).toEqual([ - LogtoConfigKey.AlterationState, - JSON.stringify({ timestamp, updatedAt }), - ]); - - return createMockQueryResult([]); - }); - jest.spyOn(functions, 'isLogtoConfigsTableExists').mockResolvedValueOnce(true); - - await updateDatabaseTimestamp(pool, timestamp); - }); -}); - -describe('getUndeployedAlterations()', () => { - beforeEach(() => { - jest - .spyOn(functions, 'getAlterationFiles') - .mockResolvedValueOnce([ - '1.0.0-1663923770-a.js', - '1.0.0-1663923772-c.js', - '1.0.0-1663923771-b.js', - ]); - }); - - it('returns all files with right order if database timestamp is null', async () => { - jest.spyOn(functions, 'getCurrentDatabaseTimestamp').mockResolvedValueOnce(null); - - await expect(getUndeployedAlterations(pool)).resolves.toEqual([ - '1.0.0-1663923770-a.js', - '1.0.0-1663923771-b.js', - '1.0.0-1663923772-c.js', - ]); - }); - - it('returns files whose timestamp is greater then database timstamp', async () => { - jest.spyOn(functions, 'getCurrentDatabaseTimestamp').mockResolvedValueOnce(1_663_923_770); - - await expect(getUndeployedAlterations(pool)).resolves.toEqual([ - '1.0.0-1663923771-b.js', - '1.0.0-1663923772-c.js', - ]); - }); -}); diff --git a/packages/core/src/alteration/index.ts b/packages/core/src/alteration/index.ts deleted file mode 100644 index bb9468b94..000000000 --- a/packages/core/src/alteration/index.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { existsSync } from 'fs'; -import { readdir, readFile } from 'fs/promises'; -import path from 'path'; - -import { - LogtoConfig, - LogtoConfigs, - AlterationState, - alterationStateGuard, - LogtoConfigKey, -} from '@logto/schemas'; -import { AlterationScript } from '@logto/schemas/lib/types/alteration'; -import { conditionalString } from '@silverhand/essentials'; -import chalk from 'chalk'; -import { copy, remove } from 'fs-extra'; -import { DatabasePool, sql } from 'slonik'; -import { raw } from 'slonik-sql-tag-raw'; - -import { convertToIdentifiers } from '@/database/utils'; - -import { - logtoConfigsTableFilePath, - alterationFilesDirectory, - alterationFilesDirectorySource, -} from './constants'; -import { getTimestampFromFileName, alterationFileNameRegex } from './utils'; - -const { table, fields } = convertToIdentifiers(LogtoConfigs); - -export const isLogtoConfigsTableExists = async (pool: DatabasePool) => { - const { exists } = await pool.one<{ exists: boolean }>(sql` - select exists ( - select from - pg_tables - where - tablename = ${LogtoConfigs.table} - ); - `); - - return exists; -}; - -export const getCurrentDatabaseTimestamp = async (pool: DatabasePool) => { - try { - const query = await pool.maybeOne( - sql`select * from ${table} where ${fields.key}=${LogtoConfigKey.AlterationState}` - ); - const { timestamp } = alterationStateGuard.parse(query?.value); - - return timestamp; - } catch { - return null; - } -}; - -export const createLogtoConfigsTable = async (pool: DatabasePool) => { - const tableQuery = await readFile(logtoConfigsTableFilePath, 'utf8'); - await pool.query(sql`${raw(tableQuery)}`); -}; - -export const updateDatabaseTimestamp = async (pool: DatabasePool, timestamp?: number) => { - if (!(await isLogtoConfigsTableExists(pool))) { - await createLogtoConfigsTable(pool); - } - - const value: AlterationState = { - timestamp: timestamp ?? (await getLatestAlterationTimestamp()), - updatedAt: new Date().toISOString(), - }; - - await pool.query( - sql` - insert into ${table} (${fields.key}, ${fields.value}) - values (${LogtoConfigKey.AlterationState}, ${sql.jsonb(value)}) - on conflict (${fields.key}) do update set ${fields.value}=excluded.${fields.value} - ` - ); -}; - -export const getLatestAlterationTimestamp = async () => { - const files = await getAlterationFiles(); - - const latestFile = files[files.length - 1]; - - if (!latestFile) { - throw new Error('No alteration files found.'); - } - - return getTimestampFromFileName(latestFile); -}; - -export const getAlterationFiles = async () => { - if (!existsSync(alterationFilesDirectorySource)) { - return []; - } - - await remove(alterationFilesDirectory); - await copy(alterationFilesDirectorySource, alterationFilesDirectory); - - const directory = await readdir(alterationFilesDirectory); - const files = directory.filter((file) => alterationFileNameRegex.test(file)); - - return files - .slice() - .sort((file1, file2) => getTimestampFromFileName(file1) - getTimestampFromFileName(file2)); -}; - -export const getUndeployedAlterations = async (pool: DatabasePool) => { - const databaseTimestamp = await getCurrentDatabaseTimestamp(pool); - const files = await getAlterationFiles(); - - return files - .filter((file) => !databaseTimestamp || getTimestampFromFileName(file) > databaseTimestamp) - .slice() - .sort((file1, file2) => getTimestampFromFileName(file1) - getTimestampFromFileName(file2)); -}; - -const importAlteration = async (file: string): Promise => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const module = await import(path.join(process.cwd(), alterationFilesDirectory, file)); - - // eslint-disable-next-line no-restricted-syntax - return module.default as AlterationScript; -}; - -const deployAlteration = async (pool: DatabasePool, file: string) => { - const { up } = await importAlteration(file); - - try { - await pool.transaction(async (connect) => { - await up(connect); - }); - } catch (error: unknown) { - if (error instanceof Error) { - console.log(`${chalk.red('[alteration]')} run ${file} failed: ${error.message}.`); - - return; - } - - throw error; - } - - await updateDatabaseTimestamp(pool, getTimestampFromFileName(file)); - console.log(`${chalk.blue('[alteration]')} run ${file} succeeded.`); -}; - -export const deployAlterations = async (pool: DatabasePool) => { - const alterations = await getUndeployedAlterations(pool); - - console.log( - `${chalk.blue('[alteration]')} found ${alterations.length} alteration${conditionalString( - alterations.length > 1 && 's' - )}` - ); - - // The await inside the loop is intended, alterations should run in order - for (const alteration of alterations) { - // eslint-disable-next-line no-await-in-loop - await deployAlteration(pool, alteration); - } - - console.log(`${chalk.blue('[alteration]')} ✓ done`); -}; diff --git a/packages/core/src/alteration/utils.test.ts b/packages/core/src/alteration/utils.test.ts deleted file mode 100644 index 4239f79d6..000000000 --- a/packages/core/src/alteration/utils.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { getTimestampFromFileName } from './utils'; - -describe('getTimestampFromFileName()', () => { - it('should get for 1.0.0-1663923211.js', () => { - expect(getTimestampFromFileName('1.0.0-1663923211.js')).toEqual(1_663_923_211); - }); - - it('should get for 1.0.0-1663923211-user-table.js', () => { - expect(getTimestampFromFileName('1.0.0-1663923211-user-table.js')).toEqual(1_663_923_211); - }); - - it('should throw for 166392321.js', () => { - expect(() => getTimestampFromFileName('166392321.js')).toThrowError(); - }); -}); diff --git a/packages/core/src/alteration/utils.ts b/packages/core/src/alteration/utils.ts deleted file mode 100644 index 439dbc5f0..000000000 --- a/packages/core/src/alteration/utils.ts +++ /dev/null @@ -1,11 +0,0 @@ -export const alterationFileNameRegex = /-(\d{10,11})-?.*\.js$/; - -export const getTimestampFromFileName = (fileName: string) => { - const match = alterationFileNameRegex.exec(fileName); - - if (!match?.[1]) { - throw new Error(`Can not get timestamp: ${fileName}`); - } - - return Number(match[1]); -}; diff --git a/packages/core/src/cli/alteration.ts b/packages/core/src/cli/alteration.ts deleted file mode 100644 index 6a063ee28..000000000 --- a/packages/core/src/cli/alteration.ts +++ /dev/null @@ -1,23 +0,0 @@ -import 'module-alias/register'; -import { assertEnv } from '@silverhand/essentials'; -import { createPool } from 'slonik'; - -import { deployAlterations } from '@/alteration'; -import { configDotEnv } from '@/env-set/dot-env'; - -configDotEnv(); - -const deploy = async () => { - const databaseUrl = assertEnv('DB_URL'); - const pool = await createPool(databaseUrl); - await deployAlterations(pool); - await pool.end(); -}; - -const command = process.argv[2]; - -if (command !== 'deploy') { - throw new Error('Unsupported command.'); -} - -void deploy(); diff --git a/packages/core/src/env-set/check-alteration-state.ts b/packages/core/src/env-set/check-alteration-state.ts index 6b975a6ed..f1dfe0402 100644 --- a/packages/core/src/env-set/check-alteration-state.ts +++ b/packages/core/src/env-set/check-alteration-state.ts @@ -1,10 +1,6 @@ -import inquirer from 'inquirer'; +import { getUndeployedAlterations } from '@logto/cli/lib/commands/database/alteration'; import { DatabasePool } from 'slonik'; -import { getUndeployedAlterations, deployAlterations } from '@/alteration'; - -import { allYes } from './parameters'; - export const checkAlterationState = async (pool: DatabasePool) => { const alterations = await getUndeployedAlterations(pool); @@ -12,23 +8,7 @@ export const checkAlterationState = async (pool: DatabasePool) => { return; } - const error = new Error( + throw new Error( `Found undeployed database alterations, you must deploy them first by "npm run alteration deploy" command, reference: https://docs.logto.io/docs/recipes/deployment/#database-alteration` ); - - if (allYes) { - throw error; - } - - const deploy = await inquirer.prompt({ - type: 'confirm', - name: 'value', - message: `Found undeployed alterations, would you like to deploy now?`, - }); - - if (!deploy.value) { - throw error; - } - - await deployAlterations(pool); }; diff --git a/packages/schemas/tsconfig.json b/packages/schemas/tsconfig.json index 1195aba73..6dac33b10 100644 --- a/packages/schemas/tsconfig.json +++ b/packages/schemas/tsconfig.json @@ -7,6 +7,6 @@ "include": [ "src", "alterations", - "jest.config.ts", "alterations", + "jest.config.ts" ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ae6923110..1f5e9d26a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,10 +23,12 @@ importers: '@logto/schemas': ^1.0.0-beta.10 '@silverhand/eslint-config': 1.0.0 '@silverhand/essentials': ^1.2.1 + '@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 '@types/node': ^16.0.0 '@types/semver': ^7.3.12 '@types/tar': ^6.1.2 @@ -39,6 +41,7 @@ importers: got: ^11.8.2 hpagent: ^1.0.0 inquirer: ^8.2.2 + jest: ^28.1.3 lint-staged: ^13.0.0 nanoid: ^3.3.4 ora: ^5.0.0 @@ -76,15 +79,18 @@ importers: zod: 3.18.0 devDependencies: '@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 '@types/node': 16.11.12 '@types/semver': 7.3.12 '@types/tar': 6.1.2 '@types/yargs': 17.0.13 eslint: 8.21.0 + jest: 28.1.3_k5ytkvaprncdyzidqqws5bqksq lint-staged: 13.0.0 prettier: 2.7.1 rimraf: 3.0.2 @@ -696,7 +702,7 @@ packages: resolution: {integrity: sha512-hoyByceqwKirw7w3Z7gnIIZC3Wx3J484Y3L/cMpXFbr7d9ZQj2mODrirNzcJa+SM3UlpWXYvKV4RlRpFXlWgXg==} engines: {node: '>=6.0.0'} dependencies: - '@jridgewell/trace-mapping': 0.3.4 + '@jridgewell/trace-mapping': 0.3.15 dev: true /@babel/code-frame/7.16.7: @@ -1557,7 +1563,7 @@ packages: '@jest/test-result': 28.1.3 '@jest/transform': 28.1.3 '@jest/types': 28.1.3 - '@jridgewell/trace-mapping': 0.3.14 + '@jridgewell/trace-mapping': 0.3.15 '@types/node': 17.0.23 chalk: 4.1.2 collect-v8-coverage: 1.0.1 @@ -1592,7 +1598,7 @@ packages: resolution: {integrity: sha512-cV8Lx3BeStJb8ipPHnqVw/IM2VCMWO3crWZzYodSIkxXnRcXJipCdx1JCK0K5MsJJouZQTH73mzf4vgxRaH9ww==} engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} dependencies: - '@jridgewell/trace-mapping': 0.3.14 + '@jridgewell/trace-mapping': 0.3.15 callsites: 3.1.0 graceful-fs: 4.2.9 dev: true @@ -1623,7 +1629,7 @@ packages: dependencies: '@babel/core': 7.17.9 '@jest/types': 28.1.3 - '@jridgewell/trace-mapping': 0.3.14 + '@jridgewell/trace-mapping': 0.3.15 babel-plugin-istanbul: 6.1.1 chalk: 4.1.2 convert-source-map: 1.8.0 @@ -1659,7 +1665,7 @@ packages: '@types/istanbul-lib-coverage': 2.0.3 '@types/istanbul-reports': 3.0.1 '@types/node': 17.0.23 - '@types/yargs': 17.0.10 + '@types/yargs': 17.0.13 chalk: 4.1.2 dev: true @@ -1672,11 +1678,6 @@ packages: '@jridgewell/trace-mapping': 0.3.15 dev: true - /@jridgewell/resolve-uri/3.0.5: - resolution: {integrity: sha512-VPeQ7+wH0itvQxnG+lIzWgkysKIr3L9sslimFW55rHMdGu/qCQ5z5h9zq4gI8uBtqkpHhsF4Z/OwExufUCThew==} - engines: {node: '>=6.0.0'} - dev: true - /@jridgewell/resolve-uri/3.1.0: resolution: {integrity: sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==} engines: {node: '>=6.0.0'} @@ -1694,21 +1695,10 @@ packages: '@jridgewell/trace-mapping': 0.3.15 dev: true - /@jridgewell/sourcemap-codec/1.4.11: - resolution: {integrity: sha512-Fg32GrJo61m+VqYSdRSjRXMjQ06j8YIYfcTqndLYVAaHmroZHLJZCydsWBOTDqXS2v+mjxohBWEMfg97GXmYQg==} - dev: true - /@jridgewell/sourcemap-codec/1.4.14: resolution: {integrity: sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==} dev: true - /@jridgewell/trace-mapping/0.3.14: - resolution: {integrity: sha512-bJWEfQ9lPTvm3SneWwRFVLzrh6nhjwqw7TUFFBEMzwvg7t7PCDenf2lDwqo4NQXzdpgBXyFgDWnQA+2vkruksQ==} - dependencies: - '@jridgewell/resolve-uri': 3.0.5 - '@jridgewell/sourcemap-codec': 1.4.11 - dev: true - /@jridgewell/trace-mapping/0.3.15: resolution: {integrity: sha512-oWZNOULl+UbhsgB51uuZzglikfIKSUBO/M9W2OfEjn7cmqoAiCgmv9lyACTUacZwBz0ITnJ2NqjU8Tx0DHL88g==} dependencies: @@ -1716,13 +1706,6 @@ packages: '@jridgewell/sourcemap-codec': 1.4.14 dev: true - /@jridgewell/trace-mapping/0.3.4: - resolution: {integrity: sha512-vFv9ttIedivx0ux3QSjhgtCVjPZd5l46ZOMDSCwnH1yUO2e964gO8LZGyv2QkqcgR6TnBU1v+1IFqmeoG+0UJQ==} - dependencies: - '@jridgewell/resolve-uri': 3.0.5 - '@jridgewell/sourcemap-codec': 1.4.11 - dev: true - /@jridgewell/trace-mapping/0.3.9: resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} dependencies: @@ -4740,12 +4723,6 @@ packages: '@types/yargs-parser': 20.2.1 dev: true - /@types/yargs/17.0.10: - resolution: {integrity: sha512-gmEaFwpj/7f/ROdtIlci1R1VYU1J4j95m8T+Tj3iBgiBFKg1foE/PSl93bBd5T9LDXNPo8UlNN6W0qwD8O5OaA==} - dependencies: - '@types/yargs-parser': 20.2.1 - dev: true - /@types/yargs/17.0.13: resolution: {integrity: sha512-9sWaruZk2JGxIQU+IhI1fhPYRcQ0UuTNuKuCW9bR5fp7qi2Llf7WDzNa17Cy7TKnh3cdxDOiyTu6gaLS0eDatg==} dependencies: @@ -5826,7 +5803,7 @@ packages: dev: false /co/4.6.0: - resolution: {integrity: sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=} + resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} /code-point-at/1.1.0: @@ -6443,7 +6420,7 @@ packages: mimic-response: 3.1.0 /dedent/0.7.0: - resolution: {integrity: sha1-JJXduvbrh0q7Dhvp3yLS5aVEMmw=} + resolution: {integrity: sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==} dev: true /deep-equal/1.0.1: @@ -9162,7 +9139,7 @@ packages: jest-util: 28.1.3 jest-validate: 28.1.3 prompts: 2.4.2 - yargs: 17.4.1 + yargs: 17.6.0 transitivePeerDependencies: - '@types/node' - supports-color @@ -15395,7 +15372,7 @@ packages: resolution: {integrity: sha512-74Y4LqY74kLE6IFyIjPtkSTWzUZmj8tdHT9Ii/26dvQ6K9Dl2NbEfj0XgU2sHCtKgt5VupqhlO/5aWuqS+IY1w==} engines: {node: '>=10.12.0'} dependencies: - '@jridgewell/trace-mapping': 0.3.14 + '@jridgewell/trace-mapping': 0.3.15 '@types/istanbul-lib-coverage': 2.0.3 convert-source-map: 1.8.0 dev: true From 8c73067b48185ed5569612ee8d60d804e5fddec9 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Sat, 8 Oct 2022 00:54:48 +0800 Subject: [PATCH 38/54] refactor: improve dx --- packages/cli/package.json | 3 --- .../core/src/env-set/check-alteration-state.ts | 14 ++++++++++++-- packages/core/src/env-set/index.ts | 4 +++- packages/core/src/index.ts | 1 + 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 757a466c6..17ecac537 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -6,9 +6,6 @@ "homepage": "https://github.com/logto-io/logto#readme", "license": "MPL-2.0", "main": "lib/index.js", - "exports": { - ".": "./lib" - }, "bin": { "logto": "bin/logto", "lg": "bin/logto" diff --git a/packages/core/src/env-set/check-alteration-state.ts b/packages/core/src/env-set/check-alteration-state.ts index f1dfe0402..1a2c5d9b2 100644 --- a/packages/core/src/env-set/check-alteration-state.ts +++ b/packages/core/src/env-set/check-alteration-state.ts @@ -1,4 +1,5 @@ import { getUndeployedAlterations } from '@logto/cli/lib/commands/database/alteration'; +import chalk from 'chalk'; import { DatabasePool } from 'slonik'; export const checkAlterationState = async (pool: DatabasePool) => { @@ -8,7 +9,16 @@ export const checkAlterationState = async (pool: DatabasePool) => { return; } - throw new Error( - `Found undeployed database alterations, you must deploy them first by "npm run alteration deploy" command, reference: https://docs.logto.io/docs/recipes/deployment/#database-alteration` + console.error( + `${chalk.red( + '[error]' + )} Found undeployed database alterations, you must deploy them first by ${chalk.green( + 'npm run alteration deploy' + )} command.\n\n` + + ` See ${chalk.blue( + 'https://docs.logto.io/docs/recipes/deployment/#database-alteration' + )} for reference.\n` ); + + throw new Error(`Undeployed database alterations found.`); }; diff --git a/packages/core/src/env-set/index.ts b/packages/core/src/env-set/index.ts index a121c9939..67bb91b2b 100644 --- a/packages/core/src/env-set/index.ts +++ b/packages/core/src/env-set/index.ts @@ -76,7 +76,9 @@ function createEnvSet() { return pool; }, - + get poolSafe() { + return pool; + }, load: async () => { values = await loadEnvValues(); pool = await createPoolByEnv(values.isTest); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1ccc6f7fe..db09bb3d5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -27,5 +27,6 @@ import initI18n from './i18n/init'; await initApp(app); } catch (error: unknown) { console.log('Error while initializing app', error); + await envSet.poolSafe?.end(); } })(); From 4a5dfb1b8ace26a4d20cf42f4551b4aef229a24b Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Sat, 8 Oct 2022 17:42:14 +0800 Subject: [PATCH 39/54] refactor(cli): per review --- packages/cli/package.json | 3 +-- packages/cli/src/commands/database/{key.ts => config.ts} | 8 ++++---- packages/cli/src/commands/database/index.ts | 6 +++--- 3 files changed, 8 insertions(+), 9 deletions(-) rename packages/cli/src/commands/database/{key.ts => config.ts} (91%) diff --git a/packages/cli/package.json b/packages/cli/package.json index 17ecac537..d03402418 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -7,8 +7,7 @@ "license": "MPL-2.0", "main": "lib/index.js", "bin": { - "logto": "bin/logto", - "lg": "bin/logto" + "logto": "bin/logto" }, "files": [ "bin", diff --git a/packages/cli/src/commands/database/key.ts b/packages/cli/src/commands/database/config.ts similarity index 91% rename from packages/cli/src/commands/database/key.ts rename to packages/cli/src/commands/database/config.ts index ebf82f716..b819f2edd 100644 --- a/packages/cli/src/commands/database/key.ts +++ b/packages/cli/src/commands/database/config.ts @@ -27,8 +27,8 @@ const validateKeys: ValidateKeysFunction = (keys) => { } }; -export const getKey: CommandModule = { - command: 'get-key [keys...]', +export const getConfig: CommandModule = { + command: 'get-config [keys...]', describe: 'Get config value(s) of the given key(s) in Logto database', builder: (yargs) => yargs @@ -67,8 +67,8 @@ export const getKey: CommandModule = { }, }; -export const setKey: CommandModule = { - command: 'set-key ', +export const setConfig: CommandModule = { + command: 'set-config ', describe: 'Set config value of the given key in Logto database', builder: (yargs) => yargs diff --git a/packages/cli/src/commands/database/index.ts b/packages/cli/src/commands/database/index.ts index f7da167e9..086d51193 100644 --- a/packages/cli/src/commands/database/index.ts +++ b/packages/cli/src/commands/database/index.ts @@ -2,7 +2,7 @@ import { CommandModule } from 'yargs'; import { noop } from '../../utilities'; import alteration from './alteration'; -import { getKey, setKey } from './key'; +import { getConfig, setConfig } from './config'; import seed from './seed'; import { getUrl, setUrl } from './url'; @@ -13,8 +13,8 @@ const database: CommandModule = { yargs .command(getUrl) .command(setUrl) - .command(getKey) - .command(setKey) + .command(getConfig) + .command(setConfig) .command(seed) .command(alteration) .demandCommand(1), From 02c082cb71258a931925df87126060fa9d9a2c5d Mon Sep 17 00:00:00 2001 From: wangsijie Date: Sat, 8 Oct 2022 17:56:26 +0800 Subject: [PATCH 40/54] fix(console): show correct password after reset (#2063) --- .../src/pages/UserDetails/components/ResetPasswordForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/console/src/pages/UserDetails/components/ResetPasswordForm.tsx b/packages/console/src/pages/UserDetails/components/ResetPasswordForm.tsx index a32cd4791..c2ad145ba 100644 --- a/packages/console/src/pages/UserDetails/components/ResetPasswordForm.tsx +++ b/packages/console/src/pages/UserDetails/components/ResetPasswordForm.tsx @@ -19,7 +19,7 @@ const ResetPasswordForm = ({ onClose, userId }: Props) => { const onSubmit = async () => { const password = nanoid(8); await api.patch(`/api/users/${userId}/password`, { json: { password } }).json(); - onClose?.(btoa(password)); + onClose?.(password); }; return ( From a8c364e9b324bfee41cf7f224aa27a2614713859 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Sat, 8 Oct 2022 18:06:40 +0800 Subject: [PATCH 41/54] refactor(cli): use env to replace config json --- packages/cli/package.json | 2 +- .../cli/src/commands/database/alteration.ts | 4 +- packages/cli/src/commands/database/config.ts | 6 +-- packages/cli/src/commands/database/index.ts | 10 +---- packages/cli/src/commands/database/url.ts | 26 ----------- packages/cli/src/commands/install.ts | 4 +- packages/cli/src/config.ts | 45 ------------------- packages/cli/src/database.ts | 22 ++++----- packages/cli/src/index.ts | 3 ++ pnpm-lock.yaml | 10 ++++- 10 files changed, 28 insertions(+), 104 deletions(-) delete mode 100644 packages/cli/src/commands/database/url.ts delete mode 100644 packages/cli/src/config.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index d03402418..3ff2ba1a4 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -39,7 +39,7 @@ "@silverhand/essentials": "^1.2.1", "chalk": "^4.1.2", "decamelize": "^5.0.0", - "find-up": "^5.0.0", + "dotenv": "^16.0.0", "fs-extra": "^10.1.0", "got": "^11.8.2", "hpagent": "^1.0.0", diff --git a/packages/cli/src/commands/database/alteration.ts b/packages/cli/src/commands/database/alteration.ts index 19311a8c9..2e34892a7 100644 --- a/packages/cli/src/commands/database/alteration.ts +++ b/packages/cli/src/commands/database/alteration.ts @@ -7,7 +7,7 @@ import { copy, existsSync, remove, readdir } from 'fs-extra'; import { DatabasePool } from 'slonik'; import { CommandModule } from 'yargs'; -import { createPoolFromConfig } from '../../database'; +import { createPoolFromEnv } from '../../database'; import { getCurrentDatabaseAlterationTimestamp, updateDatabaseTimestamp, @@ -116,7 +116,7 @@ const alteration: CommandModule = { log.error('Unsupported action'); } - const pool = await createPoolFromConfig(); + const pool = await createPoolFromEnv(); 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 b819f2edd..2133cace2 100644 --- a/packages/cli/src/commands/database/config.ts +++ b/packages/cli/src/commands/database/config.ts @@ -2,7 +2,7 @@ import { logtoConfigGuards, LogtoConfigKey, logtoConfigKeys } from '@logto/schem import chalk from 'chalk'; import { CommandModule } from 'yargs'; -import { createPoolFromConfig } from '../../database'; +import { createPoolFromEnv } from '../../database'; import { getRowsByKeys, updateValueByKey } from '../../queries/logto-config'; import { deduplicate, log } from '../../utilities'; @@ -47,7 +47,7 @@ export const getConfig: CommandModule const queryKeys = deduplicate([key, ...keys]); validateKeys(queryKeys); - const pool = await createPoolFromConfig(); + const pool = await createPoolFromEnv(); const { rows } = await getRowsByKeys(pool, queryKeys); await pool.end(); @@ -87,7 +87,7 @@ export const setConfig: CommandModule = const guarded = logtoConfigGuards[key].parse(JSON.parse(value)); - const pool = await createPoolFromConfig(); + const pool = await createPoolFromEnv(); await updateValueByKey(pool, key, guarded); await pool.end(); diff --git a/packages/cli/src/commands/database/index.ts b/packages/cli/src/commands/database/index.ts index 086d51193..96e8f5ba0 100644 --- a/packages/cli/src/commands/database/index.ts +++ b/packages/cli/src/commands/database/index.ts @@ -4,20 +4,12 @@ import { noop } from '../../utilities'; import alteration from './alteration'; import { getConfig, setConfig } from './config'; import seed from './seed'; -import { getUrl, setUrl } from './url'; const database: CommandModule = { command: ['database', 'db'], describe: 'Commands for Logto database', builder: (yargs) => - yargs - .command(getUrl) - .command(setUrl) - .command(getConfig) - .command(setConfig) - .command(seed) - .command(alteration) - .demandCommand(1), + yargs.command(getConfig).command(setConfig).command(seed).command(alteration).demandCommand(1), handler: noop, }; diff --git a/packages/cli/src/commands/database/url.ts b/packages/cli/src/commands/database/url.ts deleted file mode 100644 index f9ee95a18..000000000 --- a/packages/cli/src/commands/database/url.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { CommandModule } from 'yargs'; - -import { getConfig, patchConfig } from '../../config'; - -export const getUrl: CommandModule = { - command: 'get-url', - describe: 'Get database URL in Logto config file', - handler: async () => { - const { databaseUrl } = await getConfig(); - console.log(databaseUrl); - }, -}; - -export const setUrl: CommandModule = { - command: 'set-url ', - describe: 'Set database URL and save to config file', - builder: (yargs) => - yargs.positional('url', { - describe: 'The database URL (DSN) to use, including database name', - type: 'string', - demandOption: true, - }), - handler: async (argv) => { - await patchConfig({ databaseUrl: String(argv.url) }); - }, -}; diff --git a/packages/cli/src/commands/install.ts b/packages/cli/src/commands/install.ts index 67ca4b6e1..3d502f37d 100644 --- a/packages/cli/src/commands/install.ts +++ b/packages/cli/src/commands/install.ts @@ -12,7 +12,7 @@ import * as semver from 'semver'; import tar from 'tar'; import { CommandModule } from 'yargs'; -import { createPoolAndDatabaseIfNeeded, getDatabaseUrlFromConfig } from '../database'; +import { createPoolAndDatabaseIfNeeded, getDatabaseUrlFromEnv } from '../database'; import { downloadFile, log, oraPromise, safeExecSync } from '../utilities'; import { seedByPool } from './database/seed'; @@ -149,7 +149,7 @@ const installLogto = async ({ path: pathArgument = defaultPath, silent = false } } // Save to dot env - const databaseUrl = await getDatabaseUrlFromConfig(); + const databaseUrl = await getDatabaseUrlFromEnv(); const dotEnvPath = path.resolve(instancePath, '.env'); await writeFile(dotEnvPath, `DB_URL=${databaseUrl}`, { encoding: 'utf8', diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts deleted file mode 100644 index 5b345f3d0..000000000 --- a/packages/cli/src/config.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { readFile, writeFile } from 'fs/promises'; -import os from 'os'; -import path from 'path'; - -import chalk from 'chalk'; -import findUp from 'find-up'; -// eslint-disable-next-line id-length -import z from 'zod'; - -import { log } from './utilities'; - -// Logto config -const logtoConfigFilename = '.logto.json'; -const getConfigPath = async () => - (await findUp(logtoConfigFilename)) ?? path.join(os.homedir(), logtoConfigFilename); - -const getConfigJson = async () => { - const configPath = await getConfigPath(); - - try { - const raw = await readFile(configPath, 'utf8'); - - // Prefer `unknown` over the original return type `any`, will guard later - // eslint-disable-next-line no-restricted-syntax - return JSON.parse(raw) as unknown; - } catch {} -}; - -const configGuard = z - .object({ - databaseUrl: z.string().optional(), - }) - .default({}); - -type LogtoConfig = z.infer; - -export const getConfig = async () => { - return configGuard.parse(await getConfigJson()); -}; - -export const patchConfig = async (config: LogtoConfig) => { - const configPath = await getConfigPath(); - await writeFile(configPath, JSON.stringify({ ...(await getConfig()), ...config }, undefined, 2)); - log.info(`Updated config in ${chalk.blue(configPath)}`); -}; diff --git a/packages/cli/src/database.ts b/packages/cli/src/database.ts index 7620a2f91..c64c6b3ba 100644 --- a/packages/cli/src/database.ts +++ b/packages/cli/src/database.ts @@ -6,13 +6,12 @@ import { createPool, IdentifierSqlToken, parseDsn, sql, SqlToken, stringifyDsn } import { createInterceptors } from 'slonik-interceptor-preset'; import { z } from 'zod'; -import { getConfig, patchConfig } from './config'; import { log } from './utilities'; export const defaultDatabaseUrl = 'postgresql://localhost:5432/logto'; -export const getDatabaseUrlFromConfig = async () => { - const { databaseUrl } = await getConfig(); +export const getDatabaseUrlFromEnv = async () => { + const { DB_URL: databaseUrl } = process.env; if (!databaseUrl) { const { value } = await inquirer @@ -24,18 +23,13 @@ export const getDatabaseUrlFromConfig = async () => { }) .catch(async (error) => { if (error.isTtyError) { - log.error( - `No database URL configured. Set it via ${chalk.green( - 'database set-url' - )} command first.` - ); + log.error('No database URL 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; }); - await patchConfig({ databaseUrl: value }); return value; } @@ -43,8 +37,8 @@ export const getDatabaseUrlFromConfig = async () => { return databaseUrl; }; -export const createPoolFromConfig = async () => { - const databaseUrl = await getDatabaseUrlFromConfig(); +export const createPoolFromEnv = async () => { + const databaseUrl = await getDatabaseUrlFromEnv(); return createPool(databaseUrl, { interceptors: createInterceptors(), @@ -59,7 +53,7 @@ export const createPoolFromConfig = async () => { */ export const createPoolAndDatabaseIfNeeded = async () => { try { - return await createPoolFromConfig(); + return await createPoolFromEnv(); } catch (error: unknown) { const result = z.object({ code: z.string() }).safeParse(error); @@ -69,7 +63,7 @@ export const createPoolAndDatabaseIfNeeded = async () => { log.error(error); } - const databaseUrl = await getDatabaseUrlFromConfig(); + const databaseUrl = await getDatabaseUrlFromEnv(); const dsn = parseDsn(databaseUrl); // It's ok to fall back to '?' since: // - Database name is required to connect in the previous pool @@ -86,7 +80,7 @@ export const createPoolAndDatabaseIfNeeded = async () => { log.info(`${chalk.green('✔')} Created database ${databaseName}`); - return createPoolFromConfig(); + return createPoolFromEnv(); } }; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index e25fd5f84..7881e8b98 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,9 +1,12 @@ +import dotenv from 'dotenv'; import yargs from 'yargs'; import { hideBin } from 'yargs/helpers'; import database from './commands/database'; import install from './commands/install'; +dotenv.config(); + void yargs(hideBin(process.argv)) .command(install) .command(database) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1f5e9d26a..cd37f7aec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,8 +35,8 @@ importers: '@types/yargs': ^17.0.13 chalk: ^4.1.2 decamelize: ^5.0.0 + dotenv: ^16.0.0 eslint: ^8.21.0 - find-up: ^5.0.0 fs-extra: ^10.1.0 got: ^11.8.2 hpagent: ^1.0.0 @@ -62,7 +62,7 @@ importers: '@silverhand/essentials': 1.2.1 chalk: 4.1.2 decamelize: 5.0.1 - find-up: 5.0.0 + dotenv: 16.0.0 fs-extra: 10.1.0 got: 11.8.3 hpagent: 1.0.0 @@ -7528,6 +7528,7 @@ packages: dependencies: locate-path: 6.0.0 path-exists: 4.0.0 + dev: true /flat-cache/3.0.4: resolution: {integrity: sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==} @@ -10327,6 +10328,7 @@ packages: engines: {node: '>=10'} dependencies: p-locate: 5.0.0 + dev: true /lodash._reinterpolate/3.0.0: resolution: {integrity: sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0=} @@ -11963,6 +11965,7 @@ packages: engines: {node: '>=10'} dependencies: yocto-queue: 0.1.0 + dev: true /p-locate/2.0.0: resolution: {integrity: sha512-nQja7m7gSKuewoVRen45CtVfODR3crN3goVQ0DDZ9N3yHxgpkuBhZqsaiotSQRrADUrne346peY7kT3TSACykg==} @@ -11983,6 +11986,7 @@ packages: engines: {node: '>=10'} dependencies: p-limit: 3.1.0 + dev: true /p-map-series/2.1.0: resolution: {integrity: sha512-RpYIIK1zXSNEOdwxcfe7FdvGcs7+y5n8rifMhMNWvaxRNMPINJHF5GDeuVxWqnfrcHPSCnp7Oo5yNXHId9Av2Q==} @@ -12247,6 +12251,7 @@ packages: /path-exists/4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} + dev: true /path-is-absolute/1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} @@ -15810,6 +15815,7 @@ packages: /yocto-queue/0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + dev: true /zod/3.18.0: resolution: {integrity: sha512-gwTm8RfUCe8l9rDwN5r2A17DkAa8Ez4Yl4yXqc5VqeGaXaJahzYYXbTwvhroZi0SNBqTwh/bKm2N0mpCzuw4bA==} From cc5c99beb02537820da980d07d2e142db73bf1d5 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Sat, 8 Oct 2022 18:18:49 +0800 Subject: [PATCH 42/54] refactor(cli): support dotenv --- packages/cli/src/index.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 7881e8b98..c8d57814d 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -5,9 +5,15 @@ import { hideBin } from 'yargs/helpers'; import database from './commands/database'; import install from './commands/install'; -dotenv.config(); - void yargs(hideBin(process.argv)) + .option('env', { + alias: ['e', 'env-file'], + describe: 'The path to your `.env` file', + type: 'string', + }) + .middleware(({ env }) => { + dotenv.config({ path: env }); + }) .command(install) .command(database) .demandCommand(1) From e5e0c42104430e346fb4318ffc83a700093c38e8 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Sat, 8 Oct 2022 19:30:06 +0800 Subject: [PATCH 43/54] chore: fix integration test --- .github/workflows/integration-test.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 562ed98ed..e61b976bf 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -85,9 +85,9 @@ jobs: - name: Seed database working-directory: logto/packages/core - run: | - npm run cli db set-url postgres://postgres:postgres@localhost:5432 - npm run cli db seed + run: npm run cli db seed + env: + DB_URL: postgres://postgres:postgres@localhost:5432 - name: Run Logto working-directory: logto/packages/core From 911117a785fd43ea03473f42835f2680cccca7be Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Sat, 8 Oct 2022 23:27:43 +0800 Subject: [PATCH 44/54] feat(cli): `db seed oidc` command --- packages/cli/src/commands/database/seed.ts | 88 ---------- .../cli/src/commands/database/seed/index.ts | 153 ++++++++++++++++++ .../src/commands/database/seed/oidc-config.ts | 93 +++++++++++ packages/cli/src/commands/install.ts | 5 +- packages/cli/src/database.ts | 38 +---- packages/cli/src/queries/logto-config.ts | 5 +- packages/cli/src/utilities.ts | 44 ++++- packages/schemas/src/types/logto-config.ts | 5 + 8 files changed, 308 insertions(+), 123 deletions(-) delete mode 100644 packages/cli/src/commands/database/seed.ts create mode 100644 packages/cli/src/commands/database/seed/index.ts create mode 100644 packages/cli/src/commands/database/seed/oidc-config.ts diff --git a/packages/cli/src/commands/database/seed.ts b/packages/cli/src/commands/database/seed.ts deleted file mode 100644 index 7f7611a9d..000000000 --- a/packages/cli/src/commands/database/seed.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { readdir, readFile } from 'fs/promises'; -import path from 'path'; - -import { seeds } from '@logto/schemas'; -import chalk from 'chalk'; -import { DatabasePool, DatabaseTransactionConnection, sql } from 'slonik'; -import { raw } from 'slonik-sql-tag-raw'; -import { CommandModule } from 'yargs'; - -import { createPoolAndDatabaseIfNeeded, insertInto } from '../../database'; -import { updateDatabaseTimestamp } from '../../queries/logto-config'; -import { buildApplicationSecret, getPathInModule, log, oraPromise } from '../../utilities'; -import { getLatestAlterationTimestamp } from './alteration'; - -const createTables = async (connection: DatabaseTransactionConnection) => { - const tableDirectory = getPathInModule('@logto/schemas', 'tables'); - const directoryFiles = await readdir(tableDirectory); - const tableFiles = directoryFiles.filter((file) => file.endsWith('.sql')); - const queries = await Promise.all( - tableFiles.map>(async (file) => [ - file, - await readFile(path.join(tableDirectory, file), 'utf8'), - ]) - ); - - // Await in loop is intended for better error handling - for (const [, query] of queries) { - // eslint-disable-next-line no-await-in-loop - await connection.query(sql`${raw(query)}`); - } -}; - -const seedTables = async (connection: DatabaseTransactionConnection) => { - const { - managementResource, - defaultSignInExperience, - createDefaultSetting, - createDemoAppApplication, - defaultRole, - } = seeds; - - await Promise.all([ - connection.query(insertInto(managementResource, 'resources')), - connection.query(insertInto(createDefaultSetting(), 'settings')), - connection.query(insertInto(defaultSignInExperience, 'sign_in_experiences')), - connection.query( - insertInto(createDemoAppApplication(buildApplicationSecret()), 'applications') - ), - connection.query(insertInto(defaultRole, 'roles')), - updateDatabaseTimestamp(connection, await getLatestAlterationTimestamp()), - ]); -}; - -export const seedByPool = async (pool: DatabasePool) => { - await pool.transaction(async (connection) => { - await oraPromise(createTables(connection), { - text: 'Create tables', - prefixText: chalk.blue('[info]'), - }); - await oraPromise(seedTables(connection), { - text: 'Seed data', - prefixText: chalk.blue('[info]'), - }); - }); -}; - -const seed: CommandModule = { - command: 'seed', - describe: 'Create database and seed tables and data', - handler: async () => { - const pool = await createPoolAndDatabaseIfNeeded(); - - try { - await seedByPool(pool); - } catch (error: unknown) { - console.error(error); - console.log(); - log.warn( - 'Error ocurred during seeding your database.\n\n' + - ' Nothing has changed since the seeding process was in a transaction.\n' + - ' Try to fix the error and seed again.' - ); - } - await pool.end(); - }, -}; - -export default seed; diff --git a/packages/cli/src/commands/database/seed/index.ts b/packages/cli/src/commands/database/seed/index.ts new file mode 100644 index 000000000..1c7e18f01 --- /dev/null +++ b/packages/cli/src/commands/database/seed/index.ts @@ -0,0 +1,153 @@ +import { readdir, readFile } from 'fs/promises'; +import path from 'path'; + +import { LogtoConfigKey, LogtoOidcConfig, logtoOidcConfigGuard, seeds } from '@logto/schemas'; +import chalk from 'chalk'; +import { DatabasePool, DatabaseTransactionConnection, sql } from 'slonik'; +import { raw } from 'slonik-sql-tag-raw'; +import { CommandModule } from 'yargs'; + +import { createPoolAndDatabaseIfNeeded, insertInto } from '../../../database'; +import { + getRowsByKeys, + updateDatabaseTimestamp, + updateValueByKey, +} from '../../../queries/logto-config'; +import { buildApplicationSecret, getPathInModule, log, oraPromise } from '../../../utilities'; +import { getLatestAlterationTimestamp } from '../alteration'; +import { OidcConfigKey, oidcConfigReaders } from './oidc-config'; + +const createTables = async (connection: DatabaseTransactionConnection) => { + const tableDirectory = getPathInModule('@logto/schemas', 'tables'); + const directoryFiles = await readdir(tableDirectory); + const tableFiles = directoryFiles.filter((file) => file.endsWith('.sql')); + const queries = await Promise.all( + tableFiles.map>(async (file) => [ + file, + await readFile(path.join(tableDirectory, file), 'utf8'), + ]) + ); + + // Await in loop is intended for better error handling + for (const [, query] of queries) { + // eslint-disable-next-line no-await-in-loop + await connection.query(sql`${raw(query)}`); + } +}; + +const seedTables = async (connection: DatabaseTransactionConnection) => { + const { + managementResource, + defaultSignInExperience, + createDefaultSetting, + createDemoAppApplication, + defaultRole, + } = seeds; + + await Promise.all([ + connection.query(insertInto(managementResource, 'resources')), + connection.query(insertInto(createDefaultSetting(), 'settings')), + connection.query(insertInto(defaultSignInExperience, 'sign_in_experiences')), + connection.query( + insertInto(createDemoAppApplication(buildApplicationSecret()), 'applications') + ), + connection.query(insertInto(defaultRole, 'roles')), + updateDatabaseTimestamp(connection, await getLatestAlterationTimestamp()), + ]); +}; + +const seedOidcConfigs = async (pool: DatabaseTransactionConnection) => { + const { rows } = await getRowsByKeys(pool, [LogtoConfigKey.OidcConfig]); + const existingConfig = await logtoOidcConfigGuard + .parseAsync(rows[0]?.value) + // It's useful! + // eslint-disable-next-line unicorn/no-useless-undefined + .catch(() => undefined); + const existingKeys = existingConfig ? Object.keys(existingConfig) : []; + const validOptions = OidcConfigKey.options.filter((key) => { + const included = existingKeys.includes(key); + + if (included) { + log.info(`Key ${chalk.green(key)} exists, skipping`); + } + + return !included; + }); + + const entries: Array<[keyof LogtoOidcConfig, LogtoOidcConfig[keyof LogtoOidcConfig]]> = []; + + // Both await in loop and `.push()` are intended since we'd like to log info in sequence + for (const key of validOptions) { + // eslint-disable-next-line no-await-in-loop + const { value, fromEnv } = await oidcConfigReaders[key](); + + if (fromEnv) { + log.info(`Read config ${chalk.green(key)} from env`); + } else { + log.info(`Generated config ${chalk.green(key)}`); + } + + // eslint-disable-next-line @silverhand/fp/no-mutating-methods + entries.push([key, value]); + } + + await updateValueByKey(pool, LogtoConfigKey.OidcConfig, { + ...existingConfig, + ...Object.fromEntries(entries), + }); + log.succeed('Seed OIDC config'); +}; + +const seedChoices = Object.freeze(['all', 'oidc'] as const); + +type SeedChoice = typeof seedChoices[number]; + +export const seedByPool = async (pool: DatabasePool, type: SeedChoice) => { + await pool.transaction(async (connection) => { + if (type !== 'oidc') { + await oraPromise(createTables(connection), { + text: 'Create tables', + prefixText: chalk.blue('[info]'), + }); + await oraPromise(seedTables(connection), { + text: 'Seed data', + prefixText: chalk.blue('[info]'), + }); + } + + await seedOidcConfigs(connection); + }); +}; + +const seed: CommandModule, { type: string }> = { + command: 'seed [type]', + describe: 'Create database then seed tables and data', + builder: (yargs) => + yargs.positional('type', { + describe: 'Optional seed type', + type: 'string', + choices: seedChoices, + default: 'all', + }), + handler: async ({ type }) => { + const pool = await createPoolAndDatabaseIfNeeded(); + + try { + // Cannot avoid `as` since the official type definition of `yargs` doesn't work. + // The value of `type` can be ensured, so it's safe to use `as` here. + // eslint-disable-next-line no-restricted-syntax + await seedByPool(pool, type as SeedChoice); + } catch (error: unknown) { + console.error(error); + console.log(); + log.warn( + 'Error ocurred during seeding your database.\n\n' + + ' Nothing has changed since the seeding process was in a transaction.\n' + + ' Try to fix the error and seed again.' + ); + } + await pool.end(); + }, +}; + +export default seed; diff --git a/packages/cli/src/commands/database/seed/oidc-config.ts b/packages/cli/src/commands/database/seed/oidc-config.ts new file mode 100644 index 000000000..2b8d1fb05 --- /dev/null +++ b/packages/cli/src/commands/database/seed/oidc-config.ts @@ -0,0 +1,93 @@ +import { generateKeyPair } from 'crypto'; +import { readFile } from 'fs/promises'; +import { promisify } from 'util'; + +import { LogtoOidcConfig, logtoOidcConfigGuard } from '@logto/schemas'; +import { getEnv, getEnvAsStringArray } from '@silverhand/essentials'; +import { nanoid } from 'nanoid'; +import { z } from 'zod'; + +const isBase64FormatPrivateKey = (key: string) => !key.includes('-'); + +export const OidcConfigKey = logtoOidcConfigGuard.keyof(); + +/** + * Each config reader will do the following things in order: + * 1. Try to read value from env (mimic the behavior from the original core) + * 2. Generate value if #1 doesn't work + */ +export const oidcConfigReaders: { + [key in z.infer]: () => Promise<{ + value: NonNullable; + fromEnv: boolean; + }>; +} = { + /** + * Try to read private keys with the following order: + * + * 1. From `process.env.OIDC_PRIVATE_KEYS`. + * 2. Fetch path from `process.env.OIDC_PRIVATE_KEY_PATHS` then read from that path. + * + * + * @returns The private keys for OIDC provider. + * @throws An error when failed to read a private key. + */ + privateKeys: async () => { + // Direct keys in env + const privateKeys = getEnvAsStringArray('OIDC_PRIVATE_KEYS'); + + if (privateKeys.length > 0) { + return { + value: privateKeys.map((key) => { + if (isBase64FormatPrivateKey(key)) { + return Buffer.from(key, 'base64').toString('utf8'); + } + + return key; + }), + fromEnv: true, + }; + } + + // Read keys from files + const privateKeyPaths = getEnvAsStringArray('OIDC_PRIVATE_KEY_PATHS'); + + if (privateKeyPaths.length > 0) { + return { + value: await Promise.all(privateKeyPaths.map(async (path) => readFile(path, 'utf8'))), + fromEnv: true, + }; + } + + // Generate a new key + const { privateKey } = await promisify(generateKeyPair)('rsa', { + modulusLength: 4096, + publicKeyEncoding: { + type: 'spki', + format: 'pem', + }, + privateKeyEncoding: { + type: 'pkcs8', + format: 'pem', + }, + }); + + return { + value: [privateKey], + fromEnv: false, + }; + }, + cookieKeys: async () => { + const envKey = 'OIDC_COOKIE_KEYS'; + const keys = getEnvAsStringArray(envKey); + + return { value: keys.length > 0 ? keys : [nanoid()], fromEnv: keys.length > 0 }; + }, + refreshTokenReuseInterval: async () => { + const envKey = 'OIDC_REFRESH_TOKEN_REUSE_INTERVAL'; + const raw = Number(getEnv(envKey)); + const value = Math.max(3, raw || 0); + + return { value, fromEnv: raw === value }; + }, +}; diff --git a/packages/cli/src/commands/install.ts b/packages/cli/src/commands/install.ts index 3d502f37d..050f6fd70 100644 --- a/packages/cli/src/commands/install.ts +++ b/packages/cli/src/commands/install.ts @@ -122,7 +122,7 @@ const installLogto = async ({ path: pathArgument = defaultPath, silent = false } try { // Seed database const pool = await createPoolAndDatabaseIfNeeded(); // It will ask for database URL and save to config - await seedByPool(pool); + await seedByPool(pool, 'all'); await pool.end(); } catch (error: unknown) { console.error(error); @@ -131,7 +131,8 @@ const installLogto = async ({ path: pathArgument = defaultPath, silent = false } name: 'value', type: 'confirm', message: - 'Error occurred during seeding your Logto database. Would you like to continue without seed?', + '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, }); diff --git a/packages/cli/src/database.ts b/packages/cli/src/database.ts index c64c6b3ba..f9bdf8ed7 100644 --- a/packages/cli/src/database.ts +++ b/packages/cli/src/database.ts @@ -1,41 +1,19 @@ import { SchemaLike, SchemaValue, SchemaValuePrimitive } from '@logto/schemas'; -import chalk from 'chalk'; import decamelize from 'decamelize'; -import inquirer from 'inquirer'; import { createPool, IdentifierSqlToken, parseDsn, sql, SqlToken, stringifyDsn } from 'slonik'; import { createInterceptors } from 'slonik-interceptor-preset'; import { z } from 'zod'; -import { log } from './utilities'; +import { getCliConfig, log } from './utilities'; export const defaultDatabaseUrl = 'postgresql://localhost:5432/logto'; -export const getDatabaseUrlFromEnv = async () => { - const { DB_URL: databaseUrl } = process.env; - - if (!databaseUrl) { - const { value } = await inquirer - .prompt<{ value: string }>({ - type: 'input', - name: 'value', - message: 'Enter your Logto database URL', - default: defaultDatabaseUrl, - }) - .catch(async (error) => { - if (error.isTtyError) { - log.error('No database URL 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; - }); - - return value; - } - - return databaseUrl; -}; +export const getDatabaseUrlFromEnv = async () => + (await getCliConfig({ + key: 'DB_URL', + readableKey: 'Logto database URL', + defaultValue: defaultDatabaseUrl, + })) ?? ''; export const createPoolFromEnv = async () => { const databaseUrl = await getDatabaseUrlFromEnv(); @@ -78,7 +56,7 @@ export const createPoolAndDatabaseIfNeeded = async () => { `); await maintenancePool.end(); - log.info(`${chalk.green('✔')} Created database ${databaseName}`); + log.succeed(`Created database ${databaseName}`); return createPoolFromEnv(); } diff --git a/packages/cli/src/queries/logto-config.ts b/packages/cli/src/queries/logto-config.ts index c151aba70..51083e74a 100644 --- a/packages/cli/src/queries/logto-config.ts +++ b/packages/cli/src/queries/logto-config.ts @@ -13,7 +13,10 @@ import { convertToIdentifiers } from '../database'; const { table, fields } = convertToIdentifiers(LogtoConfigs); -export const getRowsByKeys = async (pool: DatabasePool, keys: LogtoConfigKey[]) => +export const getRowsByKeys = async ( + pool: DatabasePool | DatabaseTransactionConnection, + keys: LogtoConfigKey[] +) => pool.query(sql` select ${sql.join([fields.key, fields.value], sql`,`)} from ${table} where ${fields.key} in (${sql.join(keys, sql`,`)}) diff --git a/packages/cli/src/utilities.ts b/packages/cli/src/utilities.ts index 72f6257f0..0cac14aa6 100644 --- a/packages/cli/src/utilities.ts +++ b/packages/cli/src/utilities.ts @@ -2,9 +2,11 @@ import { execSync } from 'child_process'; import { createWriteStream } from 'fs'; import path from 'path'; +import { conditionalString } from '@silverhand/essentials'; import chalk from 'chalk'; import got, { Progress } from 'got'; import { HttpsProxyAgent } from 'hpagent'; +import inquirer from 'inquirer'; import { customAlphabet } from 'nanoid'; import ora from 'ora'; @@ -16,6 +18,7 @@ export const safeExecSync = (command: string) => { type Log = Readonly<{ info: typeof console.log; + succeed: typeof console.log; warn: typeof console.log; error: (...args: Parameters) => never; }>; @@ -24,11 +27,14 @@ export const log: Log = Object.freeze({ info: (...args) => { console.log(chalk.blue('[info]'), ...args); }, + succeed: (...args) => { + console.log(chalk.green('[succeed] ✔'), ...args); + }, warn: (...args) => { - console.log(chalk.yellow('[warn]'), ...args); + console.warn(chalk.yellow('[warn]'), ...args); }, error: (...args) => { - console.log(chalk.red('[error]'), ...args); + console.error(chalk.red('[error]'), ...args); // eslint-disable-next-line unicorn/no-process-exit process.exit(1); }, @@ -103,6 +109,40 @@ export const oraPromise = async ( } }; +export type GetCliConfig = { + key: string; + readableKey: string; + comments?: string; + defaultValue?: string; +}; + +export const getCliConfig = async ({ key, readableKey, comments, defaultValue }: GetCliConfig) => { + const { [key]: value } = process.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; + }); + + return input; + } + + return value; +}; + // TODO: Move to `@silverhand/essentials` // Intended // eslint-disable-next-line @typescript-eslint/no-empty-function diff --git a/packages/schemas/src/types/logto-config.ts b/packages/schemas/src/types/logto-config.ts index dd0c52e23..d4b2e57d6 100644 --- a/packages/schemas/src/types/logto-config.ts +++ b/packages/schemas/src/types/logto-config.ts @@ -12,6 +12,11 @@ export type AlterationState = z.infer; export const logtoOidcConfigGuard = z.object({ privateKeys: z.string().array().optional(), cookieKeys: z.string().array().optional(), + /** + * This interval helps to avoid concurrency issues when exchanging the rotating refresh token multiple times within a given timeframe. + * During the leeway window (in seconds), the consumed refresh token will be considered as valid. + * This is useful for distributed apps and serverless apps like Next.js, in which there is no shared memory. + */ refreshTokenReuseInterval: z.number().gte(3).optional(), }); From 7d40c2603add6d527093d7c8ab5fd6e50cfdcb3f Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Sat, 8 Oct 2022 23:42:42 +0800 Subject: [PATCH 45/54] refactor(cli): fixing cli config and log issues --- packages/cli/src/utilities.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/utilities.ts b/packages/cli/src/utilities.ts index 0cac14aa6..2f03ceaf9 100644 --- a/packages/cli/src/utilities.ts +++ b/packages/cli/src/utilities.ts @@ -2,7 +2,7 @@ import { execSync } from 'child_process'; import { createWriteStream } from 'fs'; import path from 'path'; -import { conditionalString } from '@silverhand/essentials'; +import { conditionalString, Optional } from '@silverhand/essentials'; import chalk from 'chalk'; import got, { Progress } from 'got'; import { HttpsProxyAgent } from 'hpagent'; @@ -28,7 +28,7 @@ export const log: Log = Object.freeze({ console.log(chalk.blue('[info]'), ...args); }, succeed: (...args) => { - console.log(chalk.green('[succeed] ✔'), ...args); + log.info(chalk.green('✔'), ...args); }, warn: (...args) => { console.warn(chalk.yellow('[warn]'), ...args); @@ -109,6 +109,8 @@ export const oraPromise = async ( } }; +const cliConfig = new Map>(); + export type GetCliConfig = { key: string; readableKey: string; @@ -117,6 +119,10 @@ export type GetCliConfig = { }; export const getCliConfig = async ({ key, readableKey, comments, defaultValue }: GetCliConfig) => { + if (cliConfig.has(key)) { + return cliConfig.get(key); + } + const { [key]: value } = process.env; if (!value) { @@ -137,9 +143,13 @@ export const getCliConfig = async ({ key, readableKey, comments, defaultValue }: throw error; }); + cliConfig.set(key, input); + return input; } + cliConfig.set(key, value); + return value; }; From 37d2b0ce5c09658d5e49be84b891d9a0d83f6f5c Mon Sep 17 00:00:00 2001 From: simeng-li Date: Sun, 9 Oct 2022 15:07:09 +0800 Subject: [PATCH 46/54] feat(console): add a11y lint to ac (#2066) --- packages/console/package.json | 2 +- .../AppContent/components/UserInfo/index.tsx | 12 +- .../console/src/components/AppError/index.tsx | 6 + .../src/components/CopyToClipboard/index.tsx | 6 + .../components/DeleteConfirmModal/index.tsx | 1 + .../console/src/components/Drawer/index.tsx | 2 + .../src/components/Dropdown/DropdownItem.tsx | 14 +- .../console/src/components/Dropdown/index.tsx | 9 +- .../src/components/MultiTextInput/index.tsx | 9 +- .../src/components/RadioGroup/Radio.tsx | 2 + .../console/src/components/Select/index.tsx | 7 + .../src/components/TabNav/TabNavItem.tsx | 11 +- .../mdx-components/DetailsSummary/index.tsx | 23 +- .../console/src/mdx-components/Step/index.tsx | 20 +- .../components/CreateForm/index.tsx | 1 + .../src/pages/ConnectorDetails/index.tsx | 1 + .../components/ConnectorName/index.tsx | 1 + .../components/CreateForm/index.tsx | 2 +- .../components/GetStartedProgress/index.tsx | 24 +- .../console/src/pages/GetStarted/index.tsx | 24 +- .../SignInExperience/components/Preview.tsx | 14 +- .../UserDetails/components/UserConnectors.tsx | 2 +- .../console/src/pages/UserDetails/index.tsx | 1 + .../Users/components/CreateForm/index.tsx | 1 + packages/console/src/pages/Users/index.tsx | 1 + packages/console/src/utilities/a11y.ts | 21 ++ pnpm-lock.yaml | 270 +++++++++++++++++- 27 files changed, 438 insertions(+), 49 deletions(-) create mode 100644 packages/console/src/utilities/a11y.ts diff --git a/packages/console/package.json b/packages/console/package.json index 567343277..c621bc026 100644 --- a/packages/console/package.json +++ b/packages/console/package.json @@ -29,7 +29,7 @@ "@parcel/transformer-sass": "2.7.0", "@parcel/transformer-svg-react": "2.7.0", "@silverhand/eslint-config": "1.0.0", - "@silverhand/eslint-config-react": "1.0.0", + "@silverhand/eslint-config-react": "1.1.0", "@silverhand/essentials": "^1.2.1", "@silverhand/ts-config": "1.0.0", "@silverhand/ts-config-react": "1.0.0", diff --git a/packages/console/src/components/AppContent/components/UserInfo/index.tsx b/packages/console/src/components/AppContent/components/UserInfo/index.tsx index 5586296a1..9913a48ad 100644 --- a/packages/console/src/components/AppContent/components/UserInfo/index.tsx +++ b/packages/console/src/components/AppContent/components/UserInfo/index.tsx @@ -1,12 +1,13 @@ import { useLogto, IdTokenClaims } from '@logto/react'; import classNames from 'classnames'; -import { useEffect, useRef, useState, MouseEvent } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import Dropdown, { DropdownItem } from '@/components/Dropdown'; import { Ring as Spinner } from '@/components/Spinner'; import { generateAvatarPlaceHolderById } from '@/consts/avatars'; import SignOut from '@/icons/SignOut'; +import { onKeyDownHandler } from '@/utilities/a11y'; import UserInfoSkeleton from '../UserInfoSkeleton'; import * as styles from './index.module.scss'; @@ -43,13 +44,18 @@ const UserInfo = () => { <>
{ + setShowDropdown(true); + })} onClick={() => { setShowDropdown(true); }} > {/* TODO: revert after SDK updated */} - + avatar
{username}
@@ -66,7 +72,7 @@ const UserInfo = () => { } - onClick={(event: MouseEvent) => { + onClick={(event) => { event.stopPropagation(); if (isLoading) { diff --git a/packages/console/src/components/AppError/index.tsx b/packages/console/src/components/AppError/index.tsx index 23bee6bfc..046906126 100644 --- a/packages/console/src/components/AppError/index.tsx +++ b/packages/console/src/components/AppError/index.tsx @@ -6,6 +6,7 @@ import ErrorDark from '@/assets/images/error-dark.svg'; import Error from '@/assets/images/error.svg'; import { useTheme } from '@/hooks/use-theme'; import { KeyboardArrowDown, KeyboardArrowUp } from '@/icons/Arrow'; +import { onKeyDownHandler } from '@/utilities/a11y'; import * as styles from './index.module.scss'; @@ -33,7 +34,12 @@ const AppError = ({ title, errorCode, errorMessage, callStack, children }: Props {errorMessage} {callStack && ( { + setIsDetailsOpen(!isDetailsOpen); + })} onClick={() => { setIsDetailsOpen(!isDetailsOpen); }} diff --git a/packages/console/src/components/CopyToClipboard/index.tsx b/packages/console/src/components/CopyToClipboard/index.tsx index 9fb8b7d89..66a0c4414 100644 --- a/packages/console/src/components/CopyToClipboard/index.tsx +++ b/packages/console/src/components/CopyToClipboard/index.tsx @@ -5,6 +5,7 @@ import { TFuncKey, useTranslation } from 'react-i18next'; import Copy from '@/icons/Copy'; import Eye from '@/icons/Eye'; import EyeClosed from '@/icons/EyeClosed'; +import { onKeyDownHandler } from '@/utilities/a11y'; import IconButton from '../IconButton'; import Tooltip from '../Tooltip'; @@ -57,6 +58,11 @@ const CopyToClipboard = ({ return (
{ + event.stopPropagation(); + })} onClick={(event) => { event.stopPropagation(); }} diff --git a/packages/console/src/components/DeleteConfirmModal/index.tsx b/packages/console/src/components/DeleteConfirmModal/index.tsx index 6772d6872..d0471158c 100644 --- a/packages/console/src/components/DeleteConfirmModal/index.tsx +++ b/packages/console/src/components/DeleteConfirmModal/index.tsx @@ -40,6 +40,7 @@ const DeleteConfirmModal = ({ {children} {expectedInput && ( { return ( ) => void; + onClick?: (event: MouseEvent | KeyboardEvent) => void; className?: string; children: ReactNode | Record; icon?: ReactNode; @@ -20,7 +22,13 @@ const DropdownItem = ({ iconClassName, type = 'default', }: Props) => ( -
  • +
  • {icon && {icon}} {children}
  • diff --git a/packages/console/src/components/Dropdown/index.tsx b/packages/console/src/components/Dropdown/index.tsx index 30c10056f..ddb0a10bf 100644 --- a/packages/console/src/components/Dropdown/index.tsx +++ b/packages/console/src/components/Dropdown/index.tsx @@ -3,6 +3,7 @@ import { ReactNode, RefObject, useRef } from 'react'; import ReactModal from 'react-modal'; import usePosition, { HorizontalAlignment } from '@/hooks/use-position'; +import { onKeyDownHandler } from '@/utilities/a11y'; import * as styles from './index.module.scss'; @@ -61,7 +62,13 @@ const Dropdown = ({ >
    {title &&
    {title}
    } -
      +
        {children}
    diff --git a/packages/console/src/components/MultiTextInput/index.tsx b/packages/console/src/components/MultiTextInput/index.tsx index bfe003174..0a191f4cc 100644 --- a/packages/console/src/components/MultiTextInput/index.tsx +++ b/packages/console/src/components/MultiTextInput/index.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'; import * as textButtonStyles from '@/components/TextButton/index.module.scss'; import Minus from '@/icons/Minus'; +import { onKeyDownHandler } from '@/utilities/a11y'; import ConfirmModal from '../ConfirmModal'; import IconButton from '../IconButton'; @@ -85,7 +86,13 @@ const MultiTextInput = ({ title, value, onChange, onKeyPress, error, placeholder )}
    ))} -
    +
    {t('general.add_another')}
    ({ className )} role="button" + tabIndex={0} + onKeyDown={onKeyDownHandler(() => { + if (!isReadOnly) { + setIsOpen(true); + } + })} onClick={() => { if (!isReadOnly) { setIsOpen(true); diff --git a/packages/console/src/components/TabNav/TabNavItem.tsx b/packages/console/src/components/TabNav/TabNavItem.tsx index e7cf06289..77214ff78 100644 --- a/packages/console/src/components/TabNav/TabNavItem.tsx +++ b/packages/console/src/components/TabNav/TabNavItem.tsx @@ -1,6 +1,8 @@ import classNames from 'classnames'; import { Link, useLocation } from 'react-router-dom'; +import { onKeyDownHandler } from '@/utilities/a11y'; + import * as styles from './TabNavItem.module.scss'; type Props = { @@ -16,7 +18,14 @@ const TabNavItem = ({ children, href, isActive, onClick }: Props) => { return (
    - {href ? {children} : {children}} + {href ? ( + {children} + ) : ( + // eslint-disable-next-line jsx-a11y/anchor-is-valid + + {children} + + )}
    ); }; diff --git a/packages/console/src/mdx-components/DetailsSummary/index.tsx b/packages/console/src/mdx-components/DetailsSummary/index.tsx index a02bfeef5..4065e08b5 100644 --- a/packages/console/src/mdx-components/DetailsSummary/index.tsx +++ b/packages/console/src/mdx-components/DetailsSummary/index.tsx @@ -1,8 +1,9 @@ import classNames from 'classnames'; -import { ReactNode, useState } from 'react'; +import { ReactNode, useState, useCallback } from 'react'; import AnimateHeight, { Height } from 'react-animate-height'; import ArrowRight from '@/assets/images/triangle-right.svg'; +import { onKeyDownHandler } from '@/utilities/a11y'; import * as styles from './index.module.scss'; @@ -15,14 +16,26 @@ const DetailsSummary = ({ children }: Props) => { const [isExpanded, setIsExpanded] = useState(false); const [height, setHeight] = useState(0); + const onClickHandler = useCallback(() => { + setIsExpanded(!isExpanded); + setHeight(height === 0 ? 'auto' : 0); + }, [height, isExpanded]); + return (
    { - setIsExpanded(!isExpanded); - setHeight(height === 0 ? 'auto' : 0); - }} + onKeyDown={onKeyDownHandler({ + Esc: () => { + setIsExpanded(false); + setHeight(0); + }, + Enter: onClickHandler, + ' ': onClickHandler, + })} + onClick={onClickHandler} > {summary} diff --git a/packages/console/src/mdx-components/Step/index.tsx b/packages/console/src/mdx-components/Step/index.tsx index d154d4f5c..90d3e1a29 100644 --- a/packages/console/src/mdx-components/Step/index.tsx +++ b/packages/console/src/mdx-components/Step/index.tsx @@ -1,6 +1,6 @@ import { AdminConsoleKey } from '@logto/phrases'; import classNames from 'classnames'; -import { PropsWithChildren, useEffect, useRef, useState } from 'react'; +import { PropsWithChildren, useEffect, useRef, useState, useCallback } from 'react'; import Button from '@/components/Button'; import Card from '@/components/Card'; @@ -10,6 +10,7 @@ import IconButton from '@/components/IconButton'; import Index from '@/components/Index'; import Spacer from '@/components/Spacer'; import { KeyboardArrowDown, KeyboardArrowUp } from '@/icons/Arrow'; +import { onKeyDownHandler } from '@/utilities/a11y'; import * as styles from './index.module.scss'; @@ -54,13 +55,24 @@ const Step = ({ } }, [isExpanded]); + const onToggle = useCallback(() => { + setIsExpanded((expand) => !expand); + }, [setIsExpanded]); + return (
    { - setIsExpanded(!isExpanded); - }} + onKeyDown={onKeyDownHandler({ + Esc: () => { + setIsExpanded(false); + }, + Enter: onToggle, + ' ': onToggle, + })} + onClick={onToggle} > {
    {
    logo
    diff --git a/packages/console/src/pages/Connectors/components/ConnectorName/index.tsx b/packages/console/src/pages/Connectors/components/ConnectorName/index.tsx index 4dc8bd6f8..01838562b 100644 --- a/packages/console/src/pages/Connectors/components/ConnectorName/index.tsx +++ b/packages/console/src/pages/Connectors/components/ConnectorName/index.tsx @@ -75,6 +75,7 @@ const ConnectorName = ({ type, connectors, onClickSetup }: Props) => {
    logo { >
    - + logo
    diff --git a/packages/console/src/pages/GetStarted/components/GetStartedProgress/index.tsx b/packages/console/src/pages/GetStarted/components/GetStartedProgress/index.tsx index 0068292ef..b70a373ef 100644 --- a/packages/console/src/pages/GetStarted/components/GetStartedProgress/index.tsx +++ b/packages/console/src/pages/GetStarted/components/GetStartedProgress/index.tsx @@ -9,6 +9,7 @@ import Dropdown, { DropdownItem } from '@/components/Dropdown'; import Index from '@/components/Index'; import { useTheme } from '@/hooks/use-theme'; import useUserPreferences from '@/hooks/use-user-preferences'; +import { onKeyDownHandler } from '@/utilities/a11y'; import useGetStartedMetadata from '../../hook'; import * as styles from './index.module.scss'; @@ -28,14 +29,27 @@ const GetStartedProgress = () => { return null; } + const showDropDown = () => { + setShowDropdown(true); + }; + + const hideDropDown = () => { + setShowDropdown(false); + }; + return ( <>
    { - setShowDropdown(true); - }} + onKeyDown={onKeyDownHandler({ + Esc: hideDropDown, + Enter: showDropDown, + ' ': showDropDown, + })} + onClick={showDropDown} > @@ -52,9 +66,7 @@ const GetStartedProgress = () => { horizontalAlign="end" title={t('get_started.progress_dropdown_title')} titleClassName={styles.dropdownTitle} - onClose={() => { - setShowDropdown(false); - }} + onClose={hideDropDown} > {data.map(({ id, title, isComplete, onClick }, index) => ( { navigate('/dashboard'); }; + const showConfirmModalHandler = () => { + setShowConfirmModal(true); + }; + + const hideConfirmModalHandler = () => { + setShowConfirmModal(false); + }; + return (
    @@ -36,10 +45,15 @@ const GetStarted = () => { {t('get_started.subtitle_part2')} { - setShowConfirmModal(true); - }} + onClick={showConfirmModalHandler} + onKeyDown={onKeyDownHandler({ + Enter: showConfirmModalHandler, + ' ': showConfirmModalHandler, + Esc: hideConfirmModalHandler, + })} > {t('get_started.hide_this')} @@ -70,9 +84,7 @@ const GetStarted = () => { confirmButtonType="primary" confirmButtonText="get_started.hide_this" onConfirm={hideGetStarted} - onCancel={() => { - setShowConfirmModal(false); - }} + onCancel={hideConfirmModalHandler} > {t('get_started.confirm_message')} diff --git a/packages/console/src/pages/SignInExperience/components/Preview.tsx b/packages/console/src/pages/SignInExperience/components/Preview.tsx index 37921366b..bc516ec28 100644 --- a/packages/console/src/pages/SignInExperience/components/Preview.tsx +++ b/packages/console/src/pages/SignInExperience/components/Preview.tsx @@ -195,12 +195,14 @@ const Preview = ({ signInExperience, className }: Props) => {
    )} - { - // The missing of attribute "sandbox" is intended since the source is trusted - /* eslint-disable react/iframe-missing-sandbox */ - } -