diff --git a/.changeset/hip-nails-watch.md b/.changeset/hip-nails-watch.md new file mode 100644 index 00000000..f2df2de3 --- /dev/null +++ b/.changeset/hip-nails-watch.md @@ -0,0 +1,5 @@ +--- +"@elasticpath/d2c-schematics": patch +--- + +home page featured products components that pulls from all products in catalog diff --git a/.changeset/khaki-shoes-listen.md b/.changeset/khaki-shoes-listen.md new file mode 100644 index 00000000..528bbc33 --- /dev/null +++ b/.changeset/khaki-shoes-listen.md @@ -0,0 +1,5 @@ +--- +"@elasticpath/d2c-schematics": patch +--- + +Error message handling for no catalog for epcc store diff --git a/.changeset/loud-toys-kiss.md b/.changeset/loud-toys-kiss.md new file mode 100644 index 00000000..e08a21b9 --- /dev/null +++ b/.changeset/loud-toys-kiss.md @@ -0,0 +1,5 @@ +--- +"@elasticpath/d2c-schematics": patch +--- + +home creates the featured products now by default diff --git a/.changeset/serious-badgers-brake.md b/.changeset/serious-badgers-brake.md new file mode 100644 index 00000000..e88cfd54 --- /dev/null +++ b/.changeset/serious-badgers-brake.md @@ -0,0 +1,17 @@ +--- +"composable-cli": minor +--- + +Composable cli now has a more complete set of commands + +- READ_ME has clear descirption on how to use the CLI +- CLI now supports help functionality with examples +- There are 7 new base commands - see the cli help for more information + - login + - logout + - feedback + - config + - store + - generate + - insights + diff --git a/.changeset/smooth-wolves-brake.md b/.changeset/smooth-wolves-brake.md new file mode 100644 index 00000000..0c643400 --- /dev/null +++ b/.changeset/smooth-wolves-brake.md @@ -0,0 +1,5 @@ +--- +"@elasticpath/d2c-schematics": patch +--- + +Added mock pages for shipping, FAQ, terms and about us diff --git a/.changeset/stupid-turkeys-decide.md b/.changeset/stupid-turkeys-decide.md new file mode 100644 index 00000000..100b982b --- /dev/null +++ b/.changeset/stupid-turkeys-decide.md @@ -0,0 +1,5 @@ +--- +"@elasticpath/d2c-schematics": patch +--- + +us-east image location added to next config to allow images from that domain by default diff --git a/.github/workflows/generate-examples.yml b/.github/workflows/generate-examples.yml index 684e2692..ead0fc6b 100644 --- a/.github/workflows/generate-examples.yml +++ b/.github/workflows/generate-examples.yml @@ -41,6 +41,12 @@ jobs: run: | echo "${{ secrets.EXAMPLES_ENV_FILE }}" > .env.examples + - name: Make .env file for composable-cli package + run: | + touch ./packages/composable-cli/.env + echo POSTHOG_PUBLIC_API_KEY=${{ secrets.POSTHOG_PUBLIC_API_KEY }} >> .env + cat .env + - name: Install Dependencies run: yarn install @@ -126,6 +132,10 @@ jobs: conclusion: ${{ steps.examples-unit-int-tests.outcome }} fail-on-error: true + - name: Make .env.test file + run: | + echo "${{ secrets.TEST_ENV_FILE }}" > .env.test + - name: Build everything run: yarn build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 56b6ef96..ee635098 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,6 +20,12 @@ jobs: with: node-version: 16.x + - name: Make .env file for composable-cli package + run: | + touch ./packages/composable-cli/.env + echo POSTHOG_PUBLIC_API_KEY=${{ secrets.POSTHOG_PUBLIC_API_KEY }} >> .env + cat .env + - name: Install Dependencies run: yarn diff --git a/examples/algolia/.husky/.gitignore b/examples/algolia/.husky/.gitignore deleted file mode 100644 index 31354ec1..00000000 --- a/examples/algolia/.husky/.gitignore +++ /dev/null @@ -1 +0,0 @@ -_ diff --git a/examples/algolia/.husky/pre-commit b/examples/algolia/.husky/pre-commit deleted file mode 100644 index 36af2198..00000000 --- a/examples/algolia/.husky/pre-commit +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/sh -. "$(dirname "$0")/_/husky.sh" - -npx lint-staged diff --git a/examples/algolia/next.config.js b/examples/algolia/next.config.js index 0bf2d9af..34bcd623 100644 --- a/examples/algolia/next.config.js +++ b/examples/algolia/next.config.js @@ -5,7 +5,7 @@ **/ const nextConfig = { images: { - domains: ["files-eu.epusercontent.com"], + domains: ["files-eu.epusercontent.com", "files-na.epusercontent.com"], }, i18n: { locales: ["en"], diff --git a/examples/algolia/package.json b/examples/algolia/package.json index 9200b4d3..4a1b029f 100644 --- a/examples/algolia/package.json +++ b/examples/algolia/package.json @@ -11,7 +11,6 @@ "format:check": "prettier --check .", "format:fix": "prettier --write .", "type:check": "tsc --noEmit", - "prepare": "husky install", "test": "vitest run", "test:coverage": "vitest run --coverage", "test:watch": "vitest", @@ -54,7 +53,6 @@ "eslint-config-next": "12.2.5", "eslint-config-prettier": "^8.5.0", "eslint-plugin-react": "^7.30.1", - "husky": "^8.0.1", "vite": "^4.2.1", "vitest": "^0.30.1", "@vitest/coverage-istanbul": "^0.30.1", diff --git a/examples/algolia/src/components/featured-products/FeaturedProducts.tsx b/examples/algolia/src/components/featured-products/FeaturedProducts.tsx new file mode 100644 index 00000000..24cb9726 --- /dev/null +++ b/examples/algolia/src/components/featured-products/FeaturedProducts.tsx @@ -0,0 +1,148 @@ +import { Box, Center, Flex, Heading, Link, Text } from "@chakra-ui/react"; +import React, { useCallback, useEffect, useState } from "react"; +import { useRouter } from "next/router"; +import type { ProductResponseWithImage } from "../../lib/types/product-types"; +import { connectProductsWithMainImages } from "../../lib/product-util"; +import { ArrowForwardIcon, ViewOffIcon } from "@chakra-ui/icons"; +import { globalBaseWidth } from "../../styles/theme"; +import { ChakraNextImage } from "../ChakraNextImage"; +import {getProducts} from "../../services/products"; + +interface IFeaturedProductsBaseProps { + title: string; + linkProps?: { + link: string; + text: string; + }; +} + +interface IFeaturedProductsProvidedProps extends IFeaturedProductsBaseProps { + type: "provided"; + products: ProductResponseWithImage[]; +} + +interface IFeaturedProductsFetchProps extends IFeaturedProductsBaseProps { + type: "fetch"; +} + +type IFeaturedProductsProps = + | IFeaturedProductsFetchProps + | IFeaturedProductsProvidedProps; + +const FeaturedProducts = (props: IFeaturedProductsProps): JSX.Element => { + const router = useRouter(); + const { type, title, linkProps } = props; + + const [products, setProducts] = useState( + type === "provided" ? props.products : [] + ); + + const fetchNodeProducts = useCallback(async () => { + if (type === "fetch") { + const { data, included } = await getProducts(); + let products = data.slice(0, 4); + if (included?.main_images) { + products = connectProductsWithMainImages( + products, + included.main_images + ); + } + setProducts(products); + } + }, [props, type]); + + useEffect(() => { + try { + fetchNodeProducts(); + } catch (error) { + console.error(error); + throw error; + } + }, [fetchNodeProducts]); + + return ( + + + + {title} + + {linkProps && ( + { + linkProps.link && router.push(linkProps.link); + }} + > + {linkProps.text} + + )} + + + {products.map((product) => ( + + + {product.main_image?.link.href ? ( + + ) : ( +
+ +
+ )} + + + {product.attributes.name} + + + {product.meta.display_price?.without_tax.formatted} + +
+ + ))} +
+
+ ); +}; + +export default FeaturedProducts; diff --git a/examples/algolia/src/components/featured-products/fetchFeaturedProducts.ts b/examples/algolia/src/components/featured-products/fetchFeaturedProducts.ts new file mode 100644 index 00000000..75a110f9 --- /dev/null +++ b/examples/algolia/src/components/featured-products/fetchFeaturedProducts.ts @@ -0,0 +1,15 @@ +// Fetching the first 4 products of in the catalog to display in the featured-products component +import { connectProductsWithMainImages } from "../../lib/product-util"; +import {getProducts} from "../../services/products"; + +export const fetchFeaturedProducts = async () => { + const { data: productsResponse, included: productsIncluded } = + await getProducts(); + + return productsIncluded?.main_images + ? connectProductsWithMainImages( + productsResponse.slice(0, 4), // Only need the first 4 products to feature + productsIncluded?.main_images + ) + : productsResponse; +}; diff --git a/examples/algolia/src/components/promotion-banner/PromotionBanner.tsx b/examples/algolia/src/components/promotion-banner/PromotionBanner.tsx new file mode 100644 index 00000000..b00ad4f1 --- /dev/null +++ b/examples/algolia/src/components/promotion-banner/PromotionBanner.tsx @@ -0,0 +1,111 @@ +import { Box, Button, Heading } from "@chakra-ui/react"; +import { useRouter } from "next/router"; + +export interface IPromotion { + title?: string; + description?: string; + imageHref?: string; +} + +interface IPromotionBanner { + linkProps?: { + link: string; + text: string; + }; + alignment?: "center" | "left" | "right"; + promotion: IPromotion; +} + +const PromotionBanner = (props: IPromotionBanner): JSX.Element => { + const router = useRouter(); + const { linkProps, promotion, alignment } = props; + + const { title, imageHref, description } = promotion; + + let background; + + if (imageHref) { + background = { + backgroundImage: `url(${imageHref})`, + backgroundSize: "cover", + backgroundPosition: "center", + _before: { + content: "''", + position: "absolute", + top: 0, + left: 0, + width: "100%", + height: "100%", + backgroundColor: "white", + filter: "opacity(0.5)", + zIndex: 0, + }, + }; + } else { + background = { + backgroundColor: "gray.100", + }; + } + + const contentAlignment = { + alignItems: (() => { + switch (alignment) { + case "left": + return "flex-start"; + case "right": + return "flex-end"; + default: + return "center"; + } + })(), + textAlign: alignment || "center", + }; + + return ( + <> + {promotion && ( + + {title && ( + + {title} + + )} + {description && ( + + {description} + + )} + {linkProps && ( + + )} + + )} + + ); +}; + +export default PromotionBanner; diff --git a/examples/algolia/src/components/shared/blurb.tsx b/examples/algolia/src/components/shared/blurb.tsx new file mode 100644 index 00000000..77fb4dfb --- /dev/null +++ b/examples/algolia/src/components/shared/blurb.tsx @@ -0,0 +1,82 @@ +import { chakra, Heading, Text } from "@chakra-ui/react"; +import { globalBaseWidth } from "../../styles/theme"; +import { ReactNode } from "react"; + +const Para = ({ children }: { children: ReactNode }) => { + return ( + + {children} + + ); +}; + +interface IBlurbProps { + title: string; +} + +const Blurb = ({ title }: IBlurbProps) => ( + + {title} + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec arcu + lectus, pharetra nec velit in, vehicula suscipit tellus. Quisque id mollis + magna. Cras nec lacinia ligula. Morbi aliquam tristique purus, nec dictum + metus euismod at. Vestibulum mollis metus lobortis lectus sodales + eleifend. Class aptent taciti sociosqu ad litora torquent per conubia + nostra, per inceptos himenaeos. Vivamus eget elementum eros, et ultricies + mi. Donec eget dolor imperdiet, gravida ante a, molestie tortor. Nullam + viverra, orci gravida sollicitudin auctor, urna magna condimentum risus, + vitae venenatis turpis mauris sed ligula. Fusce mattis, mauris ut eleifend + ullamcorper, dui felis tincidunt libero, ut commodo arcu leo a ligula. + Cras congue maximus magna, et porta nisl pulvinar in. Nam congue orci + ornare scelerisque elementum. Quisque purus justo, molestie ut leo at, + tristique pretium dui. + + + + Vestibulum imperdiet commodo egestas. Proin tincidunt leo non purus + euismod dictum. Vivamus sagittis mauris dolor, quis egestas purus placerat + eget. Mauris finibus scelerisque augue ut ultrices. Praesent vitae nulla + lorem. Ut eget accumsan risus, sed fringilla orci. Nunc volutpat, odio vel + ornare ullamcorper, massa mauris dapibus nunc, sed euismod lectus erat + eget ligula. Duis fringilla elit vel eleifend luctus. Quisque non blandit + magna. Vivamus pharetra, dolor sed molestie ultricies, tellus ex egestas + lacus, in posuere risus diam non massa. Phasellus in justo in urna + faucibus cursus. + + + + Nullam nibh nisi, lobortis at rhoncus ut, viverra at turpis. Mauris ac + sollicitudin diam. Phasellus non orci massa. Donec tincidunt odio justo. + Sed gravida leo turpis, vitae blandit sem pharetra sit amet. Vestibulum + ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia + curae; Orci varius natoque penatibus et magnis dis parturient montes, + nascetur ridiculus mus. + + + + In in pulvinar turpis, vel pulvinar ipsum. Praesent vel commodo nisi, id + maximus ex. Integer lorem augue, hendrerit et enim vel, eleifend blandit + felis. Integer egestas risus purus, ac rhoncus orci faucibus ac. + Pellentesque iaculis ligula a mauris aliquam, at ullamcorper est + vestibulum. Proin maximus sagittis purus ac pretium. Ut accumsan vitae + nisl sed viverra. + + + + Vivamus malesuada elit facilisis, fringilla lacus non, vulputate felis. + Curabitur dignissim quis ipsum eget pellentesque. Duis efficitur nec nisl + sit amet porta. Maecenas ac dui a felis finibus elementum feugiat at nibh. + Donec convallis sodales neque. Integer id libero eget diam finibus + tincidunt id id diam. Fusce ut lectus nisi. Donec orci enim, semper ac + feugiat vitae, dignissim non enim. Vestibulum commodo dolor nec sem + viverra gravida. Ut laoreet eu tortor auctor consequat. Nulla quis mauris + mollis, aliquam mi nec, laoreet ligula. Fusce laoreet lorem et malesuada + suscipit. Nullam convallis, risus a posuere ultrices, velit augue + porttitor ante, vitae lobortis ligula velit id justo. Praesent nec lorem + massa. + + +); + +export default Blurb; \ No newline at end of file diff --git a/examples/algolia/src/lib/epcc-errors.ts b/examples/algolia/src/lib/epcc-errors.ts new file mode 100644 index 00000000..2b630190 --- /dev/null +++ b/examples/algolia/src/lib/epcc-errors.ts @@ -0,0 +1,16 @@ +export function isNoDefaultCatalogError(errors: object[]): errors is [{ detail: string }] { + const error = errors[0] + return hasDetail(error) && error.detail === 'unable to resolve default catalog: no default catalog id can be identified: not found' +} + +function hasDetail(err: object): err is { detail: string } { + return 'detail' in err +} + +export function isEPError(err: unknown): err is { errors: object[] } { + return typeof err === 'object' && !!err && hasErrors(err) && Array.isArray(err.errors) +} + +function hasErrors(err: object): err is {errors: object[]} { + return 'errors' in err +} \ No newline at end of file diff --git a/examples/algolia/src/lib/store-wrapper-ssg.ts b/examples/algolia/src/lib/store-wrapper-ssg.ts index 65653c93..afc17834 100644 --- a/examples/algolia/src/lib/store-wrapper-ssg.ts +++ b/examples/algolia/src/lib/store-wrapper-ssg.ts @@ -2,6 +2,7 @@ import { GetStaticPropsContext, GetStaticPropsResult } from "next"; import type { ParsedUrlQuery } from "querystring"; import { buildSiteNavigation, NavigationNode } from "./build-site-navigation"; import { StoreContextSSG } from "@elasticpath/react-shopper-hooks"; +import {isEPError, isNoDefaultCatalogError} from "./epcc-errors"; type IncomingPageStaticProp = ( ctx: GetStaticPropsContext, @@ -52,13 +53,20 @@ export function withStoreStaticProps< notFound: true, }; } catch (err) { - console.error( - `${ - err instanceof Error - ? `${err.name} - ${err.message}` - : "Unknown error occurred while trying to resolve store wrapper." - }` - ); + + if (isEPError(err) && isNoDefaultCatalogError(err.errors)) { + console.error("\x1b[31m%s\x1b[0m", "Error: Catalog Not Published"); + console.error("Please publish a catalog for this store.") + console.error("See https://elasticpath.dev/docs/pxm/products/get-started-pxm#create-and-publish-a-catalog") + } else { + console.error( + `${ + err instanceof Error + ? `${err.name} - ${err.message}` + : "Unknown error occurred while trying to resolve store wrapper." + }` + ); + } return { notFound: true, }; diff --git a/examples/algolia/src/pages/about.tsx b/examples/algolia/src/pages/about.tsx new file mode 100644 index 00000000..e6773bba --- /dev/null +++ b/examples/algolia/src/pages/about.tsx @@ -0,0 +1,8 @@ +import { withStoreStaticProps } from "../lib/store-wrapper-ssg"; +import Blurb from "../components/shared/blurb"; + +const About = () => ; + +export default About; + +export const getServerSideProps = withStoreStaticProps(); \ No newline at end of file diff --git a/examples/algolia/src/pages/faq.tsx b/examples/algolia/src/pages/faq.tsx new file mode 100644 index 00000000..60685ba2 --- /dev/null +++ b/examples/algolia/src/pages/faq.tsx @@ -0,0 +1,8 @@ +import { withStoreStaticProps } from "../lib/store-wrapper-ssg"; +import Blurb from "../components/shared/blurb"; + +const FAQ = () => ; + +export default FAQ; + +export const getServerSideProps = withStoreStaticProps(); \ No newline at end of file diff --git a/examples/algolia/src/pages/index.tsx b/examples/algolia/src/pages/index.tsx index cd3cc26d..36647193 100644 --- a/examples/algolia/src/pages/index.tsx +++ b/examples/algolia/src/pages/index.tsx @@ -1,33 +1,48 @@ import type { NextPage } from "next"; import { chakra, Grid, GridItem } from "@chakra-ui/react"; -import type { Node, Promotion } from "@moltin/sdk"; +import type { Node } from "@moltin/sdk"; +import FeaturedProducts from "../components/featured-products/FeaturedProducts"; +import { fetchFeaturedProducts } from "../components/featured-products/fetchFeaturedProducts"; import { ProductResponseWithImage } from "../lib/types/product-types"; - - - - +import PromotionBanner, { + IPromotion, +} from "../components/promotion-banner/PromotionBanner"; import { withStoreStaticProps } from "../lib/store-wrapper-ssg"; -const nodeId = process.env.NEXT_PUBLIC_DEMO_NODE_ID || ""; -const promotionId = process.env.NEXT_PUBLIC_DEMO_PROMO_ID || ""; - export interface IHome { - - + featuredProducts?: ProductResponseWithImage[]; featuredNodes?: Node[]; + promotion?: IPromotion; } -const Home: NextPage = ({ - - -}) => { +const Home: NextPage = ({ featuredProducts, promotion }) => { return ( - + {promotion && ( + + )} - + + {featuredProducts && ( + + )} + ); @@ -35,12 +50,16 @@ const Home: NextPage = ({ export const getStaticProps = withStoreStaticProps(async () => { // Fetching static data for the home page - - + const featuredProducts = await fetchFeaturedProducts(); + return { props: { - - + promotion: { + title: "Your Elastic Path storefront", + description: + "This marks the beginning, embark on the journey of crafting something truly extraordinary, uniquely yours.", + }, + ...(featuredProducts && { featuredProducts }), }, }; }); diff --git a/examples/algolia/src/pages/shipping.tsx b/examples/algolia/src/pages/shipping.tsx new file mode 100644 index 00000000..98c4aded --- /dev/null +++ b/examples/algolia/src/pages/shipping.tsx @@ -0,0 +1,8 @@ +import { withStoreStaticProps } from "../lib/store-wrapper-ssg"; +import Blurb from "../components/shared/blurb"; + +const Shipping = () => ; + +export default Shipping; + +export const getServerSideProps = withStoreStaticProps(); \ No newline at end of file diff --git a/examples/algolia/src/pages/terms.tsx b/examples/algolia/src/pages/terms.tsx new file mode 100644 index 00000000..4738bc55 --- /dev/null +++ b/examples/algolia/src/pages/terms.tsx @@ -0,0 +1,8 @@ +import { withStoreStaticProps } from "../lib/store-wrapper-ssg"; +import Blurb from "../components/shared/blurb"; + +const Terms = () => ; + +export default Terms; + +export const getServerSideProps = withStoreStaticProps(); \ No newline at end of file diff --git a/examples/algolia/src/services/products.ts b/examples/algolia/src/services/products.ts index d3e67316..1d82f185 100644 --- a/examples/algolia/src/services/products.ts +++ b/examples/algolia/src/services/products.ts @@ -26,6 +26,12 @@ export function getAllProducts( return _getAllProductPages(client)(); } +export function getProducts(client?: EPCCClient, offset = 0, limit = 100) { + return (client ?? getEpccImplicitClient()).ShopperCatalog.Products.With(["main_image"]).Limit(limit) + .Offset(offset) + .All() +} + const _getAllPages = ( nextPageRequestFn: ( diff --git a/examples/basic/.husky/.gitignore b/examples/basic/.husky/.gitignore deleted file mode 100644 index 31354ec1..00000000 --- a/examples/basic/.husky/.gitignore +++ /dev/null @@ -1 +0,0 @@ -_ diff --git a/examples/basic/.husky/pre-commit b/examples/basic/.husky/pre-commit deleted file mode 100644 index 36af2198..00000000 --- a/examples/basic/.husky/pre-commit +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/sh -. "$(dirname "$0")/_/husky.sh" - -npx lint-staged diff --git a/examples/basic/next.config.js b/examples/basic/next.config.js index 0bf2d9af..34bcd623 100644 --- a/examples/basic/next.config.js +++ b/examples/basic/next.config.js @@ -5,7 +5,7 @@ **/ const nextConfig = { images: { - domains: ["files-eu.epusercontent.com"], + domains: ["files-eu.epusercontent.com", "files-na.epusercontent.com"], }, i18n: { locales: ["en"], diff --git a/examples/basic/package.json b/examples/basic/package.json index 3624c006..828875f9 100644 --- a/examples/basic/package.json +++ b/examples/basic/package.json @@ -11,7 +11,6 @@ "format:check": "prettier --check .", "format:fix": "prettier --write .", "type:check": "tsc --noEmit", - "prepare": "husky install", "test": "vitest run", "test:coverage": "vitest run --coverage", "test:watch": "vitest", @@ -52,7 +51,6 @@ "eslint-config-next": "12.2.5", "eslint-config-prettier": "^8.5.0", "eslint-plugin-react": "^7.30.1", - "husky": "^8.0.1", "vite": "^4.2.1", "vitest": "^0.30.1", "@vitest/coverage-istanbul": "^0.30.1", diff --git a/examples/basic/src/components/featured-products/FeaturedProducts.tsx b/examples/basic/src/components/featured-products/FeaturedProducts.tsx new file mode 100644 index 00000000..24cb9726 --- /dev/null +++ b/examples/basic/src/components/featured-products/FeaturedProducts.tsx @@ -0,0 +1,148 @@ +import { Box, Center, Flex, Heading, Link, Text } from "@chakra-ui/react"; +import React, { useCallback, useEffect, useState } from "react"; +import { useRouter } from "next/router"; +import type { ProductResponseWithImage } from "../../lib/types/product-types"; +import { connectProductsWithMainImages } from "../../lib/product-util"; +import { ArrowForwardIcon, ViewOffIcon } from "@chakra-ui/icons"; +import { globalBaseWidth } from "../../styles/theme"; +import { ChakraNextImage } from "../ChakraNextImage"; +import {getProducts} from "../../services/products"; + +interface IFeaturedProductsBaseProps { + title: string; + linkProps?: { + link: string; + text: string; + }; +} + +interface IFeaturedProductsProvidedProps extends IFeaturedProductsBaseProps { + type: "provided"; + products: ProductResponseWithImage[]; +} + +interface IFeaturedProductsFetchProps extends IFeaturedProductsBaseProps { + type: "fetch"; +} + +type IFeaturedProductsProps = + | IFeaturedProductsFetchProps + | IFeaturedProductsProvidedProps; + +const FeaturedProducts = (props: IFeaturedProductsProps): JSX.Element => { + const router = useRouter(); + const { type, title, linkProps } = props; + + const [products, setProducts] = useState( + type === "provided" ? props.products : [] + ); + + const fetchNodeProducts = useCallback(async () => { + if (type === "fetch") { + const { data, included } = await getProducts(); + let products = data.slice(0, 4); + if (included?.main_images) { + products = connectProductsWithMainImages( + products, + included.main_images + ); + } + setProducts(products); + } + }, [props, type]); + + useEffect(() => { + try { + fetchNodeProducts(); + } catch (error) { + console.error(error); + throw error; + } + }, [fetchNodeProducts]); + + return ( + + + + {title} + + {linkProps && ( + { + linkProps.link && router.push(linkProps.link); + }} + > + {linkProps.text} + + )} + + + {products.map((product) => ( + + + {product.main_image?.link.href ? ( + + ) : ( +
+ +
+ )} + + + {product.attributes.name} + + + {product.meta.display_price?.without_tax.formatted} + +
+ + ))} +
+
+ ); +}; + +export default FeaturedProducts; diff --git a/examples/basic/src/components/featured-products/fetchFeaturedProducts.ts b/examples/basic/src/components/featured-products/fetchFeaturedProducts.ts new file mode 100644 index 00000000..75a110f9 --- /dev/null +++ b/examples/basic/src/components/featured-products/fetchFeaturedProducts.ts @@ -0,0 +1,15 @@ +// Fetching the first 4 products of in the catalog to display in the featured-products component +import { connectProductsWithMainImages } from "../../lib/product-util"; +import {getProducts} from "../../services/products"; + +export const fetchFeaturedProducts = async () => { + const { data: productsResponse, included: productsIncluded } = + await getProducts(); + + return productsIncluded?.main_images + ? connectProductsWithMainImages( + productsResponse.slice(0, 4), // Only need the first 4 products to feature + productsIncluded?.main_images + ) + : productsResponse; +}; diff --git a/examples/basic/src/components/promotion-banner/PromotionBanner.tsx b/examples/basic/src/components/promotion-banner/PromotionBanner.tsx new file mode 100644 index 00000000..b00ad4f1 --- /dev/null +++ b/examples/basic/src/components/promotion-banner/PromotionBanner.tsx @@ -0,0 +1,111 @@ +import { Box, Button, Heading } from "@chakra-ui/react"; +import { useRouter } from "next/router"; + +export interface IPromotion { + title?: string; + description?: string; + imageHref?: string; +} + +interface IPromotionBanner { + linkProps?: { + link: string; + text: string; + }; + alignment?: "center" | "left" | "right"; + promotion: IPromotion; +} + +const PromotionBanner = (props: IPromotionBanner): JSX.Element => { + const router = useRouter(); + const { linkProps, promotion, alignment } = props; + + const { title, imageHref, description } = promotion; + + let background; + + if (imageHref) { + background = { + backgroundImage: `url(${imageHref})`, + backgroundSize: "cover", + backgroundPosition: "center", + _before: { + content: "''", + position: "absolute", + top: 0, + left: 0, + width: "100%", + height: "100%", + backgroundColor: "white", + filter: "opacity(0.5)", + zIndex: 0, + }, + }; + } else { + background = { + backgroundColor: "gray.100", + }; + } + + const contentAlignment = { + alignItems: (() => { + switch (alignment) { + case "left": + return "flex-start"; + case "right": + return "flex-end"; + default: + return "center"; + } + })(), + textAlign: alignment || "center", + }; + + return ( + <> + {promotion && ( + + {title && ( + + {title} + + )} + {description && ( + + {description} + + )} + {linkProps && ( + + )} + + )} + + ); +}; + +export default PromotionBanner; diff --git a/examples/basic/src/components/shared/blurb.tsx b/examples/basic/src/components/shared/blurb.tsx new file mode 100644 index 00000000..77fb4dfb --- /dev/null +++ b/examples/basic/src/components/shared/blurb.tsx @@ -0,0 +1,82 @@ +import { chakra, Heading, Text } from "@chakra-ui/react"; +import { globalBaseWidth } from "../../styles/theme"; +import { ReactNode } from "react"; + +const Para = ({ children }: { children: ReactNode }) => { + return ( + + {children} + + ); +}; + +interface IBlurbProps { + title: string; +} + +const Blurb = ({ title }: IBlurbProps) => ( + + {title} + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec arcu + lectus, pharetra nec velit in, vehicula suscipit tellus. Quisque id mollis + magna. Cras nec lacinia ligula. Morbi aliquam tristique purus, nec dictum + metus euismod at. Vestibulum mollis metus lobortis lectus sodales + eleifend. Class aptent taciti sociosqu ad litora torquent per conubia + nostra, per inceptos himenaeos. Vivamus eget elementum eros, et ultricies + mi. Donec eget dolor imperdiet, gravida ante a, molestie tortor. Nullam + viverra, orci gravida sollicitudin auctor, urna magna condimentum risus, + vitae venenatis turpis mauris sed ligula. Fusce mattis, mauris ut eleifend + ullamcorper, dui felis tincidunt libero, ut commodo arcu leo a ligula. + Cras congue maximus magna, et porta nisl pulvinar in. Nam congue orci + ornare scelerisque elementum. Quisque purus justo, molestie ut leo at, + tristique pretium dui. + + + + Vestibulum imperdiet commodo egestas. Proin tincidunt leo non purus + euismod dictum. Vivamus sagittis mauris dolor, quis egestas purus placerat + eget. Mauris finibus scelerisque augue ut ultrices. Praesent vitae nulla + lorem. Ut eget accumsan risus, sed fringilla orci. Nunc volutpat, odio vel + ornare ullamcorper, massa mauris dapibus nunc, sed euismod lectus erat + eget ligula. Duis fringilla elit vel eleifend luctus. Quisque non blandit + magna. Vivamus pharetra, dolor sed molestie ultricies, tellus ex egestas + lacus, in posuere risus diam non massa. Phasellus in justo in urna + faucibus cursus. + + + + Nullam nibh nisi, lobortis at rhoncus ut, viverra at turpis. Mauris ac + sollicitudin diam. Phasellus non orci massa. Donec tincidunt odio justo. + Sed gravida leo turpis, vitae blandit sem pharetra sit amet. Vestibulum + ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia + curae; Orci varius natoque penatibus et magnis dis parturient montes, + nascetur ridiculus mus. + + + + In in pulvinar turpis, vel pulvinar ipsum. Praesent vel commodo nisi, id + maximus ex. Integer lorem augue, hendrerit et enim vel, eleifend blandit + felis. Integer egestas risus purus, ac rhoncus orci faucibus ac. + Pellentesque iaculis ligula a mauris aliquam, at ullamcorper est + vestibulum. Proin maximus sagittis purus ac pretium. Ut accumsan vitae + nisl sed viverra. + + + + Vivamus malesuada elit facilisis, fringilla lacus non, vulputate felis. + Curabitur dignissim quis ipsum eget pellentesque. Duis efficitur nec nisl + sit amet porta. Maecenas ac dui a felis finibus elementum feugiat at nibh. + Donec convallis sodales neque. Integer id libero eget diam finibus + tincidunt id id diam. Fusce ut lectus nisi. Donec orci enim, semper ac + feugiat vitae, dignissim non enim. Vestibulum commodo dolor nec sem + viverra gravida. Ut laoreet eu tortor auctor consequat. Nulla quis mauris + mollis, aliquam mi nec, laoreet ligula. Fusce laoreet lorem et malesuada + suscipit. Nullam convallis, risus a posuere ultrices, velit augue + porttitor ante, vitae lobortis ligula velit id justo. Praesent nec lorem + massa. + + +); + +export default Blurb; \ No newline at end of file diff --git a/examples/basic/src/lib/epcc-errors.ts b/examples/basic/src/lib/epcc-errors.ts new file mode 100644 index 00000000..2b630190 --- /dev/null +++ b/examples/basic/src/lib/epcc-errors.ts @@ -0,0 +1,16 @@ +export function isNoDefaultCatalogError(errors: object[]): errors is [{ detail: string }] { + const error = errors[0] + return hasDetail(error) && error.detail === 'unable to resolve default catalog: no default catalog id can be identified: not found' +} + +function hasDetail(err: object): err is { detail: string } { + return 'detail' in err +} + +export function isEPError(err: unknown): err is { errors: object[] } { + return typeof err === 'object' && !!err && hasErrors(err) && Array.isArray(err.errors) +} + +function hasErrors(err: object): err is {errors: object[]} { + return 'errors' in err +} \ No newline at end of file diff --git a/examples/basic/src/lib/store-wrapper-ssg.ts b/examples/basic/src/lib/store-wrapper-ssg.ts index 65653c93..afc17834 100644 --- a/examples/basic/src/lib/store-wrapper-ssg.ts +++ b/examples/basic/src/lib/store-wrapper-ssg.ts @@ -2,6 +2,7 @@ import { GetStaticPropsContext, GetStaticPropsResult } from "next"; import type { ParsedUrlQuery } from "querystring"; import { buildSiteNavigation, NavigationNode } from "./build-site-navigation"; import { StoreContextSSG } from "@elasticpath/react-shopper-hooks"; +import {isEPError, isNoDefaultCatalogError} from "./epcc-errors"; type IncomingPageStaticProp = ( ctx: GetStaticPropsContext, @@ -52,13 +53,20 @@ export function withStoreStaticProps< notFound: true, }; } catch (err) { - console.error( - `${ - err instanceof Error - ? `${err.name} - ${err.message}` - : "Unknown error occurred while trying to resolve store wrapper." - }` - ); + + if (isEPError(err) && isNoDefaultCatalogError(err.errors)) { + console.error("\x1b[31m%s\x1b[0m", "Error: Catalog Not Published"); + console.error("Please publish a catalog for this store.") + console.error("See https://elasticpath.dev/docs/pxm/products/get-started-pxm#create-and-publish-a-catalog") + } else { + console.error( + `${ + err instanceof Error + ? `${err.name} - ${err.message}` + : "Unknown error occurred while trying to resolve store wrapper." + }` + ); + } return { notFound: true, }; diff --git a/examples/basic/src/pages/about.tsx b/examples/basic/src/pages/about.tsx new file mode 100644 index 00000000..e6773bba --- /dev/null +++ b/examples/basic/src/pages/about.tsx @@ -0,0 +1,8 @@ +import { withStoreStaticProps } from "../lib/store-wrapper-ssg"; +import Blurb from "../components/shared/blurb"; + +const About = () => ; + +export default About; + +export const getServerSideProps = withStoreStaticProps(); \ No newline at end of file diff --git a/examples/basic/src/pages/faq.tsx b/examples/basic/src/pages/faq.tsx new file mode 100644 index 00000000..60685ba2 --- /dev/null +++ b/examples/basic/src/pages/faq.tsx @@ -0,0 +1,8 @@ +import { withStoreStaticProps } from "../lib/store-wrapper-ssg"; +import Blurb from "../components/shared/blurb"; + +const FAQ = () => ; + +export default FAQ; + +export const getServerSideProps = withStoreStaticProps(); \ No newline at end of file diff --git a/examples/basic/src/pages/index.tsx b/examples/basic/src/pages/index.tsx index cd3cc26d..36647193 100644 --- a/examples/basic/src/pages/index.tsx +++ b/examples/basic/src/pages/index.tsx @@ -1,33 +1,48 @@ import type { NextPage } from "next"; import { chakra, Grid, GridItem } from "@chakra-ui/react"; -import type { Node, Promotion } from "@moltin/sdk"; +import type { Node } from "@moltin/sdk"; +import FeaturedProducts from "../components/featured-products/FeaturedProducts"; +import { fetchFeaturedProducts } from "../components/featured-products/fetchFeaturedProducts"; import { ProductResponseWithImage } from "../lib/types/product-types"; - - - - +import PromotionBanner, { + IPromotion, +} from "../components/promotion-banner/PromotionBanner"; import { withStoreStaticProps } from "../lib/store-wrapper-ssg"; -const nodeId = process.env.NEXT_PUBLIC_DEMO_NODE_ID || ""; -const promotionId = process.env.NEXT_PUBLIC_DEMO_PROMO_ID || ""; - export interface IHome { - - + featuredProducts?: ProductResponseWithImage[]; featuredNodes?: Node[]; + promotion?: IPromotion; } -const Home: NextPage = ({ - - -}) => { +const Home: NextPage = ({ featuredProducts, promotion }) => { return ( - + {promotion && ( + + )} - + + {featuredProducts && ( + + )} + ); @@ -35,12 +50,16 @@ const Home: NextPage = ({ export const getStaticProps = withStoreStaticProps(async () => { // Fetching static data for the home page - - + const featuredProducts = await fetchFeaturedProducts(); + return { props: { - - + promotion: { + title: "Your Elastic Path storefront", + description: + "This marks the beginning, embark on the journey of crafting something truly extraordinary, uniquely yours.", + }, + ...(featuredProducts && { featuredProducts }), }, }; }); diff --git a/examples/basic/src/pages/shipping.tsx b/examples/basic/src/pages/shipping.tsx new file mode 100644 index 00000000..98c4aded --- /dev/null +++ b/examples/basic/src/pages/shipping.tsx @@ -0,0 +1,8 @@ +import { withStoreStaticProps } from "../lib/store-wrapper-ssg"; +import Blurb from "../components/shared/blurb"; + +const Shipping = () => ; + +export default Shipping; + +export const getServerSideProps = withStoreStaticProps(); \ No newline at end of file diff --git a/examples/basic/src/pages/terms.tsx b/examples/basic/src/pages/terms.tsx new file mode 100644 index 00000000..4738bc55 --- /dev/null +++ b/examples/basic/src/pages/terms.tsx @@ -0,0 +1,8 @@ +import { withStoreStaticProps } from "../lib/store-wrapper-ssg"; +import Blurb from "../components/shared/blurb"; + +const Terms = () => ; + +export default Terms; + +export const getServerSideProps = withStoreStaticProps(); \ No newline at end of file diff --git a/examples/basic/src/services/products.ts b/examples/basic/src/services/products.ts index d3e67316..1d82f185 100644 --- a/examples/basic/src/services/products.ts +++ b/examples/basic/src/services/products.ts @@ -26,6 +26,12 @@ export function getAllProducts( return _getAllProductPages(client)(); } +export function getProducts(client?: EPCCClient, offset = 0, limit = 100) { + return (client ?? getEpccImplicitClient()).ShopperCatalog.Products.With(["main_image"]).Limit(limit) + .Offset(offset) + .All() +} + const _getAllPages = ( nextPageRequestFn: ( diff --git a/packages/composable-cli/README.md b/packages/composable-cli/README.md index 22e0573b..cc371b1a 100644 --- a/packages/composable-cli/README.md +++ b/packages/composable-cli/README.md @@ -1,40 +1,31 @@ -# `Beta` Elastic Path Commerce Cloud Schematics CLI +# `Beta` Elastic Path Composable CLI ### This package is not feature complete and is work in progress. This package contains the executable for running [Elastic Path Commerce Cloud](https://www.elasticpath.com/) Schematics. -## Schematics +## Installation -# Usage +`yarn global add composable-cli` or `npm install -g composable-cli` -``` -$ schematics [CollectionName:]SchematicName [options, ...] - -By default, if the collection name is not specified, use the internal collection provided -by the Schematics CLI. - -Options: - --debug Debug mode. This is true by default if the collection is a relative - path (in that case, turn off with --debug=false). - - --allow-private Allow private schematics to be run from the command line. Default to - false. +## Generating a storefront - --dry-run Do not output anything, but instead just show what actions would be - performed. Default to true if debug is also true. +### Login to Elasticpath - --force Force overwriting files that would otherwise be an error. +```bash +composable-cli login +``` - --list-schematics List all schematics from the collection, by name. A collection name - should be suffixed by a colon. Example: '@angular-devkit/schematics-cli:'. +### Generate a D2C (Direct-to-consumer) storefront - --no-interactive Disables interactive input prompts. +```bash +composable-cli generate d2c my-storefront +``` - --verbose Show more information. +Select your Elasticpath store from the list of stores. - --help Show this message. +### Getting help -Any additional option is passed to the Schematics depending on its schema. +```bash +composable-cli --help ``` - diff --git a/packages/composable-cli/package.json b/packages/composable-cli/package.json index 9ee61af0..83bc90b9 100644 --- a/packages/composable-cli/package.json +++ b/packages/composable-cli/package.json @@ -7,34 +7,51 @@ "directory": "packages/composable-cli" }, "bin": { - "composable-cli": "./bin/composable.js" + "composable-cli": "./bin/composable.js", + "ep": "./bin/composable.js" }, "scripts": { "build": "rimraf ./dist/ && yarn mkdirp dist && tsup", "dev": "tsup --watch", - "dev-old": "ncc build ./composable.ts -w -o dist/", - "build-old": "rimraf ./dist/ && ncc build ./composable.ts -o ./dist/ --minify --no-cache --no-source-map-register", "lint": "TIMING=1 eslint src/**/*.ts* --fix", "clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist", - "test": "jest --passWithNoTests", + "test": "jest", "test:watch": "jest --watch" }, "dependencies": { "@angular-devkit/core": "^14.1.0", "@angular-devkit/schematics": "^14.1.0", "@elasticpath/d2c-schematics": "*", + "@moltin/sdk": "^23.2.0", + "@types/ink": "^2.0.3", + "@types/yargs": "^17.0.24", "ansi-colors": "4.1.3", + "conf": "10.2.0", + "ink": "3", + "ink-big-text": "1", + "ink-gradient": "2", + "ink-link": "2", "inquirer": "8.2.4", + "node-fetch": "2.7.0", + "open": "8", + "ora": "5", + "posthog-node": "^3.1.2", + "react": "18", "symbol-observable": "4.0.0", "typescript": "^4.7.4", - "yargs-parser": "21.0.1" + "yargs": "^17.7.2", + "yargs-parser": "21.0.1", + "zod": "^3.22.2" }, "devDependencies": { "@types/fs-extra": "^9.0.13", + "@types/ink-big-text": "^1.2.1", + "@types/ink-gradient": "^2.0.1", "@types/inquirer": "^8.2.1", "@types/jest": "^29.1.2", "@types/yargs-parser": "^21.0.0", "@vercel/ncc": "^0.34.0", + "dotenv": "^16.3.1", "esbuild-plugin-copy": "^2.1.0", "fs-extra": "^10.1.0", "jest": "^29.2.0", diff --git a/packages/composable-cli/src/commands/config/config-command.tsx b/packages/composable-cli/src/commands/config/config-command.tsx new file mode 100644 index 00000000..6b50c62b --- /dev/null +++ b/packages/composable-cli/src/commands/config/config-command.tsx @@ -0,0 +1,65 @@ +import yargs from "yargs" +import Conf from "conf" +import { CommandContext, CommandHandlerFunction } from "../../types/command" +import { handleErrors } from "../../util/error-handler" +import { + ConfigCommandArguments, + ConfigCommandData, + ConfigCommandError, +} from "./config.types" +import { trackCommandHandler } from "../../util/track-command-handler" + +export function configClearCommand(store: Conf): void { + store.clear() +} + +export function createConfigCommand( + ctx: CommandContext +): yargs.CommandModule<{}, ConfigCommandArguments> { + return { + command: "config", + describe: "interact with stored configuration", + builder: (yargs) => { + return yargs + .command({ + command: "list", + describe: "List all stored configuration", + handler: (_args) => { + console.log(ctx.store.store) + }, + }) + .command({ + command: "clear", + describe: "Clear all stored configuration", + handler: (_args) => { + configClearCommand(ctx.store) + }, + }) + .example("$0 config list", "list all stored configuration") + .example("$0 config clear", "clear all stored configuration") + .help() + .demandCommand(1) + .strict() + }, + handler: handleErrors(trackCommandHandler(ctx, createConfigCommandHandler)), + } +} + +export function createConfigCommandHandler( + _ctx: CommandContext +): CommandHandlerFunction< + ConfigCommandData, + ConfigCommandError, + ConfigCommandArguments +> { + return async function configCommandHandler(_args) { + console.log("command not recognized") + return { + success: false, + error: { + code: "command_not_recognized", + message: "command not recognized", + }, + } + } +} diff --git a/packages/composable-cli/src/commands/config/config.types.ts b/packages/composable-cli/src/commands/config/config.types.ts new file mode 100644 index 00000000..5e9def6c --- /dev/null +++ b/packages/composable-cli/src/commands/config/config.types.ts @@ -0,0 +1,10 @@ +import { EmptyObj } from "../../types/empty-object" + +export type ConfigCommandData = {} + +export type ConfigCommandError = { + code: string + message: string +} + +export type ConfigCommandArguments = EmptyObj diff --git a/packages/composable-cli/src/commands/feedback/feedback-command.ts b/packages/composable-cli/src/commands/feedback/feedback-command.ts new file mode 100644 index 00000000..5cc73eeb --- /dev/null +++ b/packages/composable-cli/src/commands/feedback/feedback-command.ts @@ -0,0 +1,49 @@ +import yargs from "yargs" +import { + CommandContext, + CommandHandlerFunction, + RootCommandArguments, +} from "../../types/command" +import { handleErrors } from "../../util/error-handler" +import { renderInk } from "../../lib/ink/render-ink" +import React from "react" +import { + FeedbackCommandArguments, + FeedbackCommandData, + FeedbackCommandError, +} from "./feedback.types" +import open from "open" +import { Feedback } from "../ui/feedback/feedback" +import { trackCommandHandler } from "../../util/track-command-handler" +import { isTTY } from "../../util/is-tty" +export function createFeedbackCommand( + ctx: CommandContext +): yargs.CommandModule { + return { + command: "feedback", + describe: "Feedback to the Composable CLI", + handler: handleErrors( + trackCommandHandler(ctx, createFeedbackCommandHandler) + ), + } +} + +export function createFeedbackCommandHandler( + _ctx: CommandContext +): CommandHandlerFunction< + FeedbackCommandData, + FeedbackCommandError, + FeedbackCommandArguments +> { + return async function feedbackCommandHandler(args) { + if (args.interactive && isTTY()) { + await open("https://elasticpath.dev/docs") + } + + await renderInk(React.createElement(Feedback)) + return { + success: true, + data: {}, + } + } +} diff --git a/packages/composable-cli/src/commands/feedback/feedback.types.ts b/packages/composable-cli/src/commands/feedback/feedback.types.ts new file mode 100644 index 00000000..48873ccc --- /dev/null +++ b/packages/composable-cli/src/commands/feedback/feedback.types.ts @@ -0,0 +1,10 @@ +import { RootCommandArguments } from "../../types/command" + +export type FeedbackCommandData = {} + +export type FeedbackCommandError = { + code: string + message: string +} + +export type FeedbackCommandArguments = RootCommandArguments diff --git a/packages/composable-cli/src/commands/generate/d2c/d2c-command.tsx b/packages/composable-cli/src/commands/generate/d2c/d2c-command.tsx new file mode 100644 index 00000000..ea4dded1 --- /dev/null +++ b/packages/composable-cli/src/commands/generate/d2c/d2c-command.tsx @@ -0,0 +1,609 @@ +import yargs from "yargs" +import { logging, schema } from "@angular-devkit/core" +import { createConsoleLogger } from "@angular-devkit/core/node" +import * as ansiColors from "ansi-colors" +import { + FileSystemCollectionDescription, + FileSystemSchematicDescription, + NodeWorkflow, +} from "@angular-devkit/schematics/tools" + +import * as inquirer from "inquirer" +import { + Collection, + UnsuccessfulWorkflowExecution, +} from "@angular-devkit/schematics" +import { camelCase, decamelize } from "yargs-parser" +import { + D2CCommandArguments, + D2CCommandData, + D2CCommandError, +} from "./d2c.types" +import { + CommandContext, + CommandHandlerFunction, + CommandResult, +} from "../../../types/command" +import { handleErrors } from "../../../util/error-handler" +import { resolveHostFromRegion } from "../../../util/resolve-region" +import { getToken } from "../../../lib/authentication/get-token" +import { createApplicationKeys } from "../../../util/create-client-secret" +import { renderInk } from "../../../lib/ink/render-ink" +import React from "react" +import { D2CGenerated } from "../../ui/generate/d2c-generated" +import { getStore } from "../../../lib/stores/get-store" +import { selectStoreById } from "../../store/store-command" +import { trackCommandHandler } from "../../../util/track-command-handler" +import { addSchemaOptionsToCommand } from "../utils/add-schema-options-command" +import { getSchematicOptions } from "../utils/get-schematic-options" +import { getOrCreateWorkflowForBuilder } from "../utils/get-or-create-workflow-for-builder" +import { resolveD2CCollectionName } from "../utils/resolve-d2c-collection-name" +import { isTTY } from "../../../util/is-tty" +import { GenerateCommandArguments } from "../generate.types" +import { Option } from "../utils/json-schema" +import { + createActiveStoreMiddleware, + createAuthenticationCheckerMiddleware, +} from "../generate-command" + +export function createD2CCommand( + ctx: CommandContext +): yargs.CommandModule { + return { + command: "d2c [name]", + aliases: ["storefront"], + describe: "generate Elasticpath storefront", + builder: async (yargs) => { + const result = yargs + .middleware(createAuthenticationCheckerMiddleware(ctx)) + .middleware(createActiveStoreMiddleware(ctx)) + .positional("name", { + describe: "the name for this storefront project", + type: "string", + }) + .option("pkg-manager", { + describe: "node package manager to use", + choices: ["npm", "yarn", "pnpm"] as const, + default: "npm" as const, + }) + .help() + .parserConfiguration({ + "camel-case-expansion": false, + "dot-notation": false, + "boolean-negation": true, + "strip-aliased": true, + }) + + const collectionName = resolveD2CCollectionName( + process.env.NODE_ENV ?? "production" + ) + const workflow = await getOrCreateWorkflowForBuilder( + collectionName, + "", + "" + ) // TODO: add real root and workspace + const collection = workflow.engine.createCollection(collectionName) + + const schematicsNamesForOptions = [ + "d2c", + "cart", + "checkout", + "product-details-page", + "product-list-page", + "product-list-page-algolia", + "header", + "footer", + "home", + "setup-payment-gateway", + "ep-payments-payment-gateway", + ] + + const options = await getAllD2CSchematicOptions( + collection, + workflow, + schematicsNamesForOptions + ) + + return addSchemaOptionsToCommand(result, options) + }, + handler: handleErrors(trackCommandHandler(ctx, createD2CCommandHandler)), + } +} + +async function getAllD2CSchematicOptions( + collection: Collection< + FileSystemCollectionDescription, + FileSystemSchematicDescription + >, + workflow: NodeWorkflow, + schematicName: string[] +): Promise { + return schematicName.reduce(async (acc, schematicName) => { + const values = await acc + const latestOptions = await getSchematicOptions( + collection, + schematicName, + workflow + ) + + return [...combineOptions(values, latestOptions)] + }, Promise.resolve([]) as Promise) +} + +function combineOptions(arr1: Option[], arr2: Option[]): Option[] { + const combinedOptions: Option[] = [] + + // Create a map to keep track of unique names + const uniqueNames = new Map() + + // Add options from the first array + for (const option of arr1) { + if (!uniqueNames.has(option.name)) { + uniqueNames.set(option.name, true) + combinedOptions.push(option) + } + } + + // Add options from the second array + for (const option of arr2) { + if (!uniqueNames.has(option.name)) { + uniqueNames.set(option.name, true) + combinedOptions.push(option) + } + } + + return combinedOptions +} + +export function createD2CCommandHandler( + ctx: CommandContext +): CommandHandlerFunction< + D2CCommandData, + D2CCommandError, + D2CCommandArguments +> { + const { store } = ctx + + return async function generateCommandHandler(args) { + const colors = ansiColors.create() + + const { cliOptions, schematicOptions, _, name, pkgManager } = + parseArgs(args) + + /** Create the DevKit Logger used through the CLI. */ + const logger = createConsoleLogger( + !!cliOptions.verbose, + ctx.stdout, + ctx.stderr, + { + info: (s) => s, + debug: (s) => s, + warn: (s) => colors.bold.yellow(s), + error: (s) => colors.bold.red(s), + fatal: (s) => colors.bold.red(s), + } + ) + + logger.debug(`Cli Options: ${JSON.stringify(cliOptions)}`) + logger.debug(`Schematic Options: ${JSON.stringify(schematicOptions)}`) + + const collectionName = resolveD2CCollectionName( + process.env.NODE_ENV ?? "production" + ) + const schematicName = "d2c" + + const isLocalCollection = + collectionName.startsWith(".") || collectionName.startsWith("/") + + /** Gather the arguments for later use. */ + const debugPresent = cliOptions.debug !== null + const debug = debugPresent ? !!cliOptions.debug : isLocalCollection + const dryRunPresent = cliOptions["dry-run"] !== null + const dryRun = dryRunPresent ? !!cliOptions["dry-run"] : debug + const force = !!cliOptions.force + const allowPrivate = !!cliOptions["allow-private"] + const skipGit = !!cliOptions["skip-git"] + const skipInstall = !!cliOptions["skip-install"] + const skipConfig = !!cliOptions["skip-config"] + + /** Create the workflow scoped to the working directory that will be executed with this run. */ + const workflow = new NodeWorkflow(process.cwd(), { + force, + dryRun, + resolvePaths: [process.cwd(), __dirname], + schemaValidation: true, + }) + + /** If the user wants to list schematics, we simply show all the schematic names. */ + if (cliOptions["list-schematics"]) { + return _listSchematics(workflow, collectionName, logger) + } + + if (debug) { + logger.info( + `Debug mode enabled${ + isLocalCollection ? " by default for local collections" : "" + }.` + ) + } + + // Indicate to the user when nothing has been done. This is automatically set to off when there's + // a new DryRunEvent. + let nothingDone = true + + // Logging queue that receives all the messages to show the users. This only get shown when no + // errors happened. + let loggingQueue: string[] = [] + let error = false + + /** + * Logs out dry run events. + * + * All events will always be executed here, in order of discovery. That means that an error would + * be shown along other events when it happens. Since errors in workflows will stop the Observable + * from completing successfully, we record any events other than errors, then on completion we + * show them. + * + * This is a simple way to only show errors when an error occur. + */ + workflow.reporter.subscribe((event) => { + nothingDone = false + // Strip leading slash to prevent confusion. + const eventPath = event.path.startsWith("/") + ? event.path.slice(1) + : event.path + + switch (event.kind) { + case "error": + error = true + + const desc = + event.description == "alreadyExist" + ? "already exists" + : "does not exist" + logger.error(`ERROR! ${eventPath} ${desc}.`) + break + case "update": + loggingQueue.push( + `${colors.cyan("UPDATE")} ${eventPath} (${ + event.content.length + } bytes)` + ) + break + case "create": + loggingQueue.push( + `${colors.green("CREATE")} ${eventPath} (${ + event.content.length + } bytes)` + ) + break + case "delete": + loggingQueue.push(`${colors.yellow("DELETE")} ${eventPath}`) + break + case "rename": + const eventToPath = event.to.startsWith("/") + ? event.to.slice(1) + : event.to + loggingQueue.push( + `${colors.blue("RENAME")} ${eventPath} => ${eventToPath}` + ) + break + } + }) + + /** + * Listen to lifecycle events of the workflow to flush the logs between each phases. + */ + workflow.lifeCycle.subscribe((event) => { + if (event.kind == "workflow-end" || event.kind == "post-tasks-start") { + if (!error) { + // Flush the log queue and clean the error state. + loggingQueue.forEach((log) => logger.info(log)) + } + + loggingQueue = [] + error = false + } + }) + + // Show usage of deprecated options + workflow.registry.useXDeprecatedProvider((msg) => logger.warn(msg)) + + // Pass the rest of the arguments as the smart default "argv". Then delete it. + workflow.registry.addSmartDefaultProvider("argv", (schema) => + "index" in schema ? _[Number(schema["index"])] : _ + ) + + // Add prompts. + if (cliOptions.interactive && isTTY()) { + workflow.registry.usePromptProvider(_createPromptProvider()) + } + + let gatheredOptions: Record = { + name, + } + + if (cliOptions.interactive && isTTY()) { + // check if user is authenticated + const creds = store.get("credentials") as Record | undefined + + if (creds) { + const apiUrl = resolveHostFromRegion(store.get("region") as any) + const tokenResult = await getToken(apiUrl, store) + + if (tokenResult.success) { + const token = tokenResult.data + + let resolvedName = name + + if (!resolvedName) { + const { name: promptedName } = await inquirer.prompt([ + { + type: "input", + name: "name", + message: "What do you want to call the project?", + }, + ]) + + resolvedName = promptedName + } + + const activeStore = await getStore(store) + + if (!activeStore.success) { + return { + success: false, + error: { + code: "active-store-not-found", + message: activeStore.error.message, + }, + } + } + + const switchResult = await selectStoreById(store, activeStore.data.id) + + if (!switchResult.success) { + return { + success: false, + error: { + code: "active-store-switch-failed", + message: switchResult.error.message, + }, + } + } + + const { data } = await createApplicationKeys( + apiUrl, + token, + `${resolvedName}-${new Date().toISOString()}` + ) + + gatheredOptions = { + ...gatheredOptions, + epccClientId: data.client_id, + epccClientSecret: data.client_secret, + name: resolvedName, + } + } + } + + const region = store.get("region") as any + + const apiHost = new URL(resolveHostFromRegion(region)).host + + gatheredOptions = { + ...gatheredOptions, + epccEndpointUrl: apiHost, + } + } + + /** + * Execute the workflow, which will report the dry run events, run the tasks, and complete + * after all is done. + * + * The Observable returned will properly cancel the workflow if unsubscribed, error out if ANY + * step of the workflow failed (sink or task), with details included, and will only complete + * when everything is done. + */ + try { + await workflow + .execute({ + collection: collectionName, + schematic: schematicName, + options: { + ...schematicOptions, + skipGit, + skipInstall, + skipConfig, + ...gatheredOptions, + }, + allowPrivate: allowPrivate, + debug: debug, + logger: logger, + }) + .toPromise() + + if (nothingDone) { + logger.info("Nothing to be done.") + } else if (dryRun) { + logger.info( + `Dry run enabled${ + dryRunPresent ? "" : " by default in debug mode" + }. No files written to disk.` + ) + } else { + await renderInk( + React.createElement(D2CGenerated, { + skipInstall, + name: (gatheredOptions as any).name, + nodePkgManager: pkgManager, + }) + ) + } + + return { + success: true, + data: {}, + } + } catch (err) { + if (err instanceof UnsuccessfulWorkflowExecution) { + // "See above" because we already printed the error. + logger.fatal("The Schematic workflow failed. See above.") + } else if (debug && err instanceof Error) { + logger.fatal(`An error occured:\n${err.stack}`) + } else { + logger.fatal( + `Error: ${err instanceof Error ? err.message : JSON.stringify(err)}` + ) + } + + return { + success: false, + error: { + code: "schematic-workflow-failed", + message: "The Schematic workflow failed.", + }, + } + } + } +} + +/** Parse the command line. */ +const booleanArgs = [ + "allow-private", + "debug", + "dry-run", + "force", + "help", + "list-schematics", + "verbose", + "interactive", + "skip-install", + "skip-git", + "skip-config", +] as const + +type ElementType> = T extends ReadonlyArray< + infer ElementType +> + ? ElementType + : never + +interface Options { + _: string[] + schematicOptions: Record + cliOptions: Partial, boolean | null>> + name: string | null + pkgManager: "npm" | "yarn" | "pnpm" +} + +/** Parse the command line. */ +function parseArgs( + args: yargs.ArgumentsCamelCase +): Options { + const { _, $0, name = null, ...options } = args + + // Camelize options as yargs will return the object in kebab-case when camel casing is disabled. + const schematicOptions: Options["schematicOptions"] = {} + const cliOptions: Options["cliOptions"] = {} + + const isCliOptions = ( + key: ElementType | string + ): key is ElementType => + booleanArgs.includes(key as ElementType) + + for (const [key, value] of Object.entries(options)) { + if (/[A-Z]/.test(key)) { + throw new Error( + `Unknown argument ${key}. Did you mean ${decamelize(key)}?` + ) + } + + if (isCliOptions(key)) { + // @ts-ignore TODO: fix this + cliOptions[key] = value + } else { + schematicOptions[camelCase(key)] = value + } + } + + return { + _: _.map((v) => v.toString()), + schematicOptions, + cliOptions, + name, + pkgManager: args["pkg-manager"], + } +} + +function _listSchematics( + workflow: NodeWorkflow, + collectionName: string, + logger: logging.Logger +): CommandResult { + try { + logger.info(`collection listed for: ${collectionName}`) + const collection = workflow.engine.createCollection(collectionName) + logger.info(collection.listSchematicNames().join("\n")) + } catch (error) { + logger.fatal(error instanceof Error ? error.message : `${error}`) + + return { + success: false, + error: { + code: "invalid-collection", + message: `Invalid collection (${collectionName}).`, + }, + } + } + + return { + success: true, + data: {}, + } +} + +function _createPromptProvider(): schema.PromptProvider { + return (definitions) => { + const questions: inquirer.QuestionCollection = definitions.map( + (definition) => { + const question: inquirer.Question = { + name: definition.id, + message: definition.message, + default: definition.default, + } + + const validator = definition.validator + if (validator) { + question.validate = (input) => validator(input) + } + + switch (definition.type) { + case "confirmation": + return { ...question, type: "confirm" } + case "list": + return { + ...question, + type: definition.multiselect ? "checkbox" : "list", + choices: + definition.items && + definition.items.map((item) => { + if (typeof item == "string") { + return item + } else { + return { + name: item.label, + value: item.value, + } + } + }), + } + default: + return { ...question, type: definition.type } + } + } + ) + + return inquirer.prompt(questions) + } +} diff --git a/packages/composable-cli/src/commands/generate/d2c/d2c.types.ts b/packages/composable-cli/src/commands/generate/d2c/d2c.types.ts new file mode 100644 index 00000000..1748bfbe --- /dev/null +++ b/packages/composable-cli/src/commands/generate/d2c/d2c.types.ts @@ -0,0 +1,13 @@ +import { GenerateCommandArguments } from "../generate.types" + +export type D2CCommandData = {} + +export type D2CCommandError = { + code: string + message: string +} + +export type D2CCommandArguments = { + name?: string + "pkg-manager": "npm" | "yarn" | "pnpm" +} & GenerateCommandArguments diff --git a/packages/composable-cli/src/commands/generate/generate-command.tsx b/packages/composable-cli/src/commands/generate/generate-command.tsx new file mode 100644 index 00000000..c0ea8f65 --- /dev/null +++ b/packages/composable-cli/src/commands/generate/generate-command.tsx @@ -0,0 +1,149 @@ +import yargs, { MiddlewareFunction } from "yargs" +import { + CommandContext, + CommandHandlerFunction, + RootCommandArguments, +} from "../../types/command" +import { handleErrors } from "../../util/error-handler" +import { + GenerateCommandArguments, + GenerateCommandData, + GenerateCommandError, +} from "./generate.types" +import { createD2CCommand } from "./d2c/d2c-command" +import { hasActiveStore } from "../../util/active-store" +import { createSetStoreCommandHandler } from "../store/store-command" +import { isAuthenticated } from "../../util/check-authenticated" +import { trackCommandHandler } from "../../util/track-command-handler" +import { isTTY } from "../../util/is-tty" +import { SetStoreCommandArguments } from "../store/store.types" + +export function createGenerateCommand( + ctx: CommandContext +): yargs.CommandModule { + return { + command: "generate", + aliases: ["g"], + describe: "generate Elasticpath storefront", + builder: (yargs) => { + return yargs + .option("debug", { + type: "boolean", + default: null, + describe: + "Debug mode. This is true by default if the collection is a relative path (in that case, turn off with --debug=false).", + }) + .option("dry-run", { + type: "boolean", + default: false, + describe: + "Do not output anything, but instead just show what actions would be performed. Default to true if debug is also true.", + }) + .option("allow-private", { + type: "boolean", + describe: + "Allow private schematics to be run from the command line. Default to false.", + }) + .option("force", { + type: "boolean", + describe: "Force overwriting files that would otherwise be an error.", + }) + .option("list-schematics", { + type: "boolean", + describe: + "List all schematics from the collection, by name. A collection name should be suffixed by a colon. Example: '@elasticpath/d2c-schematics:'.", + }) + .option("skip-install", { type: "boolean" }) + .option("skip-git", { type: "boolean" }) + .option("skip-config", { type: "boolean" }) + .command(createD2CCommand(ctx)) + .help() + .parserConfiguration({ + "camel-case-expansion": false, + "dot-notation": false, + "boolean-negation": true, + "strip-aliased": true, + }) + .demandCommand(1) + .strict() + }, + handler: handleErrors( + trackCommandHandler(ctx, createGenerateCommandHandler) + ), + } +} + +function argsHasAuthInfo( + args: yargs.ArgumentsCamelCase<{ + region?: "eu-west" | "us-east" + username?: string + password?: string + }> +): args is yargs.ArgumentsCamelCase<{ + region: "eu-west" | "us-east" + username: string + password: string +}> { + return !!args.password && !!args.username && !!args.region +} + +export function createAuthenticationCheckerMiddleware( + ctx: CommandContext +): MiddlewareFunction { + return async function authenticationMiddleware( + args: yargs.ArgumentsCamelCase<{ + region?: "eu-west" | "us-east" + username?: string + password?: string + }> + ) { + const { store } = ctx + + if (!isAuthenticated(store) && !argsHasAuthInfo(args)) { + console.warn( + "You must be logged in to run this command: try running `composable-cli login` first" + ) + } + + return + } +} + +export function createActiveStoreMiddleware( + ctx: CommandContext +): MiddlewareFunction { + return async function activeStoreMiddleware( + args: yargs.ArgumentsCamelCase + ) { + const { store } = ctx + + if (!args.interactive) { + return + } + + if (hasActiveStore(store) || !isTTY()) { + return + } + + return handleErrors(createSetStoreCommandHandler(ctx))(args) + } +} + +export function createGenerateCommandHandler( + _ctx: CommandContext +): CommandHandlerFunction< + GenerateCommandData, + GenerateCommandError, + GenerateCommandArguments +> { + return async function generateCommandHandler(_args) { + return { + success: false, + error: { + code: "missing-positional-argument", + message: + 'missing positional argument did you mean to run "d2c" command?', + }, + } + } +} diff --git a/packages/composable-cli/src/commands/generate/generate.types.ts b/packages/composable-cli/src/commands/generate/generate.types.ts new file mode 100644 index 00000000..4f626176 --- /dev/null +++ b/packages/composable-cli/src/commands/generate/generate.types.ts @@ -0,0 +1,20 @@ +import { RootCommandArguments } from "../../types/command" + +export type GenerateCommandData = {} + +export type GenerateCommandError = { + code: string + message: string +} + +export type GenerateCommandArguments = { + schematic?: string + debug: boolean | null + "dry-run": boolean + "allow-private"?: boolean + force?: boolean + "list-schematics"?: boolean + "skip-install"?: boolean + "skip-git"?: boolean + "skip-config"?: boolean +} & RootCommandArguments diff --git a/packages/composable-cli/src/commands/generate/utils/add-schema-options-command.ts b/packages/composable-cli/src/commands/generate/utils/add-schema-options-command.ts new file mode 100644 index 00000000..ebeef030 --- /dev/null +++ b/packages/composable-cli/src/commands/generate/utils/add-schema-options-command.ts @@ -0,0 +1,72 @@ +import { + Arguments, + Argv, + Options as YargsOptions, + PositionalOptions, +} from "yargs" +import { Option } from "./json-schema" +import { strings } from "@angular-devkit/core" + +export function addSchemaOptionsToCommand( + localYargs: Argv, + options: Option[] +): Argv { + const booleanOptionsWithNoPrefix = new Set() + + for (const option of options) { + const { + default: defaultVal, + positional, + deprecated, + description, + alias, + type, + hidden, + name, + choices, + } = option + + const sharedOptions: YargsOptions & PositionalOptions = { + alias, + hidden, + description, + deprecated, + choices, + default: defaultVal, + } + + let dashedName = strings.dasherize(name) + + // Handle options which have been defined in the schema with `no` prefix. + if (type === "boolean" && dashedName.startsWith("no-")) { + dashedName = dashedName.slice(3) + booleanOptionsWithNoPrefix.add(dashedName) + } + + if (positional === undefined) { + localYargs = localYargs.option(dashedName, { + type, + ...sharedOptions, + }) + } else { + localYargs = localYargs.positional(dashedName, { + type: type === "array" || type === "count" ? "string" : type, + ...sharedOptions, + }) + } + } + + // Handle options which have been defined in the schema with `no` prefix. + if (booleanOptionsWithNoPrefix.size) { + localYargs.middleware((options: Arguments) => { + for (const key of booleanOptionsWithNoPrefix) { + if (key in options) { + options[`no-${key}`] = !options[key] + delete options[key] + } + } + }, false) + } + + return localYargs +} diff --git a/packages/composable-cli/src/commands/generate/utils/get-or-create-workflow-for-builder.ts b/packages/composable-cli/src/commands/generate/utils/get-or-create-workflow-for-builder.ts new file mode 100644 index 00000000..63193037 --- /dev/null +++ b/packages/composable-cli/src/commands/generate/utils/get-or-create-workflow-for-builder.ts @@ -0,0 +1,31 @@ +import { NodeWorkflow } from "@angular-devkit/schematics/tools" +import { SchematicEngineHost } from "./schematic-engine-host" + +const DEFAULT_SCHEMATICS_COLLECTION = "@elasticpath/d2c-schematics" + +export function getOrCreateWorkflowForBuilder( + collectionName: string, + root: string, + workspace: string +): NodeWorkflow { + return new NodeWorkflow(root, { + resolvePaths: getResolvePaths(collectionName, workspace, root), + engineHostCreator: (options) => + new SchematicEngineHost(options.resolvePaths), + }) +} + +function getResolvePaths( + collectionName: string, + workspace: string, + root: string +): string[] { + return workspace + ? // Workspace + collectionName === DEFAULT_SCHEMATICS_COLLECTION + ? // Favor __dirname for @schematics/angular to use the build-in version + [__dirname, process.cwd(), root] + : [process.cwd(), root, __dirname] + : // Global + [__dirname, process.cwd()] +} diff --git a/packages/composable-cli/src/commands/generate/utils/get-schematic-options.ts b/packages/composable-cli/src/commands/generate/utils/get-schematic-options.ts new file mode 100644 index 00000000..ad8cee6d --- /dev/null +++ b/packages/composable-cli/src/commands/generate/utils/get-schematic-options.ts @@ -0,0 +1,25 @@ +import { Collection } from "@angular-devkit/schematics" +import { + FileSystemCollectionDescription, + FileSystemSchematicDescription, + NodeWorkflow, +} from "@angular-devkit/schematics/tools" +import { Option, parseJsonSchemaToOptions } from "./json-schema" + +export async function getSchematicOptions( + collection: Collection< + FileSystemCollectionDescription, + FileSystemSchematicDescription + >, + schematicName: string, + workflow: NodeWorkflow +): Promise { + const schematic = collection.createSchematic(schematicName, true) + const { schemaJson } = schematic.description + + if (!schemaJson) { + return [] + } + + return parseJsonSchemaToOptions(workflow.registry, schemaJson) +} diff --git a/packages/composable-cli/src/commands/generate/utils/json-schema.ts b/packages/composable-cli/src/commands/generate/utils/json-schema.ts new file mode 100644 index 00000000..61527ca9 --- /dev/null +++ b/packages/composable-cli/src/commands/generate/utils/json-schema.ts @@ -0,0 +1,230 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { json } from "@angular-devkit/core" +import yargs from "yargs" + +/** + * An option description. + */ +export interface Option extends yargs.Options { + /** + * The name of the option. + */ + name: string + + /** + * Whether this option is required or not. + */ + required?: boolean + + /** + * Format field of this option. + */ + format?: string + + /** + * Whether this option should be hidden from the help output. It will still show up in JSON help. + */ + hidden?: boolean + + /** + * If this option can be used as an argument, the position of the argument. Otherwise omitted. + */ + positional?: number + + /** + * Whether or not to report this option to the Angular Team, and which custom field to use. + * If this is falsey, do not report this option. + */ + userAnalytics?: string +} + +export async function parseJsonSchemaToOptions( + registry: json.schema.SchemaRegistry, + schema: json.JsonObject, + interactive = true +): Promise { + const options: Option[] = [] + + function visitor( + current: json.JsonObject | json.JsonArray, + pointer: json.schema.JsonPointer, + parentSchema?: json.JsonObject | json.JsonArray + ) { + if (!parentSchema) { + // Ignore root. + return + } else if ( + pointer.split(/\/(?:properties|items|definitions)\//g).length > 2 + ) { + // Ignore subitems (objects or arrays). + return + } else if (json.isJsonArray(current)) { + return + } + + if (pointer.indexOf("/not/") != -1) { + // We don't support anyOf/not. + throw new Error('The "not" keyword is not supported in JSON Schema.') + } + + const ptr = json.schema.parseJsonPointer(pointer) + const name = ptr[ptr.length - 1] + + if (ptr[ptr.length - 2] != "properties") { + // Skip any non-property items. + return + } + + const typeSet = json.schema.getTypesOfSchema(current) + + if (typeSet.size == 0) { + throw new Error("Cannot find type of schema.") + } + + // We only support number, string or boolean (or array of those), so remove everything else. + const types = [...typeSet].filter((x) => { + switch (x) { + case "boolean": + case "number": + case "string": + return true + + case "array": + // Only include arrays if they're boolean, string or number. + if ( + json.isJsonObject(current.items) && + typeof current.items.type == "string" && + ["boolean", "number", "string"].includes(current.items.type) + ) { + return true + } + + return false + + default: + return false + } + }) as ("string" | "number" | "boolean" | "array")[] + + if (types.length == 0) { + // This means it's not usable on the command line. e.g. an Object. + return + } + + // Only keep enum values we support (booleans, numbers and strings). + const enumValues = ( + (json.isJsonArray(current.enum) && current.enum) || + [] + ).filter((x) => { + switch (typeof x) { + case "boolean": + case "number": + case "string": + return true + + default: + return false + } + }) as (string | true | number)[] + + let defaultValue: string | number | boolean | undefined = undefined + if (current.default !== undefined) { + switch (types[0]) { + case "string": + if (typeof current.default == "string") { + defaultValue = current.default + } + break + case "number": + if (typeof current.default == "number") { + defaultValue = current.default + } + break + case "boolean": + if (typeof current.default == "boolean") { + defaultValue = current.default + } + break + } + } + + const type = types[0] + const $default = current.$default + const $defaultIndex = + json.isJsonObject($default) && $default["$source"] == "argv" + ? $default["index"] + : undefined + const positional: number | undefined = + typeof $defaultIndex == "number" ? $defaultIndex : undefined + + let required = json.isJsonArray(schema.required) + ? schema.required.includes(name) + : false + if (required && interactive && current["x-prompt"]) { + required = false + } + + const alias = json.isJsonArray(current.aliases) + ? [...current.aliases].map((x) => "" + x) + : current.alias + ? ["" + current.alias] + : [] + const format = + typeof current.format == "string" ? current.format : undefined + const visible = current.visible === undefined || current.visible === true + const hidden = !!current.hidden || !visible + + const xUserAnalytics = current["x-user-analytics"] + const userAnalytics = + typeof xUserAnalytics === "string" ? xUserAnalytics : undefined + + // Deprecated is set only if it's true or a string. + const xDeprecated = current["x-deprecated"] + const deprecated = + xDeprecated === true || typeof xDeprecated === "string" + ? xDeprecated + : undefined + + const option: Option = { + name, + description: + "" + (current.description === undefined ? "" : current.description), + type, + default: defaultValue, + choices: enumValues.length ? enumValues : undefined, + required, + alias, + format, + hidden, + userAnalytics, + deprecated, + positional, + } + + options.push(option) + } + + const flattenedSchema = await registry.flatten(schema).toPromise() + // @ts-ignore + json.schema.visitJsonSchema(flattenedSchema, visitor) + + // Sort by positional and name. + return options.sort((a, b) => { + if (a.positional) { + return b.positional + ? a.positional - b.positional + : a.name.localeCompare(b.name) + } else if (b.positional) { + return -1 + } + + return a.name.localeCompare(b.name) + }) +} diff --git a/packages/composable-cli/src/commands/generate/utils/resolve-d2c-collection-name.ts b/packages/composable-cli/src/commands/generate/utils/resolve-d2c-collection-name.ts new file mode 100644 index 00000000..5881a1cc --- /dev/null +++ b/packages/composable-cli/src/commands/generate/utils/resolve-d2c-collection-name.ts @@ -0,0 +1,8 @@ +import path from "path" + +export function resolveD2CCollectionName(nodeEnv: string): string { + if (nodeEnv === "development" || nodeEnv === "CI") { + return path.resolve(__dirname, "../../../d2c-schematics/dist") + } + return "@elasticpath/d2c-schematics" +} diff --git a/packages/composable-cli/src/commands/generate/utils/schematic-engine-host.ts b/packages/composable-cli/src/commands/generate/utils/schematic-engine-host.ts new file mode 100644 index 00000000..e8cd4ec3 --- /dev/null +++ b/packages/composable-cli/src/commands/generate/utils/schematic-engine-host.ts @@ -0,0 +1,282 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { + RuleFactory, + SchematicsException, + Tree, +} from "@angular-devkit/schematics" +import { + FileSystemCollectionDesc, + NodeModulesEngineHost, +} from "@angular-devkit/schematics/tools" +import { readFileSync } from "fs" +import { parse as parseJson } from "jsonc-parser" +import { createRequire } from "module" +import { dirname, resolve } from "path" +import { TextEncoder } from "util" +import { Script } from "vm" + +/** + * Environment variable to control schematic package redirection + */ +const schematicRedirectVariable = + process.env["NG_SCHEMATIC_REDIRECT"]?.toLowerCase() + +function shouldWrapSchematic( + schematicFile: string, + schematicEncapsulation: boolean +): boolean { + // Check environment variable if present + switch (schematicRedirectVariable) { + case "0": + case "false": + case "off": + case "none": + return false + case "all": + return true + } + + const normalizedSchematicFile = schematicFile.replace(/\\/g, "/") + // Never wrap the internal update schematic when executed directly + // It communicates with the update command via `global` + // But we still want to redirect schematics located in `@angular/cli/node_modules`. + if ( + normalizedSchematicFile.includes("node_modules/@angular/cli/") && + !normalizedSchematicFile.includes("node_modules/@angular/cli/node_modules/") + ) { + return false + } + + // @angular/pwa uses dynamic imports which causes `[1] 2468039 segmentation fault` when wrapped. + // We should remove this when make `importModuleDynamically` work. + // See: https://nodejs.org/docs/latest-v14.x/api/vm.html + if (normalizedSchematicFile.includes("@angular/pwa")) { + return false + } + + // Check for first-party Angular schematic packages + // Angular schematics are safe to use in the wrapped VM context + if ( + /\/node_modules\/@(?:angular|schematics|nguniversal)\//.test( + normalizedSchematicFile + ) + ) { + return true + } + + // Otherwise use the value of the schematic collection's encapsulation option (current default of false) + return schematicEncapsulation +} + +export class SchematicEngineHost extends NodeModulesEngineHost { + protected override _resolveReferenceString( + refString: string, + parentPath: string, + collectionDescription?: FileSystemCollectionDesc + ) { + const [path, name] = refString.split("#", 2) + // Mimic behavior of ExportStringRef class used in default behavior + const fullPath = + path[0] === "." ? resolve(parentPath ?? process.cwd(), path) : path + + const referenceRequire = createRequire(__filename) + const schematicFile = referenceRequire.resolve(fullPath, { + paths: [parentPath], + }) + + if ( + shouldWrapSchematic(schematicFile, !!collectionDescription?.encapsulation) + ) { + const schematicPath = dirname(schematicFile) + + const moduleCache = new Map() + const factoryInitializer = wrap( + schematicFile, + schematicPath, + moduleCache, + name || "default" + ) as () => RuleFactory<{}> + + const factory = factoryInitializer() + if (!factory || typeof factory !== "function") { + return null + } + + return { ref: factory, path: schematicPath } + } + + // All other schematics use default behavior + return super._resolveReferenceString( + refString, + parentPath, + collectionDescription + ) + } +} + +/** + * Minimal shim modules for legacy deep imports of `@schematics/angular` + */ +const legacyModules: Record = { + "@schematics/angular/utility/config": { + getWorkspace(host: Tree) { + const path = "/.angular.json" + const data = host.read(path) + if (!data) { + throw new SchematicsException(`Could not find (${path})`) + } + + return parseJson(data.toString(), [], { allowTrailingComma: true }) + }, + }, + "@schematics/angular/utility/project": { + buildDefaultPath(project: { + sourceRoot?: string + root: string + projectType: string + }): string { + const root = project.sourceRoot + ? `/${project.sourceRoot}/` + : `/${project.root}/src/` + + return `${root}${project.projectType === "application" ? "app" : "lib"}` + }, + }, +} + +/** + * Wrap a JavaScript file in a VM context to allow specific Angular dependencies to be redirected. + * This VM setup is ONLY intended to redirect dependencies. + * + * @param schematicFile A JavaScript schematic file path that should be wrapped. + * @param schematicDirectory A directory that will be used as the location of the JavaScript file. + * @param moduleCache A map to use for caching repeat module usage and proper `instanceof` support. + * @param exportName An optional name of a specific export to return. Otherwise, return all exports. + */ +function wrap( + schematicFile: string, + schematicDirectory: string, + moduleCache: Map, + exportName?: string +): () => unknown { + const hostRequire = createRequire(__filename) + const schematicRequire = createRequire(schematicFile) + + const customRequire = function (id: string) { + if (legacyModules[id]) { + // Provide compatibility modules for older versions of @angular/cdk + return legacyModules[id] + } else if (id.startsWith("schematics:")) { + // Schematics built-in modules use the `schematics` scheme (similar to the Node.js `node` scheme) + const builtinId = id.slice(11) + const builtinModule = loadBuiltinModule(builtinId) + if (!builtinModule) { + throw new Error( + `Unknown schematics built-in module '${id}' requested from schematic '${schematicFile}'` + ) + } + + return builtinModule + } else if ( + id.startsWith("@angular-devkit/") || + id.startsWith("@schematics/") + ) { + // Files should not redirect `@angular/core` and instead use the direct + // dependency if available. This allows old major version migrations to continue to function + // even though the latest major version may have breaking changes in `@angular/core`. + if (id.startsWith("@angular-devkit/core")) { + try { + return schematicRequire(id) + } catch (e) { + console.error(e) + if ((e as any).code !== "MODULE_NOT_FOUND") { + throw e + } + } + } + + // Resolve from inside the `@angular/cli` project + return hostRequire(id) + } else if (id.startsWith(".") || id.startsWith("@angular/cdk")) { + // Wrap relative files inside the schematic collection + // Also wrap `@angular/cdk`, it contains helper utilities that import core schematic packages + + // Resolve from the original file + const modulePath = schematicRequire.resolve(id) + + // Use cached module if available + const cachedModule = moduleCache.get(modulePath) + if (cachedModule) { + return cachedModule + } + + // Do not wrap vendored third-party packages or JSON files + if ( + !/[/\\]node_modules[/\\]@schematics[/\\]angular[/\\]third_party[/\\]/.test( + modulePath + ) && + !modulePath.endsWith(".json") + ) { + // Wrap module and save in cache + const wrappedModule = wrap( + modulePath, + dirname(modulePath), + moduleCache + )() + moduleCache.set(modulePath, wrappedModule) + + return wrappedModule + } + } + + // All others are required directly from the original file + return schematicRequire(id) + } + + // Setup a wrapper function to capture the module's exports + const schematicCode = readFileSync(schematicFile, "utf8") + // `module` is required due to @angular/localize ng-add being in UMD format + const headerCode = + "(function() {\nvar exports = {};\nvar module = { exports };\n" + const footerCode = exportName + ? `\nreturn module.exports['${exportName}'];});` + : "\nreturn module.exports;});" + + const script = new Script(headerCode + schematicCode + footerCode, { + filename: schematicFile, + lineOffset: 3, + }) + + const context = { + __dirname: schematicDirectory, + __filename: schematicFile, + Buffer, + // TextEncoder is used by the compiler to generate i18n message IDs. See: + // https://github.com/angular/angular/blob/main/packages/compiler/src/i18n/digest.ts#L17 + // It is referenced globally, because it may be run either on the browser or the server. + // Usually Node exposes it globally, but in order for it to work, our custom context + // has to expose it too. Issue context: https://github.com/angular/angular/issues/48940. + TextEncoder, + console, + process, + get global() { + return this + }, + require: customRequire, + } + + const exportsFactory = script.runInNewContext(context) + + return exportsFactory +} + +function loadBuiltinModule(_id: string): unknown { + return undefined +} diff --git a/packages/composable-cli/src/commands/insights/insights-command.tsx b/packages/composable-cli/src/commands/insights/insights-command.tsx new file mode 100644 index 00000000..d90b2d15 --- /dev/null +++ b/packages/composable-cli/src/commands/insights/insights-command.tsx @@ -0,0 +1,72 @@ +import yargs from "yargs" +import { + CommandContext, + CommandHandlerFunction, + RootCommandArguments, +} from "../../types/command" +import { handleErrors } from "../../util/error-handler" +import { + InsightsCommandArguments, + InsightsCommandData, + InsightsCommandError, +} from "./insights.types" +import { promptOptInProductInsights } from "../../lib/insights/opt-in-product-insights-middleware" +import { optInsights } from "../../util/has-opted-insights" +import { trackCommandHandler } from "../../util/track-command-handler" +import { isTTY } from "../../util/is-tty" + +export function createInsightsCommand( + ctx: CommandContext +): yargs.CommandModule { + return { + command: "insights", + describe: "opt in/out product insights", + builder: (yargs) => { + return yargs + .option("opt-in", { + alias: "o", + describe: "opt in to product insights", + type: "boolean", + }) + .help() + }, + handler: handleErrors( + trackCommandHandler(ctx, createInsightsCommandHandler) + ), + } +} + +export function createInsightsCommandHandler( + ctx: CommandContext +): CommandHandlerFunction< + InsightsCommandData, + InsightsCommandError, + InsightsCommandArguments +> { + return async function configCommandHandler(args) { + if (!args.interactive || !isTTY()) { + console.warn("When not interactive, the opt-in flag must be provided.") + return { + success: false, + error: { + code: "not-interactive", + message: "When not interactive, the opt-in flag must be provided.", + }, + } + } + + if (args.optIn === undefined) { + await promptOptInProductInsights(ctx.store) + return { + success: true, + data: {}, + } + } + + optInsights(ctx.store, args.optIn) + return { + success: true, + data: {}, + } + } +} diff --git a/packages/composable-cli/src/commands/insights/insights.types.ts b/packages/composable-cli/src/commands/insights/insights.types.ts new file mode 100644 index 00000000..2a522fcf --- /dev/null +++ b/packages/composable-cli/src/commands/insights/insights.types.ts @@ -0,0 +1,13 @@ +import { RootCommandArguments } from "../../types/command" + +export type InsightsCommandData = {} + +export type InsightsCommandError = { + code: string + message: string +} + +export type InsightsCommandArguments = { + "opt-in"?: boolean + optIn?: boolean +} & RootCommandArguments diff --git a/packages/composable-cli/src/commands/login/epcc-authenticate.ts b/packages/composable-cli/src/commands/login/epcc-authenticate.ts new file mode 100644 index 00000000..c6ad2824 --- /dev/null +++ b/packages/composable-cli/src/commands/login/epcc-authenticate.ts @@ -0,0 +1,44 @@ +import fetch from "node-fetch" +import { encodeObjectToQueryString } from "../../util/encode-object-to-query-str" + +export async function authenticateGrantTypePassword( + apiUrl: string, + username: string, + password: string +): Promise { + const body = { + grant_type: "password", + username, + password, + } + + const response = await fetch(`${apiUrl}/oauth/access_token`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: encodeObjectToQueryString(body), + }) + + return await response.json() +} + +export async function authenticateRefreshToken( + apiUrl: string, + refreshToken: string +): Promise { + const body = { + grant_type: "refresh_token", + refresh_token: refreshToken, + } + + const response = await fetch(`${apiUrl}/oauth/access_token`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: encodeObjectToQueryString(body), + }) + + return await response.json() +} diff --git a/packages/composable-cli/src/commands/login/login-command.ts b/packages/composable-cli/src/commands/login/login-command.ts new file mode 100644 index 00000000..4cff0273 --- /dev/null +++ b/packages/composable-cli/src/commands/login/login-command.ts @@ -0,0 +1,265 @@ +import yargs, { MiddlewareFunction } from "yargs" +import inquirer from "inquirer" +import Conf from "conf" +import { resolveHostFromRegion } from "../../util/resolve-region" +import ora from "ora" +import { checkIsErrorResponse } from "../../util/epcc-error" +import { epccUserProfile } from "../../util/epcc-user-profile" +import { + CommandContext, + CommandHandlerFunction, + RootCommandArguments, +} from "../../types/command" +import { handleErrors } from "../../util/error-handler" +import { + LoginCommandArguments, + LoginCommandData, + LoginCommandError, +} from "./login.types" +import { resolveRegion } from "../../util/conf-store/resolve-region" +import { authenticateGrantTypePassword } from "./epcc-authenticate" +import { + handleClearCredentials, + storeCredentials, + storeUserProfile, +} from "../../util/conf-store/store-credentials" +import { isAuthenticated } from "../../util/check-authenticated" +import React from "react" +import { WelcomeNote } from "../ui/login/welcome-note" +import { render } from "ink" +import { trackCommandHandler } from "../../util/track-command-handler" + +/** + * Region prompts + */ +const regionPrompts = { + type: "list", + name: "region", + message: "What region do you want to authenticated with?", + choices: [ + { name: "us-east (free trial users)", value: "us-east" }, + { name: "eu-west", value: "eu-west" }, + ] as const, + default: "us-east", +} as const + +function handleRegionUpdate(store: Conf, region: "eu-west" | "us-east"): void { + store.set("region", region) +} + +export function createLoginCommand( + ctx: CommandContext +): yargs.CommandModule { + return { + command: "login", + describe: "Login to the Composable CLI", + builder: (yargs) => { + return yargs + .option("region", { + alias: "r", + choices: ["us-east", "eu-west"] as const, + description: "Region of Elastic Path account", + }) + .option("username", { + alias: "u", + type: "string", + description: "Username of Elastic Path account", + }) + .option("password", { + alias: "p", + type: "string", + description: "Password of Elastic Path account", + }) + .example("$0 login", "using interactive prompts") + .example( + "$0 login --region=us-east --username=john.doe@example.com --password=topSecret", + "using command line arguments" + ) + .help() + .parserConfiguration({ + "strip-aliased": true, + }) + }, + handler: handleErrors(trackCommandHandler(ctx, createLoginCommandHandler)), + } +} + +export function createAuthenticationMiddleware( + ctx: CommandContext +): MiddlewareFunction { + return async function authenticationMiddleware( + args: yargs.ArgumentsCamelCase + ) { + const { store } = ctx + + if (isAuthenticated(store) || !args.interactive) { + return + } + + return handleErrors(trackCommandHandler(ctx, createLoginCommandHandler))( + args + ) + } +} + +export function createLoginCommandHandler( + ctx: CommandContext +): CommandHandlerFunction< + LoginCommandData, + LoginCommandError, + LoginCommandArguments +> { + const { store } = ctx + + return async function loginCommandHandler(args) { + const regionAnswers = await inquirer.prompt(regionPrompts, { + ...(args.region ? { region: args.region } : {}), + }) + + handleClearCredentials(store) + + if (regionAnswers.region) { + handleRegionUpdate(store, regionAnswers.region) + } + + const { username, password } = await promptUsernamePasswordLogin(args) + const region = resolveRegion(store) + const apiHost = resolveHostFromRegion(region) + + const spinner = ora("Authenticating").start() + + const result = await authenticateUserPassword( + store, + apiHost, + username, + password + ) + + if (!result.success) { + spinner.fail("Failed to authenticate") + console.log("There was a problem logging you in.") + console.log(`${result.name}`) + console.log(result.message) + return { + success: false, + error: { + code: "authentication-failure", + message: "Failed to authenticate", + }, + } + } + + spinner.text = "Fetching your profile" + + const userProfileResponse = await epccUserProfile( + apiHost, + (result as any).data?.access_token + ) + + if (!userProfileResponse.success) { + spinner.warn("Successfully authenticated but failed to load user profile") + console.warn( + "Their was a problem loading your user profile.", + userProfileResponse.error.code, + userProfileResponse.error.message + ) + return { + success: true, + data: {}, + } + } + + storeUserProfile(store, userProfileResponse.data.data) + + spinner.succeed( + `Successfully authenticated as ${userProfileResponse.data.data.email}` + ) + + await render( + React.createElement(WelcomeNote, { + name: userProfileResponse.data.data.name, + }) + ) + return { + success: true, + data: {}, + } + } +} + +async function authenticateUserPassword( + store: Conf, + apiHost: string, + username: string, + password: string +): Promise< + | { success: true; data: unknown } + | { + success: false + code: "authentication-failure" + name: string + message: string + } +> { + try { + const credentialsResp = await authenticateGrantTypePassword( + apiHost, + username, + password + ) + + if (checkIsErrorResponse(credentialsResp)) { + return { + success: false, + code: "authentication-failure", + name: "epcc error", + message: credentialsResp.errors.toString(), + } + } + + storeCredentials(store, credentialsResp as any) + + return { + success: true, + data: credentialsResp, + } + } catch (e) { + const { name, message } = + e instanceof Error + ? { name: e.name, message: e.message } + : { name: "UnknownError", message: "An unknown error occurred" } + return { + success: false, + code: "authentication-failure", + name, + message, + } + } +} + +async function promptUsernamePasswordLogin( + args: LoginCommandArguments +): Promise<{ + username: string + password: string +}> { + return inquirer.prompt( + [ + { + type: "string", + message: "Enter your username", + name: "username", + }, + { + type: "password", + message: "Enter your password", + name: "password", + mask: "*", + }, + ], + { + ...(args.username ? { username: args.username } : {}), + ...(args.password ? { password: args.password } : {}), + } + ) +} diff --git a/packages/composable-cli/src/commands/login/login.types.ts b/packages/composable-cli/src/commands/login/login.types.ts new file mode 100644 index 00000000..b0202223 --- /dev/null +++ b/packages/composable-cli/src/commands/login/login.types.ts @@ -0,0 +1,14 @@ +import { RootCommandArguments } from "../../types/command" + +export type LoginCommandData = {} + +export type LoginCommandError = { + code: string + message: string +} + +export type LoginCommandArguments = { + username?: string + password?: string + region?: string +} & RootCommandArguments diff --git a/packages/composable-cli/src/commands/login/unauthenticated-message.tsx b/packages/composable-cli/src/commands/login/unauthenticated-message.tsx new file mode 100644 index 00000000..8e0cef4e --- /dev/null +++ b/packages/composable-cli/src/commands/login/unauthenticated-message.tsx @@ -0,0 +1,19 @@ +import React from "react" +import { Box, Text } from "ink" + +export function UnauthenticatedMessage() { + return ( + + It seems you are not currently authenticated. + + If you already have an Elasticpath account, you can use the following + command to authenticate and access your account: + + elasticpath login + + If you haven't registered for an Elasticpath account yet, you can sign + up for a free account by visiting our website: + + + ) +} diff --git a/packages/composable-cli/src/commands/logout/epcc-authenticate.ts b/packages/composable-cli/src/commands/logout/epcc-authenticate.ts new file mode 100644 index 00000000..c6ad2824 --- /dev/null +++ b/packages/composable-cli/src/commands/logout/epcc-authenticate.ts @@ -0,0 +1,44 @@ +import fetch from "node-fetch" +import { encodeObjectToQueryString } from "../../util/encode-object-to-query-str" + +export async function authenticateGrantTypePassword( + apiUrl: string, + username: string, + password: string +): Promise { + const body = { + grant_type: "password", + username, + password, + } + + const response = await fetch(`${apiUrl}/oauth/access_token`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: encodeObjectToQueryString(body), + }) + + return await response.json() +} + +export async function authenticateRefreshToken( + apiUrl: string, + refreshToken: string +): Promise { + const body = { + grant_type: "refresh_token", + refresh_token: refreshToken, + } + + const response = await fetch(`${apiUrl}/oauth/access_token`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: encodeObjectToQueryString(body), + }) + + return await response.json() +} diff --git a/packages/composable-cli/src/commands/logout/logout-command.ts b/packages/composable-cli/src/commands/logout/logout-command.ts new file mode 100644 index 00000000..4202c856 --- /dev/null +++ b/packages/composable-cli/src/commands/logout/logout-command.ts @@ -0,0 +1,47 @@ +import yargs from "yargs" +import { CommandContext, CommandHandlerFunction } from "../../types/command" +import { handleErrors } from "../../util/error-handler" +import { + LogoutCommandArguments, + LogoutCommandData, + LogoutCommandError, +} from "./logout.types" +import { isAuthenticated } from "../../util/check-authenticated" +import { renderInk } from "../../lib/ink/render-ink" +import React from "react" +import { LogoutNote } from "../ui/logout/logout-note" +import { handleClearCredentials } from "../../util/conf-store/store-credentials" +import { trackCommandHandler } from "../../util/track-command-handler" + +export function createLogoutCommand( + ctx: CommandContext +): yargs.CommandModule<{}, LogoutCommandArguments> { + return { + command: "logout", + describe: "Logout of the Composable CLI", + handler: handleErrors(trackCommandHandler(ctx, createLogoutCommandHandler)), + } +} + +export function createLogoutCommandHandler( + ctx: CommandContext +): CommandHandlerFunction< + LogoutCommandData, + LogoutCommandError, + LogoutCommandArguments +> { + const { store } = ctx + + return async function logoutCommandHandler(_args) { + if (isAuthenticated(store)) { + handleClearCredentials(store) + } + + await renderInk(React.createElement(LogoutNote)) + + return { + success: true, + data: {}, + } + } +} diff --git a/packages/composable-cli/src/commands/logout/logout.types.ts b/packages/composable-cli/src/commands/logout/logout.types.ts new file mode 100644 index 00000000..25b1086a --- /dev/null +++ b/packages/composable-cli/src/commands/logout/logout.types.ts @@ -0,0 +1,10 @@ +import { EmptyObj } from "../../types/empty-object" + +export type LogoutCommandData = {} + +export type LogoutCommandError = { + code: string + message: string +} + +export type LogoutCommandArguments = EmptyObj diff --git a/packages/composable-cli/src/commands/logout/unauthenticated-message.tsx b/packages/composable-cli/src/commands/logout/unauthenticated-message.tsx new file mode 100644 index 00000000..8e0cef4e --- /dev/null +++ b/packages/composable-cli/src/commands/logout/unauthenticated-message.tsx @@ -0,0 +1,19 @@ +import React from "react" +import { Box, Text } from "ink" + +export function UnauthenticatedMessage() { + return ( + + It seems you are not currently authenticated. + + If you already have an Elasticpath account, you can use the following + command to authenticate and access your account: + + elasticpath login + + If you haven't registered for an Elasticpath account yet, you can sign + up for a free account by visiting our website: + + + ) +} diff --git a/packages/composable-cli/src/commands/store/store-command.tsx b/packages/composable-cli/src/commands/store/store-command.tsx new file mode 100644 index 00000000..342eec62 --- /dev/null +++ b/packages/composable-cli/src/commands/store/store-command.tsx @@ -0,0 +1,243 @@ +import yargs from "yargs" +import Conf from "conf" +import { + CommandContext, + CommandHandlerFunction, + RootCommandArguments, +} from "../../types/command" +import { handleErrors } from "../../util/error-handler" +import { + SetStoreCommandArguments, + SetStoreCommandData, + SetStoreCommandError, + StoreCommandArguments, + StoreCommandData, + StoreCommandError, +} from "./store.types" +import { resolveHostFromRegion } from "../../util/resolve-region" +import { getToken } from "../../lib/authentication/get-token" +import { + buildStorePrompts, + fetchStore, + switchUserStore, +} from "../../util/build-store-prompts" +import inquirer from "inquirer" +import { createAuthenticationMiddleware } from "../login/login-command" +import { userStoreResponseSchema } from "../../lib/stores/stores-schema" +import { Result } from "../../types/results" +import { + checkIsErrorResponse, + resolveEPCCErrorMessage, +} from "../../util/epcc-error" +import { trackCommandHandler } from "../../util/track-command-handler" + +export function createStoreCommand( + ctx: CommandContext +): yargs.CommandModule { + return { + command: "store", + describe: "interact with Elasticpath store", + builder: (yargs) => { + return yargs + .middleware(createAuthenticationMiddleware(ctx)) + .command(createSetStoreCommand(ctx)) + .help("h") + .demandCommand(1) + .strict() + }, + handler: handleErrors(trackCommandHandler(ctx, createStoreCommandHandler)), + } +} + +export function createSetStoreCommand( + ctx: CommandContext +): yargs.CommandModule { + return { + command: "set", + describe: "Set active store", + builder: (yargs) => { + return yargs + .option("id", { + type: "string", + description: "Id of Elastic Path store to set as active", + }) + .help() + }, + handler: handleErrors( + trackCommandHandler(ctx, createSetStoreCommandHandler) + ), + } +} + +export function createSetStoreCommandHandler( + ctx: CommandContext +): CommandHandlerFunction< + SetStoreCommandData, + SetStoreCommandError, + SetStoreCommandArguments +> { + return async function storeCommandHandler(args) { + if (args.id) { + const selectResult = await selectStoreById(ctx.store, args.id) + + if (!selectResult.success) { + return { + success: false, + error: { + code: "failed-set-store", + message: `Failed to set store: ${selectResult.error.message}`, + }, + } + } + + return { + success: true, + data: {}, + } + } + + const selectResult = await storeSelectPrompt(ctx.store) + + if (!selectResult.success) { + return { + success: false, + error: { + code: "failed-set-store", + message: `Failed to set store: ${selectResult.error.message}`, + }, + } + } + + return { + success: true, + data: {}, + } + } +} + +export function createStoreCommandHandler( + _ctx: CommandContext +): CommandHandlerFunction< + StoreCommandData, + StoreCommandError, + StoreCommandArguments +> { + return async function storeCommandHandler(_args) { + console.warn("command not recognized") + return { + success: false, + error: { + code: "command_not_recognized", + message: "command not recognized", + }, + } + } +} + +export async function selectStoreById( + store: Conf, + id: string +): Promise> { + const apiUrl = resolveHostFromRegion(store.get("region") as any) + const tokenResult = await getToken(apiUrl, store) + + if (!tokenResult.success) { + console.error("Not authenticated: ", tokenResult.error) + return { + success: false, + error: new Error("Not authenticated"), + } + } + + const { data: token } = tokenResult + + const storeResponse = await fetchStore(apiUrl, token, id) + + const parsedResponse = userStoreResponseSchema.safeParse(storeResponse) + + // Handle parsing errors + if (!parsedResponse.success) { + return { + success: false, + error: new Error(parsedResponse.error.message), + } + } + + if (checkIsErrorResponse(parsedResponse.data)) { + return { + success: false, + error: new Error(resolveEPCCErrorMessage(parsedResponse.data.errors)), + } + } + + const { data: parsedResultData } = parsedResponse.data + + const switchStoreResult = await switchUserStore(apiUrl, token, id) + + if (!switchStoreResult.success) { + return { + success: false, + error: new Error(switchStoreResult.error.message), + } + } + + store.set("store", parsedResultData) + + return { + success: true, + data: {}, + } +} + +export async function storeSelectPrompt( + store: Conf +): Promise> { + const apiUrl = resolveHostFromRegion(store.get("region") as any) + const tokenResult = await getToken(apiUrl, store) + + if (!tokenResult.success) { + console.error("Not authenticated: ", tokenResult.error) + return { + success: false, + error: new Error("Not authenticated"), + } + } + + const { data: token } = tokenResult + + const choicesResult = await buildStorePrompts(apiUrl, token) + + if (!choicesResult.success) { + console.error(choicesResult.error) + return { + success: false, + error: new Error(choicesResult.error.message), + } + } + + const answers = await inquirer.prompt([ + { + type: "list", + loop: false, + name: "store", + message: "What store?", + choices: choicesResult.data, + }, + ]) + + const switchResult = await switchUserStore(apiUrl, token, answers.store.id) + + if (!switchResult.success) { + return { + success: false, + error: new Error(switchResult.error.message), + } + } + + store.set("store", answers.store) + + return { + success: true, + data: {}, + } +} diff --git a/packages/composable-cli/src/commands/store/store.types.ts b/packages/composable-cli/src/commands/store/store.types.ts new file mode 100644 index 00000000..6a23cfc8 --- /dev/null +++ b/packages/composable-cli/src/commands/store/store.types.ts @@ -0,0 +1,21 @@ +import { EmptyObj } from "../../types/empty-object" +import { RootCommandArguments } from "../../types/command" + +export type StoreCommandData = {} + +export type StoreCommandError = { + code: string + message: string +} + +export type StoreCommandArguments = RootCommandArguments + +export type SetStoreCommandData = EmptyObj +export type SetStoreCommandError = { + code: string + message: string +} + +export type SetStoreCommandArguments = { + id?: string +} & RootCommandArguments diff --git a/packages/composable-cli/src/commands/ui/feedback/feedback.tsx b/packages/composable-cli/src/commands/ui/feedback/feedback.tsx new file mode 100644 index 00000000..52002b7c --- /dev/null +++ b/packages/composable-cli/src/commands/ui/feedback/feedback.tsx @@ -0,0 +1,30 @@ +import React from "react" +import { Box, Newline, Text } from "ink" + +export function Feedback() { + return ( + + 🌟 Your Feedback Matters! 🌟 + + + Thank you for taking the time to provide us with your valuable feedback! + We greatly appreciate your input, as it helps us shape the future of + composable cli. + + + Your opinion is essential in making our tools better than ever. Simply + follow the link below to our feedback site, where you can share your + thoughts, suggestions, and ideas: + + + https://elasticpath.dev/docs + + ) +} diff --git a/packages/composable-cli/src/commands/ui/generate/d2c-generated.tsx b/packages/composable-cli/src/commands/ui/generate/d2c-generated.tsx new file mode 100644 index 00000000..d423009b --- /dev/null +++ b/packages/composable-cli/src/commands/ui/generate/d2c-generated.tsx @@ -0,0 +1,72 @@ +import React from "react" +import { Box, Text } from "ink" + +export function D2CGenerated({ + name, + nodePkgManager, + skipInstall, +}: { + name: string + nodePkgManager: "yarn" | "npm" | "pnpm" + skipInstall: boolean +}) { + return ( + + Next steps: + {constructSteps({ name, nodePkgManager, skipInstall }).map( + (step, index) => ( + + - Step {index + 1}: {step} + + ) + )} + + ) +} + +function constructSteps({ + name, + nodePkgManager, + skipInstall, +}: { + name: string + nodePkgManager: "yarn" | "npm" | "pnpm" + skipInstall: boolean +}) { + return [ + `Navigate to your project directory using 'cd ${name}'`, + ...(skipInstall + ? [`Run install using '${resolveInstallCommand(nodePkgManager)}'`] + : []), + `Start the development server using '${resolveStartCommand( + nodePkgManager + )}'`, + ] +} + +function resolveStartCommand(nodePkgManager: "yarn" | "npm" | "pnpm") { + switch (nodePkgManager) { + case "yarn": + return "yarn run dev" + case "npm": + return "npm run dev" + case "pnpm": + return "pnpm run dev" + } +} + +function resolveInstallCommand(nodePkgManager: "yarn" | "npm" | "pnpm") { + switch (nodePkgManager) { + case "yarn": + return "yarn install" + case "npm": + return "npm install" + case "pnpm": + return "pnpm install" + } +} diff --git a/packages/composable-cli/src/commands/ui/login/welcome-note.tsx b/packages/composable-cli/src/commands/ui/login/welcome-note.tsx new file mode 100644 index 00000000..e449ecb3 --- /dev/null +++ b/packages/composable-cli/src/commands/ui/login/welcome-note.tsx @@ -0,0 +1,27 @@ +import React from "react" +import Gradient from "ink-gradient" +import BigText from "ink-big-text" +import { Box, Newline, Text } from "ink" + +export function WelcomeNote({ name }: { name: string }) { + return ( + <> + + + + + + 👋 {name} welcome to Elasticpath composable cli + + + A CLI for managing your Elasticpath powered storefront + + + ) +} diff --git a/packages/composable-cli/src/commands/ui/logout/logout-note.tsx b/packages/composable-cli/src/commands/ui/logout/logout-note.tsx new file mode 100644 index 00000000..c48ade69 --- /dev/null +++ b/packages/composable-cli/src/commands/ui/logout/logout-note.tsx @@ -0,0 +1,14 @@ +import React from "react" +import { Box, Text } from "ink" + +export function LogoutNote() { + return ( + + Successfully logged out of Elasticpath composable cli + + We value your feedback! Please let us know about your experience by + using the `feedback` command `composable-cli feedback`. + + + ) +} diff --git a/packages/composable-cli/src/composable.ts b/packages/composable-cli/src/composable.ts index 380a926d..b1133008 100644 --- a/packages/composable-cli/src/composable.ts +++ b/packages/composable-cli/src/composable.ts @@ -1,464 +1,81 @@ #!/usr/bin/env node -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ // symbol polyfill must go first import "symbol-observable" -import { logging, schema, tags } from "@angular-devkit/core" -import { ProcessOutput, createConsoleLogger } from "@angular-devkit/core/node" -import { UnsuccessfulWorkflowExecution } from "@angular-devkit/schematics" -import { NodeWorkflow } from "@angular-devkit/schematics/tools" -import * as ansiColors from "ansi-colors" -import * as inquirer from "inquirer" -import yargsParser, { camelCase, decamelize } from "yargs-parser" - -/** - * Parse the name of schematic passed in argument, and return a {collection, schematic} named - * tuple. The user can pass in `collection-name:schematic-name`, and this function will either - * return `{collection: 'collection-name', schematic: 'schematic-name'}`, or it will error out - * and show usage. - * - * In the case where a collection name isn't part of the argument, the default is to use the - * schematics package (composable-cli) as the collection. - * - * This logic is entirely up to the tooling. - * - * @param str The argument to parse. - * @return {{collection: string, schematic: (string)}} - */ -function parseSchematicName(str: string | null): { - collection: string - schematic: string | null -} { - let collection = "@elasticpath/d2c-schematics" - - let schematic = str - if (schematic?.includes(":")) { - const lastIndexOfColon = schematic.lastIndexOf(":") - ;[collection, schematic] = [ - schematic.slice(0, lastIndexOfColon), - schematic.substring(lastIndexOfColon + 1), - ] - } - - return { collection, schematic } -} +import { ProcessOutput } from "@angular-devkit/core/node" +import yargs from "yargs/yargs" +import { createLoginCommand } from "./commands/login/login-command" +import { createConfigCommand } from "./commands/config/config-command" +import { createCommandContext } from "./util/command" +import { createLogoutCommand } from "./commands/logout/logout-command" +import { createFeedbackCommand } from "./commands/feedback/feedback-command" +import { createStoreCommand } from "./commands/store/store-command" +import { createGenerateCommand } from "./commands/generate/generate-command" +import { hideBin } from "yargs/helpers" +import { createOptInProductInsightsMiddleware } from "./lib/insights/opt-in-product-insights-middleware" +import { createInsightsCommand } from "./commands/insights/insights-command" +import { createPostHogMiddleware } from "./lib/insights/posthog-middleware" +import { createUUIDMiddleware } from "./lib/insights/uuid-middleware" export interface MainOptions { - args: string[] + argv: string[] stdout?: ProcessOutput stderr?: ProcessOutput } -function _listSchematics( - workflow: NodeWorkflow, - collectionName: string, - logger: logging.Logger -) { - try { - logger.info(`collection listed for: ${collectionName}`) - const collection = workflow.engine.createCollection(collectionName) - logger.info(collection.listSchematicNames().join("\n")) - } catch (error) { - logger.fatal(error instanceof Error ? error.message : `${error}`) - - return 1 - } - - return 0 -} - -function _createPromptProvider(): schema.PromptProvider { - return (definitions) => { - const questions: inquirer.QuestionCollection = definitions.map( - (definition) => { - const question: inquirer.Question = { - name: definition.id, - message: definition.message, - default: definition.default, - } - - const validator = definition.validator - if (validator) { - question.validate = (input) => validator(input) - } - - switch (definition.type) { - case "confirmation": - return { ...question, type: "confirm" } - case "list": - return { - ...question, - type: definition.multiselect ? "checkbox" : "list", - choices: - definition.items && - definition.items.map((item) => { - if (typeof item == "string") { - return item - } else { - return { - name: item.label, - value: item.value, - } - } - }), - } - default: - return { ...question, type: definition.type } - } - } - ) - - return inquirer.prompt(questions) - } -} +const commandContext = createCommandContext() // eslint-disable-next-line max-lines-per-function export async function main({ - args, + argv, stdout = process.stdout, stderr = process.stderr, -}: MainOptions): Promise<0 | 1> { - const { cliOptions, schematicOptions, _ } = parseArgs(args) - // Create a separate instance to prevent unintended global changes to the color configuration - const colors = ansiColors.create() - - /** Create the DevKit Logger used through the CLI. */ - const logger = createConsoleLogger(!!cliOptions.verbose, stdout, stderr, { - info: (s) => s, - debug: (s) => s, - warn: (s) => colors.bold.yellow(s), - error: (s) => colors.bold.red(s), - fatal: (s) => colors.bold.red(s), - }) - - logger.debug(`Cli Options: ${JSON.stringify(cliOptions)}`) - logger.debug(`Schematic Options: ${JSON.stringify(schematicOptions)}`) - - if (cliOptions.help) { - logger.info(getUsage()) - - return 0 - } - - /** Get the collection an schematic name from the first argument. */ - const { collection: collectionName, schematic: schematicName } = - parseSchematicName(_.shift() || null) - - const isLocalCollection = - collectionName.startsWith(".") || collectionName.startsWith("/") - - /** Gather the arguments for later use. */ - const debugPresent = cliOptions.debug !== null - const debug = debugPresent ? !!cliOptions.debug : isLocalCollection - const dryRunPresent = cliOptions["dry-run"] !== null - const dryRun = dryRunPresent ? !!cliOptions["dry-run"] : debug - const force = !!cliOptions.force - const allowPrivate = !!cliOptions["allow-private"] - const skipGit = !!cliOptions["skip-git"] - const skipInstall = !!cliOptions["skip-install"] - const skipConfig = !!cliOptions["skip-config"] - - /** Create the workflow scoped to the working directory that will be executed with this run. */ - const workflow = new NodeWorkflow(process.cwd(), { - force, - dryRun, - resolvePaths: [process.cwd(), __dirname], - schemaValidation: true, - }) - - /** If the user wants to list schematics, we simply show all the schematic names. */ - if (cliOptions["list-schematics"]) { - return _listSchematics(workflow, collectionName, logger) - } - - if (!schematicName) { - logger.info(getUsage()) - - return 1 - } - - if (debug) { - logger.info( - `Debug mode enabled${ - isLocalCollection ? " by default for local collections" : "" - }.` - ) - } - - // Indicate to the user when nothing has been done. This is automatically set to off when there's - // a new DryRunEvent. - let nothingDone = true - - // Logging queue that receives all the messages to show the users. This only get shown when no - // errors happened. - let loggingQueue: string[] = [] - let error = false - - /** - * Logs out dry run events. - * - * All events will always be executed here, in order of discovery. That means that an error would - * be shown along other events when it happens. Since errors in workflows will stop the Observable - * from completing successfully, we record any events other than errors, then on completion we - * show them. - * - * This is a simple way to only show errors when an error occur. - */ - workflow.reporter.subscribe((event) => { - nothingDone = false - // Strip leading slash to prevent confusion. - const eventPath = event.path.startsWith("/") - ? event.path.slice(1) - : event.path - - switch (event.kind) { - case "error": - error = true - - const desc = - event.description == "alreadyExist" - ? "already exists" - : "does not exist" - logger.error(`ERROR! ${eventPath} ${desc}.`) - break - case "update": - loggingQueue.push( - `${colors.cyan("UPDATE")} ${eventPath} (${ - event.content.length - } bytes)` - ) - break - case "create": - loggingQueue.push( - `${colors.green("CREATE")} ${eventPath} (${ - event.content.length - } bytes)` - ) - break - case "delete": - loggingQueue.push(`${colors.yellow("DELETE")} ${eventPath}`) - break - case "rename": - const eventToPath = event.to.startsWith("/") - ? event.to.slice(1) - : event.to - loggingQueue.push( - `${colors.blue("RENAME")} ${eventPath} => ${eventToPath}` - ) - break - } - }) - - /** - * Listen to lifecycle events of the workflow to flush the logs between each phases. - */ - workflow.lifeCycle.subscribe((event) => { - if (event.kind == "workflow-end" || event.kind == "post-tasks-start") { - if (!error) { - // Flush the log queue and clean the error state. - loggingQueue.forEach((log) => logger.info(log)) - } - - loggingQueue = [] - error = false - } - }) - - // Show usage of deprecated options - workflow.registry.useXDeprecatedProvider((msg) => logger.warn(msg)) - - // Pass the rest of the arguments as the smart default "argv". Then delete it. - workflow.registry.addSmartDefaultProvider("argv", (schema) => - "index" in schema ? _[Number(schema["index"])] : _ - ) - - // Add prompts. - if (cliOptions.interactive && isTTY()) { - workflow.registry.usePromptProvider(_createPromptProvider()) - } - - /** - * Execute the workflow, which will report the dry run events, run the tasks, and complete - * after all is done. - * - * The Observable returned will properly cancel the workflow if unsubscribed, error out if ANY - * step of the workflow failed (sink or task), with details included, and will only complete - * when everything is done. - */ +}: MainOptions): Promise<1 | 0> { try { - await workflow - .execute({ - collection: collectionName, - schematic: schematicName, - options: { ...schematicOptions, skipGit, skipInstall, skipConfig }, - allowPrivate: allowPrivate, - debug: debug, - logger: logger, + commandContext.stdout = stdout + commandContext.stderr = stderr + + await yargs(hideBin(argv)) + .option("interactive", { + type: "boolean", + default: true, + describe: "Setting to false disables interactive input prompts.", }) - .toPromise() - - if (nothingDone) { - logger.info("Nothing to be done.") - } else if (dryRun) { - logger.info( - `Dry run enabled${ - dryRunPresent ? "" : " by default in debug mode" - }. No files written to disk.` - ) - } + .option("verbose", { + alias: "v", + type: "boolean", + default: false, + description: "Run with verbose logging", + }) + .middleware(createUUIDMiddleware(commandContext)) + .middleware(createOptInProductInsightsMiddleware(commandContext)) + .middleware(createPostHogMiddleware(commandContext)) + .command(createLoginCommand(commandContext)) + .command(createLogoutCommand(commandContext)) + .command(createFeedbackCommand(commandContext)) + .command(createConfigCommand(commandContext)) + .command(createStoreCommand(commandContext)) + .command(createGenerateCommand(commandContext)) + .command(createInsightsCommand(commandContext)) + .example("$0 login", "using interactive prompts") + .example("$0 logout", "logout of the CLI") + .strictCommands() + .demandCommand(1) + .help("h").argv return 0 - } catch (err) { - if (err instanceof UnsuccessfulWorkflowExecution) { - // "See above" because we already printed the error. - logger.fatal("The Schematic workflow failed. See above.") - } else if (debug && err instanceof Error) { - logger.fatal(`An error occured:\n${err.stack}`) - } else { - logger.fatal( - `Error: ${err instanceof Error ? err.message : JSON.stringify(err)}` - ) - } - + } catch (e) { + console.error(e) return 1 - } -} - -/** - * Get usage of the CLI tool. - */ -function getUsage(): string { - return tags.stripIndent` - schematics [collection-name:]schematic-name [options, ...] - - By default, if the collection name is not specified, use the internal collection provided - by the Schematics CLI. - - Options: - --debug Debug mode. This is true by default if the collection is a relative - path (in that case, turn off with --debug=false). - - --allow-private Allow private schematics to be run from the command line. Default to - false. - - --dry-run Do not output anything, but instead just show what actions would be - performed. Default to true if debug is also true. - - --force Force overwriting files that would otherwise be an error. - - --list-schematics List all schematics from the collection, by name. A collection name - should be suffixed by a colon. Example: '@angular-devkit/schematics-cli:'. - - --no-interactive Disables interactive input prompts. - - --verbose Show more information. - - --help Show this message. - - Any additional option is passed to the Schematics depending on its schema. - ` -} - -/** Parse the command line. */ -const booleanArgs = [ - "allow-private", - "debug", - "dry-run", - "force", - "help", - "list-schematics", - "verbose", - "interactive", - "skip-install", - "skip-git", - "skip-config", -] as const - -type ElementType> = T extends ReadonlyArray< - infer ElementType -> - ? ElementType - : never - -interface Options { - _: string[] - schematicOptions: Record - cliOptions: Partial, boolean | null>> -} - -/** Parse the command line. */ -function parseArgs(args: string[]): Options { - const { _, ...options } = yargsParser(args, { - boolean: booleanArgs as unknown as string[], - default: { - interactive: true, - debug: null, - "dry-run": null, - }, - configuration: { - "dot-notation": false, - "boolean-negation": true, - "strip-aliased": true, - "camel-case-expansion": false, - }, - }) - - // Camelize options as yargs will return the object in kebab-case when camel casing is disabled. - const schematicOptions: Options["schematicOptions"] = {} - const cliOptions: Options["cliOptions"] = {} - - const isCliOptions = ( - key: ElementType | string - ): key is ElementType => - booleanArgs.includes(key as ElementType) - - for (const [key, value] of Object.entries(options)) { - if (/[A-Z]/.test(key)) { - throw new Error( - `Unknown argument ${key}. Did you mean ${decamelize(key)}?` - ) + } finally { + if (commandContext.posthog) { + await commandContext.posthog.client.shutdownAsync() } - - if (isCliOptions(key)) { - cliOptions[key] = value - } else { - schematicOptions[camelCase(key)] = value - } - } - - return { - _: _.map((v) => v.toString()), - schematicOptions, - cliOptions, - } -} - -function isTTY(): boolean { - const isTruthy = (value: undefined | string) => { - // Returns true if value is a string that is anything but 0 or false. - return ( - value !== undefined && value !== "0" && value.toUpperCase() !== "FALSE" - ) } - - // If we force TTY, we always return true. - const force = process.env["NG_FORCE_TTY"] - if (force !== undefined) { - return isTruthy(force) - } - - return !!process.stdout.isTTY && !isTruthy(process.env["CI"]) } if (require.main === module) { - const args = process.argv.slice(2) - main({ args }) + main({ argv: process.argv }) .then((exitCode) => (process.exitCode = exitCode)) .catch((e) => { throw e diff --git a/packages/composable-cli/src/lib/authentication/credentials-schema.ts b/packages/composable-cli/src/lib/authentication/credentials-schema.ts new file mode 100644 index 00000000..c748aa55 --- /dev/null +++ b/packages/composable-cli/src/lib/authentication/credentials-schema.ts @@ -0,0 +1,20 @@ +import { z } from "zod" +import { epccErrorResponseSchema } from "../epcc-error-schema" + +export const credentialsSchema = z.object({ + access_token: z.string(), + token_type: z.literal("Bearer"), + expires_in: z.number(), + expires: z.number(), + refresh_token: z.string(), + identifier: z.literal("password"), +}) + +export const credentialsResponseSchema = z.union([ + credentialsSchema, + epccErrorResponseSchema, +]) + +export type CredentialsResponse = z.infer + +export type Credentials = z.infer diff --git a/packages/composable-cli/src/lib/authentication/get-profile.ts b/packages/composable-cli/src/lib/authentication/get-profile.ts new file mode 100644 index 00000000..3660d530 --- /dev/null +++ b/packages/composable-cli/src/lib/authentication/get-profile.ts @@ -0,0 +1,23 @@ +import Conf from "conf" +import { Result } from "../../types/results" +import { epccUserProfileSchema, UserProfile } from "../epcc-user-profile-schema" + +export async function getProfile( + store: Conf +): Promise> { + const parsedStore = epccUserProfileSchema.safeParse(store.get("profile")) + + if (!parsedStore.success) { + return { + success: false, + error: new Error( + `User profile not found in store: ${parsedStore.error.message}` + ), + } + } + + return { + success: true, + data: parsedStore.data, + } +} diff --git a/packages/composable-cli/src/lib/authentication/get-token.ts b/packages/composable-cli/src/lib/authentication/get-token.ts new file mode 100644 index 00000000..beecd6db --- /dev/null +++ b/packages/composable-cli/src/lib/authentication/get-token.ts @@ -0,0 +1,100 @@ +import Conf from "conf" +import { + Credentials, + credentialsResponseSchema, + credentialsSchema, +} from "./credentials-schema" +import { authenticateRefreshToken } from "../../commands/login/epcc-authenticate" +import { hasExpiredWithThreshold } from "../../util/has-expired" +import { Result } from "../../types/results" +import { EPCCErrorResponse } from "../epcc-error-schema" +import { + checkIsErrorResponse, + resolveEPCCErrorMessage, +} from "../../util/epcc-error" + +export async function getToken( + apiUrl: string, + store: Conf +): Promise> { + const credentials = credentialsSchema.safeParse(store.get("credentials")) + + if (!credentials.success) { + return { + success: false, + error: new Error( + `Credentials not found in store: ${credentials.error.message}` + ), + } + } + + if ( + hasExpiredWithThreshold( + credentials.data.expires, + credentials.data.expires_in, + 300 // 5 minutes + ) + ) { + const renewedToken = await renewToken( + apiUrl, + credentials.data.refresh_token + ) + + if (process.env.NODE_ENV === "development") { + console.log( + "CALL WAS MADE TO RENEW TOKEN DID YOU EXPECT THIS? ", + renewedToken + ) + } + + if (!renewedToken.success) { + return { + success: false, + error: renewedToken.error, + } + } + + store.set("credentials", renewedToken.data) + return { + success: true, + data: renewedToken.data.access_token, + } + } + + return { + success: true, + data: credentials.data.access_token, + } +} + +export type GetTokenError = EPCCErrorResponse | Error + +async function renewToken( + apiUrl: string, + refreshToken: string +): Promise> { + const renewalResponse = await authenticateRefreshToken(apiUrl, refreshToken) + const credentialsResponse = + credentialsResponseSchema.safeParse(renewalResponse) + + if (!credentialsResponse.success) { + return { + success: false, + error: new Error(credentialsResponse.error.message), + } + } + + if (checkIsErrorResponse(credentialsResponse.data)) { + return { + success: false, + error: new Error( + resolveEPCCErrorMessage(credentialsResponse.data.errors) + ), + } + } + + return { + success: true, + data: credentialsResponse.data, + } +} diff --git a/packages/composable-cli/src/lib/authentication/get-uuid.ts b/packages/composable-cli/src/lib/authentication/get-uuid.ts new file mode 100644 index 00000000..ada7bf10 --- /dev/null +++ b/packages/composable-cli/src/lib/authentication/get-uuid.ts @@ -0,0 +1,19 @@ +import Conf from "conf" +import { Result } from "../../types/results" +import { z } from "zod" + +export async function getUUID(store: Conf): Promise> { + const parsedStore = z.string().safeParse(store.get("uuid")) + + if (!parsedStore.success) { + return { + success: false, + error: new Error(`UUID not found in store: ${parsedStore.error.message}`), + } + } + + return { + success: true, + data: parsedStore.data, + } +} diff --git a/packages/composable-cli/src/lib/epcc-error-schema.ts b/packages/composable-cli/src/lib/epcc-error-schema.ts new file mode 100644 index 00000000..549846de --- /dev/null +++ b/packages/composable-cli/src/lib/epcc-error-schema.ts @@ -0,0 +1,24 @@ +import { z } from "zod" + +const epccErrorSchema = z.object({ + status: z.union([z.string(), z.number()]), + title: z.string(), + detail: z.string().optional(), +}) + +const epccCodeErrorSchema = z.object({ + code: z.string(), + title: z.string(), + detail: z.string().optional(), +}) + +export const epccErrorResponseSchema = z.object({ + errors: z.union([ + z.array(epccErrorSchema), + epccErrorSchema, + z.array(epccCodeErrorSchema), + epccCodeErrorSchema, + ]), +}) + +export type EPCCErrorResponse = z.infer diff --git a/packages/composable-cli/src/lib/epcc-user-profile-schema.ts b/packages/composable-cli/src/lib/epcc-user-profile-schema.ts new file mode 100644 index 00000000..32d3368c --- /dev/null +++ b/packages/composable-cli/src/lib/epcc-user-profile-schema.ts @@ -0,0 +1,35 @@ +import { z } from "zod" +import { epccErrorResponseSchema } from "./epcc-error-schema" + +/** + * User profile types + */ +export const epccUserProfileSchema = z.object({ + id: z.string(), + name: z.string(), + email: z.string(), + type: z.literal("user_profile"), +}) + +const epccUserProfileSuccessResponseSchema = z.object({ + data: epccUserProfileSchema, +}) + +export type UserProfile = z.infer + +export type EpccUserProfileSuccessResponse = z.infer< + typeof epccUserProfileSuccessResponseSchema +> + +export const epccUserProfileResponseSchema = z.union([ + epccUserProfileSuccessResponseSchema, + epccErrorResponseSchema, +]) + +export type EPCCUserProfileResponse = z.infer< + typeof epccUserProfileResponseSchema +> + +export type EPCCUserProfileSuccessResponse = z.infer< + typeof epccUserProfileSuccessResponseSchema +> diff --git a/packages/composable-cli/src/lib/ink/render-ink.tsx b/packages/composable-cli/src/lib/ink/render-ink.tsx new file mode 100644 index 00000000..4492dad4 --- /dev/null +++ b/packages/composable-cli/src/lib/ink/render-ink.tsx @@ -0,0 +1,14 @@ +import React, { Fragment } from "react" +import { render } from "ink" +import { exit } from "process" + +export const renderInk = async (component: React.ReactElement) => { + const { waitUntilExit } = render(React.createElement(Fragment, {}, component)) + + try { + await waitUntilExit() + } catch (e: any) { + console.error(e.message) + exit(1) + } +} diff --git a/packages/composable-cli/src/lib/insights/capture-posthog.ts b/packages/composable-cli/src/lib/insights/capture-posthog.ts new file mode 100644 index 00000000..37ff5c40 --- /dev/null +++ b/packages/composable-cli/src/lib/insights/capture-posthog.ts @@ -0,0 +1,33 @@ +import { PostHog } from "posthog-node" +import { getProfile } from "../authentication/get-profile" +import Conf from "conf" +import { EventMessageV1 } from "posthog-node/lib/posthog-node/src/types" +import { getUUID } from "../authentication/get-uuid" + +export async function createPostHogCapture(store: Conf, client: PostHog) { + const uuidResult = await getUUID(store) + + if (!uuidResult.success) { + throw new Error( + `Unable to create post hog capture failed to get uuid from store: ${uuidResult.error.message}` + ) + } + + const profile = await getProfile(store) + + const resolvedDistinctId = profile.success ? profile.data.id : uuidResult.data + + return function capt(args: Omit) { + return client.capture({ + ...args, + distinctId: resolvedDistinctId, + properties: { + ...args.properties, + $set_once: { + ...args.properties?.["$set_once"], + cli_user: true, + }, + }, + }) + } +} diff --git a/packages/composable-cli/src/lib/insights/opt-in-product-insights-middleware.ts b/packages/composable-cli/src/lib/insights/opt-in-product-insights-middleware.ts new file mode 100644 index 00000000..b80f6216 --- /dev/null +++ b/packages/composable-cli/src/lib/insights/opt-in-product-insights-middleware.ts @@ -0,0 +1,66 @@ +import { CommandContext, RootCommandArguments } from "../../types/command" +import { MiddlewareFunction } from "yargs" +import { isOptedInsights, optInsights } from "../../util/has-opted-insights" +import inquirer from "inquirer" +import Conf from "conf" + +const optInQuestion: inquirer.QuestionCollection = [ + { + type: "confirm", + name: "optIn", + message: ` +Welcome to Elastic Paths Composable CLI! + +This is a new tool. To help us improve, would you like to opt-in to error tracking? + +When you opt-in, we'll collect: +- Commands you run with composable cli +- Error messages + +Your data will be used solely for improving our tool. + +(Your data will be kept private) +`, + default: true, // You can set the default value to true or false as needed + }, +] + +export function createOptInProductInsightsMiddleware( + ctx: CommandContext +): MiddlewareFunction { + return async function optInProductInsightsMiddleware( + args: RootCommandArguments + ) { + const { store } = ctx + + if (isOptedInsights(store) || isInsightsCommand(args)) { + return + } + + if (!args.interactive) { + optInsights(store, false) + return + } + + await promptOptInProductInsights(store) + return + } +} + +function isInsightsCommand(argv: any): boolean { + return argv._[0] === "insights" +} + +export async function promptOptInProductInsights(store: Conf): Promise { + const answers = await inquirer.prompt(optInQuestion) + + if (answers.optIn) { + console.log("Thank you for opting in. Your data will be kept private.") + } else { + console.log( + "You have chosen not to opt-in. Your privacy will be fully respected." + ) + } + + optInsights(store, answers.optIn) +} diff --git a/packages/composable-cli/src/lib/insights/posthog-middleware.ts b/packages/composable-cli/src/lib/insights/posthog-middleware.ts new file mode 100644 index 00000000..5f90cdb8 --- /dev/null +++ b/packages/composable-cli/src/lib/insights/posthog-middleware.ts @@ -0,0 +1,36 @@ +import { CommandContext } from "../../types/command" +import { MiddlewareFunction } from "yargs" +import { isOptIn } from "../../util/has-opted-insights" +import { PostHog } from "posthog-node" +import { createPostHogCapture } from "./capture-posthog" + +export function createPostHogMiddleware( + ctx: CommandContext +): MiddlewareFunction { + return async function postHogMiddleware(_argv: any) { + const { store } = ctx + + if (!isOptIn(store)) { + return + } + + const postHogKey = process.env.POSTHOG_PUBLIC_API_KEY + + if (!postHogKey) { + console.warn( + "Missing POSTHOG_PUBLIC_API_KEY environment variable at build time, skipping PostHog." + ) + return + } + + const client = new PostHog(postHogKey, { + host: "https://app.posthog.com", + }) + + ctx.posthog = { + client, + postHogCapture: await createPostHogCapture(store, client), + } + return + } +} diff --git a/packages/composable-cli/src/lib/insights/uuid-middleware.ts b/packages/composable-cli/src/lib/insights/uuid-middleware.ts new file mode 100644 index 00000000..7db2bc77 --- /dev/null +++ b/packages/composable-cli/src/lib/insights/uuid-middleware.ts @@ -0,0 +1,17 @@ +import { CommandContext } from "../../types/command" +import { MiddlewareFunction } from "yargs" +import { hasUUID, setUUID } from "../../util/has-uuid" +import { uuidv7 } from "../../util/uuidv7" + +export function createUUIDMiddleware(ctx: CommandContext): MiddlewareFunction { + return async function uuidMiddleware(_argv: any) { + const { store } = ctx + + if (hasUUID(store)) { + return + } + + setUUID(store, uuidv7()) + return + } +} diff --git a/packages/composable-cli/src/lib/logger/logger.ts b/packages/composable-cli/src/lib/logger/logger.ts new file mode 100644 index 00000000..d4f4e38f --- /dev/null +++ b/packages/composable-cli/src/lib/logger/logger.ts @@ -0,0 +1,80 @@ +const logger = console + +type LoggerStatus = + | "task" + | "start" + | "end" + | "info" + | "warn" + | "error" + | "debug" +const write = ( + status: LoggerStatus, + text: string, + data?: object, + verbose = false +) => { + let textToLog = "" + + if (status === "task") textToLog = "👍 " + else if (status === "start") textToLog = "\n🔥 " + else if (status === "end") textToLog = "\n✅ " + else if (status === "info") textToLog = "ℹ️ " + else if (status === "warn") textToLog = "🙀 " + else if (status === "error") textToLog = "\n❌ " + else if (status === "debug") textToLog = "🐞 " + + textToLog += text + + // Adds optional verbose output + if (verbose) { + textToLog += `\n${verbose}` + } + + logger.log(textToLog) + if (["start", "end", "error"].indexOf(status) > -1) { + logger.log() + } + if (data) logger.dir(verbose, { depth: 15 }) +} +// Printing any statements +export const log = (text: string) => { + logger.log(text) +} + +// Starting a process +export const start = (text: string) => { + write("start", text) +} + +// Ending a process +export const end = (text: string) => { + write("end", text) +} + +// Tasks within a process +export const task = (text: string) => { + write("task", text) +} + +// Info about a process task +export const info = (text: string) => { + write("info", text) +} + +// Verbose output +// takes optional data +export const debug = (text: string, data: any) => { + write("debug", text, data) +} + +// Warn output +export const warn = (text: string, data?: object) => { + write("warn", text, data) +} + +// Error output +// takes an optional error +export const error = (text: string, err?: Error) => { + write("error", text, err) +} diff --git a/packages/composable-cli/src/lib/stores/get-store.ts b/packages/composable-cli/src/lib/stores/get-store.ts new file mode 100644 index 00000000..ffa0328b --- /dev/null +++ b/packages/composable-cli/src/lib/stores/get-store.ts @@ -0,0 +1,21 @@ +import Conf from "conf" +import { Result } from "../../types/results" +import { UserStore, userStoreSchema } from "./stores-schema" + +export async function getStore(store: Conf): Promise> { + const parsedStore = userStoreSchema.safeParse(store.get("store")) + + if (!parsedStore.success) { + return { + success: false, + error: new Error( + `Store not found in store: ${parsedStore.error.message}` + ), + } + } + + return { + success: true, + data: parsedStore.data, + } +} diff --git a/packages/composable-cli/src/lib/stores/stores-schema.ts b/packages/composable-cli/src/lib/stores/stores-schema.ts new file mode 100644 index 00000000..f17be6a0 --- /dev/null +++ b/packages/composable-cli/src/lib/stores/stores-schema.ts @@ -0,0 +1,42 @@ +import { z } from "zod" +import { epccErrorResponseSchema } from "../epcc-error-schema" + +export const userStoreSchema = z.object({ + id: z.string(), + name: z.string(), + store_type: z.string(), + type: z.literal("store"), + ep_disabled: z.boolean(), + meta: z.object({ + timestamps: z.object({ + created_at: z.string(), + updated_at: z.string(), + }), + }), +}) + +export const userStoresSuccessResponseSchema = z.object({ + data: z.array(userStoreSchema), +}) + +export const userStoresResponseSchema = z.union([ + userStoresSuccessResponseSchema, + epccErrorResponseSchema, +]) + +export type UserStoresResponse = z.infer + +export type UserStoresSuccessResponse = z.infer< + typeof userStoresSuccessResponseSchema +> + +export type UserStore = z.infer + +export const userStoreSuccessResponseSchema = z.object({ + data: userStoreSchema, +}) + +export const userStoreResponseSchema = z.union([ + userStoreSuccessResponseSchema, + epccErrorResponseSchema, +]) diff --git a/packages/composable-cli/src/lib/stores/switch-store-schema.ts b/packages/composable-cli/src/lib/stores/switch-store-schema.ts new file mode 100644 index 00000000..e1c46cad --- /dev/null +++ b/packages/composable-cli/src/lib/stores/switch-store-schema.ts @@ -0,0 +1,14 @@ +import { z } from "zod" +import { epccErrorResponseSchema } from "../epcc-error-schema" + +export const userSwitchStoreSuccessResponseSchema = z.object({ + data: z.object({ + title: z.string(), + status: z.literal(200), + }), +}) + +export const userSwitchStoreResponseSchema = z.union([ + userSwitchStoreSuccessResponseSchema, + epccErrorResponseSchema, +]) diff --git a/packages/composable-cli/src/types/command.ts b/packages/composable-cli/src/types/command.ts new file mode 100644 index 00000000..b3075366 --- /dev/null +++ b/packages/composable-cli/src/types/command.ts @@ -0,0 +1,40 @@ +import Conf from "conf" +import fetch from "node-fetch" +import yargs from "yargs" +import type { PostHog } from "posthog-node" +import { createPostHogCapture } from "../lib/insights/capture-posthog" +import { ProcessOutput } from "@angular-devkit/core/node" + +export type CommandResult = + | { + success: true + data: TData + } + | { + success: false + error: TError + } + +export type CommandContext = { + store: Conf + requester: typeof fetch + posthog?: { + client: PostHog + postHogCapture: Awaited> + } + stdout: ProcessOutput + stderr: ProcessOutput +} + +export type RootCommandArguments = { + interactive: boolean + verbose: boolean +} + +export type CommandHandlerFunction< + TData, + TError, + TCommandArguments extends Record +> = ( + args: yargs.ArgumentsCamelCase +) => Promise> diff --git a/packages/composable-cli/src/types/empty-object.ts b/packages/composable-cli/src/types/empty-object.ts new file mode 100644 index 00000000..acd83b6d --- /dev/null +++ b/packages/composable-cli/src/types/empty-object.ts @@ -0,0 +1 @@ +export type EmptyObj = Record diff --git a/packages/composable-cli/src/types/results.ts b/packages/composable-cli/src/types/results.ts new file mode 100644 index 00000000..a0f62e0f --- /dev/null +++ b/packages/composable-cli/src/types/results.ts @@ -0,0 +1,11 @@ +export type ResultSuccess = { + success: true + data: TData +} + +export type ResultFailed = { + success: false + error: TError +} + +export type Result = ResultSuccess | ResultFailed diff --git a/packages/composable-cli/src/util/active-store.ts b/packages/composable-cli/src/util/active-store.ts new file mode 100644 index 00000000..1615d033 --- /dev/null +++ b/packages/composable-cli/src/util/active-store.ts @@ -0,0 +1,5 @@ +import Conf from "conf" + +export function hasActiveStore(store: Conf): boolean { + return store.has("store") +} diff --git a/packages/composable-cli/src/util/build-store-prompts.ts b/packages/composable-cli/src/util/build-store-prompts.ts new file mode 100644 index 00000000..3d42ade3 --- /dev/null +++ b/packages/composable-cli/src/util/build-store-prompts.ts @@ -0,0 +1,129 @@ +import fetch from "node-fetch" +import { + UserStore, + userStoresResponseSchema, +} from "../lib/stores/stores-schema" +import { checkIsErrorResponse, resolveEPCCErrorMessage } from "./epcc-error" +import { userSwitchStoreResponseSchema } from "../lib/stores/switch-store-schema" +import { Result } from "../types/results" + +type PromptBuildSuccessResult = { + success: true + data: TData +} + +type PromptBuildErrorResult = { + success: false + error: TError +} + +type PromptBuildResult = + | PromptBuildSuccessResult + | PromptBuildErrorResult + +export async function buildStorePrompts( + apiUrl: string, + token: string +): Promise> { + const storesResponse = await fetchUserStores(apiUrl, token) + + const parsedResponse = userStoresResponseSchema.safeParse(storesResponse) + + // Handle parsing errors + if (!parsedResponse.success) { + return { + success: false, + error: new Error(parsedResponse.error.message), + } + } + + const { data: parsedResultData } = parsedResponse + + //Handle epcc errors + if (checkIsErrorResponse(parsedResultData)) { + return { + success: false, + error: new Error(resolveEPCCErrorMessage(parsedResultData.errors)), + } + } + + return { + success: true, + data: mapStoresToStorePrompts(parsedResultData.data), + } +} + +function mapStoresToStorePrompts(stores: UserStore[]) { + return stores.map((store) => { + return { + name: `${store.name} - ${store.id}`, + value: store, + } + }) +} + +async function fetchUserStores( + apiUrl: string, + token: string +): Promise { + const stores = await fetch(`${apiUrl}/v2/user/stores`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }) + + return stores.json() +} + +export async function switchUserStore( + apiUrl: string, + token: string, + storeId: string +): Promise> { + const switchResult = await postSwitchUserStore(apiUrl, token, storeId) + + const parsedResult = userSwitchStoreResponseSchema.safeParse(switchResult) + + if (!parsedResult.success) { + return { + success: false, + error: new Error(parsedResult.error.message), + } + } + + return { + success: true, + data: {}, + } +} + +export async function postSwitchUserStore( + apiUrl: string, + token: string, + storeId: string +): Promise { + const response = await fetch( + `${apiUrl}/v1/account/stores/switch/${storeId}`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + } + ) + + return response.json() +} + +export async function fetchStore( + apiUrl: string, + token: string, + storeId: String +): Promise { + const stores = await fetch(`${apiUrl}/v2/user/stores/${storeId}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }) + + return stores.json() +} diff --git a/packages/composable-cli/src/util/check-authenticated.ts b/packages/composable-cli/src/util/check-authenticated.ts new file mode 100644 index 00000000..7d4064fc --- /dev/null +++ b/packages/composable-cli/src/util/check-authenticated.ts @@ -0,0 +1,5 @@ +import Conf from "conf" + +export function isAuthenticated(store: Conf): boolean { + return store.has("credentials") +} diff --git a/packages/composable-cli/src/util/command.ts b/packages/composable-cli/src/util/command.ts new file mode 100644 index 00000000..b3776c9c --- /dev/null +++ b/packages/composable-cli/src/util/command.ts @@ -0,0 +1,37 @@ +import Conf from "conf" +import { Schema } from "conf/dist/source/types" +import { CommandContext } from "../types/command" +import fetch from "node-fetch" + +export const storeSchema = { + credentials: { + type: "object", + properties: { + access_token: { type: "string" }, + expires: { type: "number" }, + expires_in: { type: "number" }, + identifier: { type: "string" }, + refresh_token: { type: "string" }, + token_type: { type: "string" }, + }, + }, + region: { + enum: ["eu-west", "us-east"], + type: "string", + default: "eu-west", + }, +} + +export function createCommandContext(): CommandContext { + const store = new Conf({ + projectName: "composable-cli", + schema: storeSchema as Schema>, + }) + + return { + store, + requester: fetch, + stdout: process.stdout, + stderr: process.stderr, + } +} diff --git a/packages/composable-cli/src/util/conf-store/resolve-region.ts b/packages/composable-cli/src/util/conf-store/resolve-region.ts new file mode 100644 index 00000000..69fc3833 --- /dev/null +++ b/packages/composable-cli/src/util/conf-store/resolve-region.ts @@ -0,0 +1,5 @@ +import Conf from "conf" + +export function resolveRegion(store: Conf): "us-east" | "eu-west" { + return store.get("region") as any +} diff --git a/packages/composable-cli/src/util/conf-store/store-credentials.ts b/packages/composable-cli/src/util/conf-store/store-credentials.ts new file mode 100644 index 00000000..1691f20b --- /dev/null +++ b/packages/composable-cli/src/util/conf-store/store-credentials.ts @@ -0,0 +1,20 @@ +import Conf from "conf" +import { UserProfile } from "../../lib/epcc-user-profile-schema" + +export function storeCredentials( + store: Conf, + credentials: { accessToken: string; refreshToken: string; expires: number } +) { + return store.set("credentials", credentials) +} + +export function handleClearCredentials(store: Conf): void { + store.delete("credentials") + store.delete("store") + store.delete("region") + store.delete("profile") +} + +export function storeUserProfile(store: Conf, userProfile: UserProfile) { + return store.set("profile", userProfile) +} diff --git a/packages/composable-cli/src/util/create-client-secret.ts b/packages/composable-cli/src/util/create-client-secret.ts new file mode 100644 index 00000000..109ba577 --- /dev/null +++ b/packages/composable-cli/src/util/create-client-secret.ts @@ -0,0 +1,35 @@ +import fetch from "node-fetch" + +export async function createApplicationKeys( + apiUrl: string, + token: string, + name?: string +) { + const result = await postApplicationKeys(apiUrl, token, name) + + return result +} + +export async function postApplicationKeys( + apiUrl: string, + token: string, + name?: string +) { + const resp = await fetch(`${apiUrl}/v2/application-keys`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + data: { + name: name ?? `composable-cli-${new Date().toISOString()}`, + type: "application_key", + }, + }), + }) + + const result = await resp.json() + + return result +} diff --git a/packages/composable-cli/src/util/encode-object-to-query-str.ts b/packages/composable-cli/src/util/encode-object-to-query-str.ts new file mode 100644 index 00000000..768036b7 --- /dev/null +++ b/packages/composable-cli/src/util/encode-object-to-query-str.ts @@ -0,0 +1,5 @@ +export function encodeObjectToQueryString(body: Record): string { + return Object.keys(body) + .map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(body[k])}`) + .join("&") +} diff --git a/packages/composable-cli/src/util/epcc-endpoint.types.ts b/packages/composable-cli/src/util/epcc-endpoint.types.ts new file mode 100644 index 00000000..6732c1dd --- /dev/null +++ b/packages/composable-cli/src/util/epcc-endpoint.types.ts @@ -0,0 +1,3 @@ +export type EPCCEndpointResult = + | { success: true; data: D } + | { success: false; error: E } diff --git a/packages/composable-cli/src/util/epcc-error.ts b/packages/composable-cli/src/util/epcc-error.ts new file mode 100644 index 00000000..e5d3ed7b --- /dev/null +++ b/packages/composable-cli/src/util/epcc-error.ts @@ -0,0 +1,16 @@ +import { EPCCErrorResponse } from "../lib/epcc-error-schema" + +export function checkIsErrorResponse( + response: any +): response is EPCCErrorResponse { + return !!response && "errors" in response +} + +export function resolveEPCCErrorMessage( + errors: EPCCErrorResponse["errors"] +): string { + // TODO: extract all the errors if it's an array and process into a useful message. + const extractedError = Array.isArray(errors) ? errors[0] : errors + + return extractedError.detail ? extractedError.detail : extractedError.title +} diff --git a/packages/composable-cli/src/util/epcc-user-profile.ts b/packages/composable-cli/src/util/epcc-user-profile.ts new file mode 100644 index 00000000..3dcd50b1 --- /dev/null +++ b/packages/composable-cli/src/util/epcc-user-profile.ts @@ -0,0 +1,91 @@ +import { checkIsErrorResponse, resolveEPCCErrorMessage } from "./epcc-error" +import { ZodError } from "zod" +import { EPCCEndpointResult } from "./epcc-endpoint.types" +import { + EPCCUserProfileResponse, + epccUserProfileResponseSchema, + EPCCUserProfileSuccessResponse, +} from "../lib/epcc-user-profile-schema" +import fetch from "node-fetch" + +export type UserProfileError = { + code: "epcc-endpoint-error" | "unexpected-data-shape" | "unknown-error" + message: string +} + +export async function epccUserProfile( + apiUrl: string, + accessToken: string +): Promise< + EPCCEndpointResult +> { + try { + const result = await getEPCCUserProfile(apiUrl, accessToken) + + const parsedResult = epccUserProfileResponseSchema.safeParse(result) + + if (parsedResult.success) { + return createResult(parsedResult.data) + } else { + return createUnexpectedDataShapeResult(parsedResult.error) + } + } catch (e) { + console.error(e instanceof Error ? e.message : e) + return createUnknownErrorResult(e) + } +} + +function createUnknownErrorResult( + e: unknown +): EPCCEndpointResult { + return { + success: false, + error: { + code: "unknown-error", + message: `epccUserProfile: An unknown error occurred ${ + e instanceof Error ? e.message : e + }`, + }, + } +} + +function createUnexpectedDataShapeResult( + error: ZodError +): EPCCEndpointResult { + return { + success: false, + error: { + code: "unexpected-data-shape", + message: error.message, + }, + } +} + +function createResult( + response: EPCCUserProfileResponse +): EPCCEndpointResult { + if (checkIsErrorResponse(response)) { + return { + success: false, + error: { + code: "epcc-endpoint-error", + message: resolveEPCCErrorMessage(response.errors), + }, + } + } + + return { + success: true, + data: response, + } +} + +async function getEPCCUserProfile( + apiUrl: string, + accessToken: string +): Promise { + return fetch(`${apiUrl}/v2/user`, { + method: "GET", + headers: { Authorization: `Bearer ${accessToken}` }, + }).then((res) => res.json()) +} diff --git a/packages/composable-cli/src/util/error-handler.ts b/packages/composable-cli/src/util/error-handler.ts new file mode 100644 index 00000000..57dc4945 --- /dev/null +++ b/packages/composable-cli/src/util/error-handler.ts @@ -0,0 +1,40 @@ +const makeErrorWrapper = + (errorHandler: (err: unknown) => T) => + (fn: (...a: A) => Promise) => + async (...a: A): Promise => { + try { + const result = await fn(...a) + + if (isResultError(result)) { + return Promise.reject(result.error) + } + + return Promise.resolve() + } catch (err) { + await errorHandler(err) + return Promise.resolve() + } + } + +function isResultError( + result: any +): result is { success: false; error: unknown } { + return ( + !!result && + "success" in result && + typeof result.success === "boolean" && + !result.success + ) +} + +export const handleErrors = makeErrorWrapper((err) => { + if (err instanceof Error) { + console.error(err.name) + console.error(err.message) + console.error(err.stack) + console.error(err.cause) + return Promise.resolve() + } + console.error("There was an unexpected error!") + return Promise.resolve() +}) diff --git a/packages/composable-cli/src/util/has-expired.test.ts b/packages/composable-cli/src/util/has-expired.test.ts new file mode 100644 index 00000000..ef9f0351 --- /dev/null +++ b/packages/composable-cli/src/util/has-expired.test.ts @@ -0,0 +1,32 @@ +import { hasExpiredWithThreshold } from "./has-expired" + +describe("hasExpiredWithThreshold", () => { + // Test case 1: Token has expired + it("hasExpiredWithThreshold returns true when the token has expired", () => { + const unixTimestamp = 1695056417 + const expiresIn = 3600 + const threshold = 300 + const result = hasExpiredWithThreshold(unixTimestamp, expiresIn, threshold) + expect(result).toBe(true) + }) + + // Test case 2: Token is still valid within the threshold + it("hasExpiredWithThreshold returns false when the token is still valid within the threshold", () => { + const currentTimestamp = Math.floor(Date.now() / 1000) + const unixTimestamp = currentTimestamp + 1800 // Expires in 30 minutes + const expiresIn = 3600 + const threshold = 3600 // 1 hour threshold + const result = hasExpiredWithThreshold(unixTimestamp, expiresIn, threshold) + expect(result).toBe(false) + }) + + // Test case 3: Token is still valid and outside the threshold + it("hasExpiredWithThreshold returns false when the token is still valid and outside the threshold", () => { + const currentTimestamp = Math.floor(Date.now() / 1000) + const unixTimestamp = currentTimestamp + 3600 // Expires in 1 hour + const expiresIn = 7200 // Expires in 2 hours + const threshold = 1800 // 30 minutes threshold + const result = hasExpiredWithThreshold(unixTimestamp, expiresIn, threshold) + expect(result).toBe(false) + }) +}) diff --git a/packages/composable-cli/src/util/has-expired.ts b/packages/composable-cli/src/util/has-expired.ts new file mode 100644 index 00000000..406d7a96 --- /dev/null +++ b/packages/composable-cli/src/util/has-expired.ts @@ -0,0 +1,15 @@ +/** + * + * @param timestamp in unix time + * @param expiresIn in seconds + * @param threshold in seconds + */ +export function hasExpiredWithThreshold( + timestamp: number, + expiresIn: number, + threshold: number +): boolean { + const currentTimestamp = Math.floor(Date.now() / 1000) // Convert current time to Unix timestamp + const expirationTimestamp = timestamp + expiresIn + return expirationTimestamp - threshold <= currentTimestamp +} diff --git a/packages/composable-cli/src/util/has-opted-insights.ts b/packages/composable-cli/src/util/has-opted-insights.ts new file mode 100644 index 00000000..cfa45ba0 --- /dev/null +++ b/packages/composable-cli/src/util/has-opted-insights.ts @@ -0,0 +1,14 @@ +import Conf from "conf" + +export function isOptedInsights(store: Conf): boolean { + return store.has("insights") +} + +export function isOptIn(store: Conf): boolean { + const insights = store.get("insights") + return typeof insights === "boolean" && insights === true +} + +export function optInsights(store: Conf, optIn: boolean): void { + store.set("insights", optIn) +} diff --git a/packages/composable-cli/src/util/has-uuid.ts b/packages/composable-cli/src/util/has-uuid.ts new file mode 100644 index 00000000..c0fa38e9 --- /dev/null +++ b/packages/composable-cli/src/util/has-uuid.ts @@ -0,0 +1,9 @@ +import Conf from "conf" + +export function hasUUID(store: Conf): boolean { + return store.has("uuid") +} + +export function setUUID(store: Conf, uuid: string): void { + store.set("uuid", uuid) +} diff --git a/packages/composable-cli/src/util/is-tty.ts b/packages/composable-cli/src/util/is-tty.ts new file mode 100644 index 00000000..be00e8d6 --- /dev/null +++ b/packages/composable-cli/src/util/is-tty.ts @@ -0,0 +1,16 @@ +export function isTTY(): boolean { + const isTruthy = (value: undefined | string) => { + // Returns true if value is a string that is anything but 0 or false. + return ( + value !== undefined && value !== "0" && value.toUpperCase() !== "FALSE" + ) + } + + // If we force TTY, we always return true. + const force = process.env["NG_FORCE_TTY"] + if (force !== undefined) { + return isTruthy(force) + } + + return !!process.stdout.isTTY && !isTruthy(process.env["CI"]) +} diff --git a/packages/composable-cli/src/util/resolve-region.test.ts b/packages/composable-cli/src/util/resolve-region.test.ts new file mode 100644 index 00000000..8c73095d --- /dev/null +++ b/packages/composable-cli/src/util/resolve-region.test.ts @@ -0,0 +1,12 @@ +import { resolveHostFromRegion } from "./resolve-region" + +describe("resolve region utilities", () => { + test("resolveRegion should return https://euwest.api.elasticpath.com when provided eu-west", () => { + const result = resolveHostFromRegion("eu-west") + expect(result).toEqual("https://euwest.api.elasticpath.com") + }) + test("resolveRegion should return useast.api.elasticpath.com when provided us-east host", () => { + const result = resolveHostFromRegion("us-east") + expect(result).toEqual("https://useast.api.elasticpath.com") + }) +}) diff --git a/packages/composable-cli/src/util/resolve-region.ts b/packages/composable-cli/src/util/resolve-region.ts new file mode 100644 index 00000000..2f37c3af --- /dev/null +++ b/packages/composable-cli/src/util/resolve-region.ts @@ -0,0 +1,12 @@ +export function resolveHostFromRegion( + host: "eu-west" | "us-east" +): "https://euwest.api.elasticpath.com" | "https://useast.api.elasticpath.com" { + switch (host) { + case "eu-west": + return "https://euwest.api.elasticpath.com" + case "us-east": + return "https://useast.api.elasticpath.com" + default: + return "https://euwest.api.elasticpath.com" + } +} diff --git a/packages/composable-cli/src/util/track-command-handler.ts b/packages/composable-cli/src/util/track-command-handler.ts new file mode 100644 index 00000000..5fdc6206 --- /dev/null +++ b/packages/composable-cli/src/util/track-command-handler.ts @@ -0,0 +1,30 @@ +import { CommandContext, CommandHandlerFunction } from "../types/command" +import { ProcessOutput } from "@angular-devkit/core/node" + +export function trackCommandHandler< + TData, + TError, + TCommandArguments extends Record +>( + ctx: CommandContext, + handler: ( + ctx: CommandContext, + stdout?: ProcessOutput, + stderr?: ProcessOutput + ) => CommandHandlerFunction +): CommandHandlerFunction { + return (args) => { + if (ctx.posthog) { + // Making sure to filter out any properties that might contain sensitive information + const { _, $0, password, username, ...rest } = args + ctx.posthog.postHogCapture({ + event: `composable cli command ${args._.join(" ")}`, + properties: { + ...rest, + }, + }) + } + + return handler(ctx)(args) + } +} diff --git a/packages/composable-cli/src/util/uuidv7.ts b/packages/composable-cli/src/util/uuidv7.ts new file mode 100644 index 00000000..44fc50bd --- /dev/null +++ b/packages/composable-cli/src/util/uuidv7.ts @@ -0,0 +1,267 @@ +/** + * uuidv7: An experimental implementation of the proposed UUID Version 7 + * + * @license Apache-2.0 + * @copyright 2021-2023 LiosK + * @packageDocumentation + * + * from https://github.com/LiosK/uuidv7/blob/e501462ea3d23241de13192ceae726956f9b3b7d/src/index.ts + */ + +// polyfill for IE11 +if (!Math.trunc) { + Math.trunc = function (v) { + return v < 0 ? Math.ceil(v) : Math.floor(v) + } +} + +// polyfill for IE11 +if (!Number.isInteger) { + Number.isInteger = function (value) { + return ( + typeof value === "number" && + isFinite(value) && + Math.floor(value) === value + ) + } +} + +const DIGITS = "0123456789abcdef" + +/** Represents a UUID as a 16-byte byte array. */ +export class UUID { + /** @param bytes - The 16-byte byte array representation. */ + constructor(readonly bytes: Readonly) { + if (bytes.length !== 16) { + throw new TypeError("not 128-bit length") + } + } + + /** + * Builds a byte array from UUIDv7 field values. + * + * @param unixTsMs - A 48-bit `unix_ts_ms` field value. + * @param randA - A 12-bit `rand_a` field value. + * @param randBHi - The higher 30 bits of 62-bit `rand_b` field value. + * @param randBLo - The lower 32 bits of 62-bit `rand_b` field value. + */ + static fromFieldsV7( + unixTsMs: number, + randA: number, + randBHi: number, + randBLo: number + ): UUID { + if ( + !Number.isInteger(unixTsMs) || + !Number.isInteger(randA) || + !Number.isInteger(randBHi) || + !Number.isInteger(randBLo) || + unixTsMs < 0 || + randA < 0 || + randBHi < 0 || + randBLo < 0 || + unixTsMs > 0xffff_ffff_ffff || + randA > 0xfff || + randBHi > 0x3fff_ffff || + randBLo > 0xffff_ffff + ) { + throw new RangeError("invalid field value") + } + + const bytes = new Uint8Array(16) + bytes[0] = unixTsMs / 2 ** 40 + bytes[1] = unixTsMs / 2 ** 32 + bytes[2] = unixTsMs / 2 ** 24 + bytes[3] = unixTsMs / 2 ** 16 + bytes[4] = unixTsMs / 2 ** 8 + bytes[5] = unixTsMs + bytes[6] = 0x70 | (randA >>> 8) + bytes[7] = randA + bytes[8] = 0x80 | (randBHi >>> 24) + bytes[9] = randBHi >>> 16 + bytes[10] = randBHi >>> 8 + bytes[11] = randBHi + bytes[12] = randBLo >>> 24 + bytes[13] = randBLo >>> 16 + bytes[14] = randBLo >>> 8 + bytes[15] = randBLo + return new UUID(bytes) + } + + /** @returns The 8-4-4-4-12 canonical hexadecimal string representation. */ + toString(): string { + let text = "" + for (let i = 0; i < this.bytes.length; i++) { + text = + text + + DIGITS.charAt(this.bytes[i] >>> 4) + + DIGITS.charAt(this.bytes[i] & 0xf) + if (i === 3 || i === 5 || i === 7 || i === 9) { + text += "-" + } + } + + if (text.length !== 36) { + // We saw one customer whose bundling code was mangling the UUID generation + // rather than accept a bad UUID, we throw an error here. + throw new Error("Invalid UUIDv7 was generated") + } + return text + } + + /** Creates an object from `this`. */ + clone(): UUID { + return new UUID(this.bytes.slice(0)) + } + + /** Returns true if `this` is equivalent to `other`. */ + equals(other: UUID): boolean { + return this.compareTo(other) === 0 + } + + /** + * Returns a negative integer, zero, or positive integer if `this` is less + * than, equal to, or greater than `other`, respectively. + */ + compareTo(other: UUID): number { + for (let i = 0; i < 16; i++) { + const diff = this.bytes[i] - other.bytes[i] + if (diff !== 0) { + return Math.sign(diff) + } + } + return 0 + } +} + +/** Encapsulates the monotonic counter state. */ +class V7Generator { + private timestamp = 0 + private counter = 0 + private readonly random = new DefaultRandom() + + /** + * Generates a new UUIDv7 object from the current timestamp, or resets the + * generator upon significant timestamp rollback. + * + * This method returns monotonically increasing UUIDs unless the up-to-date + * timestamp is significantly (by ten seconds or more) smaller than the one + * embedded in the immediately preceding UUID. If such a significant clock + * rollback is detected, this method resets the generator and returns a new + * UUID based on the current timestamp. + */ + generate(): UUID { + const value = this.generateOrAbort() + if (value !== undefined) { + return value + } else { + // reset state and resume + this.timestamp = 0 + const valueAfterReset = this.generateOrAbort() + if (valueAfterReset === undefined) { + throw new Error("Could not generate UUID after timestamp reset") + } + return valueAfterReset + } + } + + /** + * Generates a new UUIDv7 object from the current timestamp, or returns + * `undefined` upon significant timestamp rollback. + * + * This method returns monotonically increasing UUIDs unless the up-to-date + * timestamp is significantly (by ten seconds or more) smaller than the one + * embedded in the immediately preceding UUID. If such a significant clock + * rollback is detected, this method aborts and returns `undefined`. + */ + generateOrAbort(): UUID | undefined { + const MAX_COUNTER = 0x3ff_ffff_ffff + const ROLLBACK_ALLOWANCE = 10_000 // 10 seconds + + const ts = Date.now() + if (ts > this.timestamp) { + this.timestamp = ts + this.resetCounter() + } else if (ts + ROLLBACK_ALLOWANCE > this.timestamp) { + // go on with previous timestamp if new one is not much smaller + this.counter++ + if (this.counter > MAX_COUNTER) { + // increment timestamp at counter overflow + this.timestamp++ + this.resetCounter() + } + } else { + // abort if clock went backwards to unbearable extent + return undefined + } + + return UUID.fromFieldsV7( + this.timestamp, + Math.trunc(this.counter / 2 ** 30), + this.counter & (2 ** 30 - 1), + this.random.nextUint32() + ) + } + + /** Initializes the counter at a 42-bit random integer. */ + private resetCounter(): void { + this.counter = + this.random.nextUint32() * 0x400 + (this.random.nextUint32() & 0x3ff) + } +} + +/** A global flag to force use of cryptographically strong RNG. */ +declare const UUIDV7_DENY_WEAK_RNG: boolean + +/** Stores `crypto.getRandomValues()` available in the environment. */ +let getRandomValues: (buffer: T) => T = ( + buffer +) => { + // fall back on Math.random() unless the flag is set to true + if (typeof UUIDV7_DENY_WEAK_RNG !== "undefined" && UUIDV7_DENY_WEAK_RNG) { + throw new Error("no cryptographically strong RNG available") + } + + for (let i = 0; i < buffer.length; i++) { + buffer[i] = + Math.trunc(Math.random() * 0x1_0000) * 0x1_0000 + + Math.trunc(Math.random() * 0x1_0000) + } + return buffer +} + +// detect Web Crypto API +if (typeof crypto !== "undefined" && crypto.getRandomValues) { + getRandomValues = (buffer) => crypto.getRandomValues(buffer) +} + +/** + * Wraps `crypto.getRandomValues()` and compatibles to enable buffering; this + * uses a small buffer by default to avoid unbearable throughput decline in some + * environments as well as the waste of time and space for unused values. + */ +class DefaultRandom { + private readonly buffer = new Uint32Array(8) + private cursor = Infinity + nextUint32(): number { + if (this.cursor >= this.buffer.length) { + getRandomValues(this.buffer) + this.cursor = 0 + } + return this.buffer[this.cursor++] + } +} + +let defaultGenerator: V7Generator | undefined + +/** + * Generates a UUIDv7 string. + * + * @returns The 8-4-4-4-12 canonical hexadecimal string representation + * ("xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"). + */ +export const uuidv7 = (): string => uuidv7obj().toString() + +/** Generates a UUIDv7 object. */ +const uuidv7obj = (): UUID => + (defaultGenerator || (defaultGenerator = new V7Generator())).generate() diff --git a/packages/composable-cli/tsconfig.json b/packages/composable-cli/tsconfig.json index ca288bc9..9b3b6f08 100644 --- a/packages/composable-cli/tsconfig.json +++ b/packages/composable-cli/tsconfig.json @@ -10,16 +10,19 @@ "noImplicitThis": true, "noUnusedParameters": true, "noUnusedLocals": true, - "rootDir": "src/", + "rootDir": ".", "skipDefaultLibCheck": true, "skipLibCheck": true, "sourceMap": true, "strictNullChecks": true, + "strict": true, "target": "es6", "types": ["node", "jest"], - "esModuleInterop": true + "esModuleInterop": true, + "resolveJsonModule": true, + "jsx": "react" }, - "include": ["src/**/*"], + "include": ["src/**/*", "./package.json"], "exclude": [ "src/*/files/**/*", "src/*/other-files/**/*", diff --git a/packages/composable-cli/tsup.config.ts b/packages/composable-cli/tsup.config.ts index 85de1dd7..246c24bf 100644 --- a/packages/composable-cli/tsup.config.ts +++ b/packages/composable-cli/tsup.config.ts @@ -1,5 +1,6 @@ import { defineConfig } from "tsup" import { copy } from "esbuild-plugin-copy" +import "dotenv/config" export default defineConfig(({ env }) => { return { @@ -17,6 +18,12 @@ export default defineConfig(({ env }) => { "bin/composable": "src/composable.ts", }, }, + esbuildOptions(options) { + options.define = { + ...options.define, + "process.env.POSTHOG_PUBLIC_API_KEY": `"${process.env.POSTHOG_PUBLIC_API_KEY}"`, + } + }, clean: false, sourcemap: false, esbuildPlugins: [ diff --git a/packages/d2c-schematics/__tests__/application.test.ts b/packages/d2c-schematics/__tests__/application.test.ts index 4a415238..bbf34a97 100644 --- a/packages/d2c-schematics/__tests__/application.test.ts +++ b/packages/d2c-schematics/__tests__/application.test.ts @@ -127,6 +127,7 @@ describe("Application Schematic", () => { "/src/lib/types/read-only-non-empty-array.ts", "/src/lib/sort-alphabetically.ts", "/src/lib/get-main-layout.tsx", + "/src/lib/epcc-errors.ts", ]) }) }) diff --git a/packages/d2c-schematics/__tests__/featured-product.test.ts b/packages/d2c-schematics/__tests__/featured-product.test.ts index b78695d4..8de173d0 100644 --- a/packages/d2c-schematics/__tests__/featured-product.test.ts +++ b/packages/d2c-schematics/__tests__/featured-product.test.ts @@ -6,8 +6,6 @@ import { import { Schema as WorkspaceOptions } from "../workspace/schema" import { Schema as ApplicationOptions } from "../application/schema" import { Schema as FeaturedProductOptions } from "../featured-products/schema" -import { parseEnv } from "../utility/add-env-variable" -import { FEATURED_PRODUCTS_ENV_KEY } from "../featured-products" describe("Featured Products Schematic", () => { const schematicRunner = new SchematicTestRunner( @@ -26,9 +24,7 @@ describe("Featured Products Schematic", () => { name: "foo", } - const defaultOptions: FeaturedProductOptions = { - "featured-node-id": "123", - } + const defaultOptions: FeaturedProductOptions = {} let initTree: UnitTestTree beforeEach(async () => { @@ -55,16 +51,4 @@ describe("Featured Products Schematic", () => { "/src/components/featured-products/fetchFeaturedProducts.ts", ]) }) - - it("featured products schematic should update .env.local", async () => { - const options = { ...defaultOptions } - const tree = await schematicRunner - .runSchematicAsync("featured-products", options, initTree) - .toPromise() - - const rawText = tree.readText("/.env.local") - const parsed = parseEnv(rawText) - - expect(parsed[FEATURED_PRODUCTS_ENV_KEY]).toEqual("123") - }) }) diff --git a/packages/d2c-schematics/__tests__/home.test.ts b/packages/d2c-schematics/__tests__/home.test.ts index b5e75c5b..155581bc 100644 --- a/packages/d2c-schematics/__tests__/home.test.ts +++ b/packages/d2c-schematics/__tests__/home.test.ts @@ -76,7 +76,6 @@ describe("Home Schematic", () => { const tree = await schematicRunner .runSchematicAsync("home", { ...defaultOptions, - components: ["FeaturedProducts"], }) .toPromise() diff --git a/packages/d2c-schematics/__tests__/promotion-banner.test.ts b/packages/d2c-schematics/__tests__/promotion-banner.test.ts index 68330997..b208680f 100644 --- a/packages/d2c-schematics/__tests__/promotion-banner.test.ts +++ b/packages/d2c-schematics/__tests__/promotion-banner.test.ts @@ -6,8 +6,6 @@ import { import { Schema as WorkspaceOptions } from "../workspace/schema" import { Schema as ApplicationOptions } from "../application/schema" import { Schema as PromotionBannerOptions } from "../promotion-banner/schema" -import { parseEnv } from "../utility/add-env-variable" -import { PROMO_ENV_KEY } from "../promotion-banner" describe("Promotion Banner Schematic", () => { const schematicRunner = new SchematicTestRunner( @@ -26,9 +24,7 @@ describe("Promotion Banner Schematic", () => { name: "foo", } - const defaultOptions: PromotionBannerOptions = { - "promotion-id": "123", - } + const defaultOptions: PromotionBannerOptions = {} let initTree: UnitTestTree beforeEach(async () => { @@ -56,16 +52,4 @@ describe("Promotion Banner Schematic", () => { "/src/services/promotion.ts", ]) }) - - it("promotion banner schematic should update .env.local", async () => { - const options = { ...defaultOptions } - const tree = await schematicRunner - .runSchematicAsync("promotion-banner", options, initTree) - .toPromise() - - const rawText = tree.readText("/.env.local") - const parsed = parseEnv(rawText) - - expect(parsed[PROMO_ENV_KEY]).toEqual("123") - }) }) diff --git a/packages/d2c-schematics/__tests__/workspace.test.ts b/packages/d2c-schematics/__tests__/workspace.test.ts index 858e55b3..1d83e995 100644 --- a/packages/d2c-schematics/__tests__/workspace.test.ts +++ b/packages/d2c-schematics/__tests__/workspace.test.ts @@ -37,8 +37,6 @@ describe("Workspace Schematic", () => { "/.prettierignore", "/.eslintrc.json", "/license.md", - "/.husky/.gitignore", - "/.husky/pre-commit", "/playwright.config.ts", "/.env.test", "/vite.config.ts", diff --git a/packages/d2c-schematics/application/files/src/components/shared/blurb.tsx.template b/packages/d2c-schematics/application/files/src/components/shared/blurb.tsx.template new file mode 100644 index 00000000..77fb4dfb --- /dev/null +++ b/packages/d2c-schematics/application/files/src/components/shared/blurb.tsx.template @@ -0,0 +1,82 @@ +import { chakra, Heading, Text } from "@chakra-ui/react"; +import { globalBaseWidth } from "../../styles/theme"; +import { ReactNode } from "react"; + +const Para = ({ children }: { children: ReactNode }) => { + return ( + + {children} + + ); +}; + +interface IBlurbProps { + title: string; +} + +const Blurb = ({ title }: IBlurbProps) => ( + + {title} + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec arcu + lectus, pharetra nec velit in, vehicula suscipit tellus. Quisque id mollis + magna. Cras nec lacinia ligula. Morbi aliquam tristique purus, nec dictum + metus euismod at. Vestibulum mollis metus lobortis lectus sodales + eleifend. Class aptent taciti sociosqu ad litora torquent per conubia + nostra, per inceptos himenaeos. Vivamus eget elementum eros, et ultricies + mi. Donec eget dolor imperdiet, gravida ante a, molestie tortor. Nullam + viverra, orci gravida sollicitudin auctor, urna magna condimentum risus, + vitae venenatis turpis mauris sed ligula. Fusce mattis, mauris ut eleifend + ullamcorper, dui felis tincidunt libero, ut commodo arcu leo a ligula. + Cras congue maximus magna, et porta nisl pulvinar in. Nam congue orci + ornare scelerisque elementum. Quisque purus justo, molestie ut leo at, + tristique pretium dui. + + + + Vestibulum imperdiet commodo egestas. Proin tincidunt leo non purus + euismod dictum. Vivamus sagittis mauris dolor, quis egestas purus placerat + eget. Mauris finibus scelerisque augue ut ultrices. Praesent vitae nulla + lorem. Ut eget accumsan risus, sed fringilla orci. Nunc volutpat, odio vel + ornare ullamcorper, massa mauris dapibus nunc, sed euismod lectus erat + eget ligula. Duis fringilla elit vel eleifend luctus. Quisque non blandit + magna. Vivamus pharetra, dolor sed molestie ultricies, tellus ex egestas + lacus, in posuere risus diam non massa. Phasellus in justo in urna + faucibus cursus. + + + + Nullam nibh nisi, lobortis at rhoncus ut, viverra at turpis. Mauris ac + sollicitudin diam. Phasellus non orci massa. Donec tincidunt odio justo. + Sed gravida leo turpis, vitae blandit sem pharetra sit amet. Vestibulum + ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia + curae; Orci varius natoque penatibus et magnis dis parturient montes, + nascetur ridiculus mus. + + + + In in pulvinar turpis, vel pulvinar ipsum. Praesent vel commodo nisi, id + maximus ex. Integer lorem augue, hendrerit et enim vel, eleifend blandit + felis. Integer egestas risus purus, ac rhoncus orci faucibus ac. + Pellentesque iaculis ligula a mauris aliquam, at ullamcorper est + vestibulum. Proin maximus sagittis purus ac pretium. Ut accumsan vitae + nisl sed viverra. + + + + Vivamus malesuada elit facilisis, fringilla lacus non, vulputate felis. + Curabitur dignissim quis ipsum eget pellentesque. Duis efficitur nec nisl + sit amet porta. Maecenas ac dui a felis finibus elementum feugiat at nibh. + Donec convallis sodales neque. Integer id libero eget diam finibus + tincidunt id id diam. Fusce ut lectus nisi. Donec orci enim, semper ac + feugiat vitae, dignissim non enim. Vestibulum commodo dolor nec sem + viverra gravida. Ut laoreet eu tortor auctor consequat. Nulla quis mauris + mollis, aliquam mi nec, laoreet ligula. Fusce laoreet lorem et malesuada + suscipit. Nullam convallis, risus a posuere ultrices, velit augue + porttitor ante, vitae lobortis ligula velit id justo. Praesent nec lorem + massa. + + +); + +export default Blurb; \ No newline at end of file diff --git a/packages/d2c-schematics/application/files/src/lib/epcc-errors.ts.template b/packages/d2c-schematics/application/files/src/lib/epcc-errors.ts.template new file mode 100644 index 00000000..2b630190 --- /dev/null +++ b/packages/d2c-schematics/application/files/src/lib/epcc-errors.ts.template @@ -0,0 +1,16 @@ +export function isNoDefaultCatalogError(errors: object[]): errors is [{ detail: string }] { + const error = errors[0] + return hasDetail(error) && error.detail === 'unable to resolve default catalog: no default catalog id can be identified: not found' +} + +function hasDetail(err: object): err is { detail: string } { + return 'detail' in err +} + +export function isEPError(err: unknown): err is { errors: object[] } { + return typeof err === 'object' && !!err && hasErrors(err) && Array.isArray(err.errors) +} + +function hasErrors(err: object): err is {errors: object[]} { + return 'errors' in err +} \ No newline at end of file diff --git a/packages/d2c-schematics/application/files/src/lib/store-wrapper-ssg.ts.template b/packages/d2c-schematics/application/files/src/lib/store-wrapper-ssg.ts.template index 65653c93..afc17834 100644 --- a/packages/d2c-schematics/application/files/src/lib/store-wrapper-ssg.ts.template +++ b/packages/d2c-schematics/application/files/src/lib/store-wrapper-ssg.ts.template @@ -2,6 +2,7 @@ import { GetStaticPropsContext, GetStaticPropsResult } from "next"; import type { ParsedUrlQuery } from "querystring"; import { buildSiteNavigation, NavigationNode } from "./build-site-navigation"; import { StoreContextSSG } from "@elasticpath/react-shopper-hooks"; +import {isEPError, isNoDefaultCatalogError} from "./epcc-errors"; type IncomingPageStaticProp = ( ctx: GetStaticPropsContext, @@ -52,13 +53,20 @@ export function withStoreStaticProps< notFound: true, }; } catch (err) { - console.error( - `${ - err instanceof Error - ? `${err.name} - ${err.message}` - : "Unknown error occurred while trying to resolve store wrapper." - }` - ); + + if (isEPError(err) && isNoDefaultCatalogError(err.errors)) { + console.error("\x1b[31m%s\x1b[0m", "Error: Catalog Not Published"); + console.error("Please publish a catalog for this store.") + console.error("See https://elasticpath.dev/docs/pxm/products/get-started-pxm#create-and-publish-a-catalog") + } else { + console.error( + `${ + err instanceof Error + ? `${err.name} - ${err.message}` + : "Unknown error occurred while trying to resolve store wrapper." + }` + ); + } return { notFound: true, }; diff --git a/packages/d2c-schematics/application/files/src/pages/about.tsx.template b/packages/d2c-schematics/application/files/src/pages/about.tsx.template new file mode 100644 index 00000000..e6773bba --- /dev/null +++ b/packages/d2c-schematics/application/files/src/pages/about.tsx.template @@ -0,0 +1,8 @@ +import { withStoreStaticProps } from "../lib/store-wrapper-ssg"; +import Blurb from "../components/shared/blurb"; + +const About = () => ; + +export default About; + +export const getServerSideProps = withStoreStaticProps(); \ No newline at end of file diff --git a/packages/d2c-schematics/application/files/src/pages/faq.tsx.template b/packages/d2c-schematics/application/files/src/pages/faq.tsx.template new file mode 100644 index 00000000..60685ba2 --- /dev/null +++ b/packages/d2c-schematics/application/files/src/pages/faq.tsx.template @@ -0,0 +1,8 @@ +import { withStoreStaticProps } from "../lib/store-wrapper-ssg"; +import Blurb from "../components/shared/blurb"; + +const FAQ = () => ; + +export default FAQ; + +export const getServerSideProps = withStoreStaticProps(); \ No newline at end of file diff --git a/packages/d2c-schematics/application/files/src/pages/shipping.tsx.template b/packages/d2c-schematics/application/files/src/pages/shipping.tsx.template new file mode 100644 index 00000000..98c4aded --- /dev/null +++ b/packages/d2c-schematics/application/files/src/pages/shipping.tsx.template @@ -0,0 +1,8 @@ +import { withStoreStaticProps } from "../lib/store-wrapper-ssg"; +import Blurb from "../components/shared/blurb"; + +const Shipping = () => ; + +export default Shipping; + +export const getServerSideProps = withStoreStaticProps(); \ No newline at end of file diff --git a/packages/d2c-schematics/application/files/src/pages/terms.tsx.template b/packages/d2c-schematics/application/files/src/pages/terms.tsx.template new file mode 100644 index 00000000..4738bc55 --- /dev/null +++ b/packages/d2c-schematics/application/files/src/pages/terms.tsx.template @@ -0,0 +1,8 @@ +import { withStoreStaticProps } from "../lib/store-wrapper-ssg"; +import Blurb from "../components/shared/blurb"; + +const Terms = () => ; + +export default Terms; + +export const getServerSideProps = withStoreStaticProps(); \ No newline at end of file diff --git a/packages/d2c-schematics/d2c/index.ts b/packages/d2c-schematics/d2c/index.ts index 28bd6ce2..22d95a1c 100644 --- a/packages/d2c-schematics/d2c/index.ts +++ b/packages/d2c-schematics/d2c/index.ts @@ -36,6 +36,7 @@ export default function (options: D2COptions): Rule { plpType, skipTests, name, + packageManager, } = options const workspaceOptions: WorkspaceOptions = { @@ -117,7 +118,7 @@ export default function (options: D2COptions): Rule { context.addTask( new NodePackageInstallTask({ workingDirectory: options.directory, - packageManager: "yarn", + packageManager: packageManager ?? "npm", }), packageTask ? [packageTask] : [] ) diff --git a/packages/d2c-schematics/d2c/schema.json b/packages/d2c-schematics/d2c/schema.json index 2511fa34..b6c4b4a8 100644 --- a/packages/d2c-schematics/d2c/schema.json +++ b/packages/d2c-schematics/d2c/schema.json @@ -19,6 +19,12 @@ }, "x-prompt": "What name would you like to use for the new workspace and initial project?" }, + "packageManager": { + "description": "Node package manager to use.", + "type": "string", + "enum": ["npm", "yarn", "pnpm"], + "default": "npm" + }, "epccClientId": { "description": "The client id value for an Elastic Path Commerce Cloud store.", "type": "string", diff --git a/packages/d2c-schematics/ep-payments-payment-gateway/schema.json b/packages/d2c-schematics/ep-payments-payment-gateway/schema.json index 4ad3a348..49eb4a23 100644 --- a/packages/d2c-schematics/ep-payments-payment-gateway/schema.json +++ b/packages/d2c-schematics/ep-payments-payment-gateway/schema.json @@ -48,13 +48,11 @@ "epPaymentsStripeAccountId": { "description": "The stripe account id that goes with your EP payment setup.", "type": "string", - "visible": "false", "x-prompt": "What is the account id for EP Payments?" }, "epPaymentsStripePublishableKey": { "description": "The stripe publishable key that goes with your EP payment setup.", "type": "string", - "visible": "false", "x-prompt": "What is the publishable key for EP Payments?" } }, diff --git a/packages/d2c-schematics/featured-products/files/src/components/featured-products/FeaturedProducts.tsx.template b/packages/d2c-schematics/featured-products/files/src/components/featured-products/FeaturedProducts.tsx.template index 1a94337a..24cb9726 100644 --- a/packages/d2c-schematics/featured-products/files/src/components/featured-products/FeaturedProducts.tsx.template +++ b/packages/d2c-schematics/featured-products/files/src/components/featured-products/FeaturedProducts.tsx.template @@ -1,12 +1,12 @@ import { Box, Center, Flex, Heading, Link, Text } from "@chakra-ui/react"; import React, { useCallback, useEffect, useState } from "react"; import { useRouter } from "next/router"; -import { getProductsByNode } from "../../services/hierarchy"; import type { ProductResponseWithImage } from "../../lib/types/product-types"; import { connectProductsWithMainImages } from "../../lib/product-util"; import { ArrowForwardIcon, ViewOffIcon } from "@chakra-ui/icons"; import { globalBaseWidth } from "../../styles/theme"; import { ChakraNextImage } from "../ChakraNextImage"; +import {getProducts} from "../../services/products"; interface IFeaturedProductsBaseProps { title: string; @@ -23,7 +23,6 @@ interface IFeaturedProductsProvidedProps extends IFeaturedProductsBaseProps { interface IFeaturedProductsFetchProps extends IFeaturedProductsBaseProps { type: "fetch"; - nodeId: string; } type IFeaturedProductsProps = @@ -40,7 +39,7 @@ const FeaturedProducts = (props: IFeaturedProductsProps): JSX.Element => { const fetchNodeProducts = useCallback(async () => { if (type === "fetch") { - const { data, included } = await getProductsByNode(props.nodeId); + const { data, included } = await getProducts(); let products = data.slice(0, 4); if (included?.main_images) { products = connectProductsWithMainImages( @@ -104,13 +103,13 @@ const FeaturedProducts = (props: IFeaturedProductsProps): JSX.Element => { p={4} flex={{ base: "100%", md: "50%", lg: "25%" }} key={product.id} - href="/category" + href={`/products/${product.id}`} > - + {product.main_image?.link.href ? ( { - const { data: nodeProductsResponse, included: nodeProductsIncluded } = - await getProductsByNode(NODE_ID); +export const fetchFeaturedProducts = async () => { + const { data: productsResponse, included: productsIncluded } = + await getProducts(); - return nodeProductsIncluded?.main_images + return productsIncluded?.main_images ? connectProductsWithMainImages( - nodeProductsResponse.slice(0, 4), // Only need the first 4 products to feature - nodeProductsIncluded?.main_images + productsResponse.slice(0, 4), // Only need the first 4 products to feature + productsIncluded?.main_images ) - : nodeProductsResponse; + : productsResponse; }; diff --git a/packages/d2c-schematics/featured-products/index.ts b/packages/d2c-schematics/featured-products/index.ts index d57a80e7..e36152a2 100644 --- a/packages/d2c-schematics/featured-products/index.ts +++ b/packages/d2c-schematics/featured-products/index.ts @@ -10,17 +10,10 @@ import { MergeStrategy, } from "@angular-devkit/schematics" import { Schema as FeaturedProductsOptions } from "./schema" -import { addEnvVariables } from "../utility/add-env-variable" - -export type EnvData = Record - -export const FEATURED_PRODUCTS_ENV_KEY = "NEXT_PUBLIC_DEMO_NODE_ID" export default function (options: FeaturedProductsOptions): Rule { return () => { - const { "featured-node-id": nodeId } = options return chain([ - addEnvVariables({ [FEATURED_PRODUCTS_ENV_KEY]: nodeId }), mergeWith( apply(url("./files"), [ applyTemplates({ diff --git a/packages/d2c-schematics/featured-products/schema.json b/packages/d2c-schematics/featured-products/schema.json index 326d8e5f..b8b0407e 100644 --- a/packages/d2c-schematics/featured-products/schema.json +++ b/packages/d2c-schematics/featured-products/schema.json @@ -13,12 +13,6 @@ }, "description": "The path at which to create the component file, relative to the current workspace. Default is a folder with the same name as the component in the project root.", "visible": false - }, - "featured-node-id": { - "description": "The id of the EPCC node entity you want to use to power the featured product component", - "type": "string", - "x-prompt": "What is the id of the node you want to use for featured products?" } - }, - "required": ["featured-node-id"] + } } diff --git a/packages/d2c-schematics/home/files/src/pages/index.tsx.template b/packages/d2c-schematics/home/files/src/pages/index.tsx.template index b9e49408..36647193 100644 --- a/packages/d2c-schematics/home/files/src/pages/index.tsx.template +++ b/packages/d2c-schematics/home/files/src/pages/index.tsx.template @@ -1,57 +1,35 @@ import type { NextPage } from "next"; import { chakra, Grid, GridItem } from "@chakra-ui/react"; -import type { Node, Promotion } from "@moltin/sdk"; -import { ProductResponseWithImage } from "../lib/types/product-types"; - -<% if (components.includes("PromotionBanner")) { %> -import PromotionBanner from "../components/promotion-banner/PromotionBanner"; -import { fetchFeaturedPromotion } from "../components/promotion-banner/fetchFeaturedPromotion"; -<% } %> - -<% if (components.includes("FeaturedProducts")) { %> +import type { Node } from "@moltin/sdk"; import FeaturedProducts from "../components/featured-products/FeaturedProducts"; import { fetchFeaturedProducts } from "../components/featured-products/fetchFeaturedProducts"; -<% } %> +import { ProductResponseWithImage } from "../lib/types/product-types"; +import PromotionBanner, { + IPromotion, +} from "../components/promotion-banner/PromotionBanner"; import { withStoreStaticProps } from "../lib/store-wrapper-ssg"; -const nodeId = process.env.NEXT_PUBLIC_DEMO_NODE_ID || ""; -const promotionId = process.env.NEXT_PUBLIC_DEMO_PROMO_ID || ""; - export interface IHome { - <% if (components.includes("PromotionBanner")) { %> - promotion?: Promotion; - <% } %> - <% if (components.includes("FeaturedProducts")) { %> featuredProducts?: ProductResponseWithImage[]; - <% } %> featuredNodes?: Node[]; + promotion?: IPromotion; } -const Home: NextPage = ({ - <% if (components.includes("PromotionBanner")) { %> - promotion, - <% } %> - <% if (components.includes("FeaturedProducts")) { %> - featuredProducts, - <% } %> -}) => { +const Home: NextPage = ({ featuredProducts, promotion }) => { return ( - <% if (components.includes("PromotionBanner")) { %> {promotion && ( )} - <% } %> - <% if (components.includes("FeaturedProducts")) { %> {featuredProducts && ( = ({ /> )} - <% } %> ); @@ -73,24 +50,16 @@ const Home: NextPage = ({ export const getStaticProps = withStoreStaticProps(async () => { // Fetching static data for the home page - <% if (components.includes("PromotionBanner")) { %> - const promotion = promotionId - ? await fetchFeaturedPromotion(promotionId) - : undefined; - <% } %> - <% if (components.includes("FeaturedProducts")) { %> - const featuredProducts = nodeId - ? await fetchFeaturedProducts(nodeId) - : undefined; - <% } %> + const featuredProducts = await fetchFeaturedProducts(); + return { props: { - <% if (components.includes("PromotionBanner")) { %> - ...(promotion && { promotion }), - <% } %> - <% if (components.includes("FeaturedProducts")) { %> + promotion: { + title: "Your Elastic Path storefront", + description: + "This marks the beginning, embark on the journey of crafting something truly extraordinary, uniquely yours.", + }, ...(featuredProducts && { featuredProducts }), - <% } %> }, }; }); diff --git a/packages/d2c-schematics/home/index.ts b/packages/d2c-schematics/home/index.ts index 714e6732..33cdeb5a 100644 --- a/packages/d2c-schematics/home/index.ts +++ b/packages/d2c-schematics/home/index.ts @@ -13,20 +13,9 @@ import { import { Schema as HomeOptions } from "./schema" export default function (options: HomeOptions): Rule { - const { components = [] } = options - - const componentSchematicNames = components.map((x) => { - return strings.dasherize(x.toString()) - }) - - const componentCreators = componentSchematicNames.map((name) => - // TODO need to work out how I'm going to pass the options for each scheamtic for the test because we can't prompt - // emulate it in the test? - schematic(name, {}) - ) - return chain([ - ...componentCreators, + schematic("featured-products", {}), + schematic("promotion-banner", {}), mergeWith( apply(url("./files"), [ applyTemplates({ diff --git a/packages/d2c-schematics/home/schema.json b/packages/d2c-schematics/home/schema.json index 9a31dae7..babd0c23 100644 --- a/packages/d2c-schematics/home/schema.json +++ b/packages/d2c-schematics/home/schema.json @@ -13,17 +13,6 @@ }, "description": "The path at which to create the component file, relative to the current workspace. Default is a folder with the same name as the component in the project root.", "visible": false - }, - "components": { - "description": "The home page components to include.", - "type": "array", - "uniqueItems": true, - "items": { - "enum": ["PromotionBanner", "FeaturedProducts"], - "type": "string" - }, - "default": [], - "x-prompt": "What home page components would you like to include?" } } } diff --git a/packages/d2c-schematics/product-details-page/files/src/services/products.ts.template b/packages/d2c-schematics/product-details-page/files/src/services/products.ts.template index d3e67316..1d82f185 100644 --- a/packages/d2c-schematics/product-details-page/files/src/services/products.ts.template +++ b/packages/d2c-schematics/product-details-page/files/src/services/products.ts.template @@ -26,6 +26,12 @@ export function getAllProducts( return _getAllProductPages(client)(); } +export function getProducts(client?: EPCCClient, offset = 0, limit = 100) { + return (client ?? getEpccImplicitClient()).ShopperCatalog.Products.With(["main_image"]).Limit(limit) + .Offset(offset) + .All() +} + const _getAllPages = ( nextPageRequestFn: ( diff --git a/packages/d2c-schematics/product-list-page-algolia/schema.json b/packages/d2c-schematics/product-list-page-algolia/schema.json index a8db2005..c5295001 100644 --- a/packages/d2c-schematics/product-list-page-algolia/schema.json +++ b/packages/d2c-schematics/product-list-page-algolia/schema.json @@ -62,6 +62,11 @@ "type": "string", "visible": "false", "x-prompt": "What is the endpoint url for the EPCC store?" + }, + "algoliaIndexName": { + "description": "The name of the Algolia index to use.", + "type": "string", + "x-prompt": "What is the name of the Algolia index to use?" } }, "required": ["epccClientId", "epccClientSecret", "epccEndpointUrl", "directory"] diff --git a/packages/d2c-schematics/promotion-banner/files/src/components/promotion-banner/PromotionBanner.tsx.template b/packages/d2c-schematics/promotion-banner/files/src/components/promotion-banner/PromotionBanner.tsx.template index 535a84b3..b00ad4f1 100644 --- a/packages/d2c-schematics/promotion-banner/files/src/components/promotion-banner/PromotionBanner.tsx.template +++ b/packages/d2c-schematics/promotion-banner/files/src/components/promotion-banner/PromotionBanner.tsx.template @@ -1,9 +1,10 @@ -import { Box, Button, Heading, Text } from "@chakra-ui/react"; -import { Promotion } from "@moltin/sdk"; +import { Box, Button, Heading } from "@chakra-ui/react"; import { useRouter } from "next/router"; -interface IPromotion extends Promotion { - "epcc-reference-promotion-image"?: string; +export interface IPromotion { + title?: string; + description?: string; + imageHref?: string; } interface IPromotionBanner { @@ -19,14 +20,13 @@ const PromotionBanner = (props: IPromotionBanner): JSX.Element => { const router = useRouter(); const { linkProps, promotion, alignment } = props; - const { name, description } = promotion; - const imageUrl = promotion["epcc-reference-promotion-image"]; + const { title, imageHref, description } = promotion; let background; - if (imageUrl) { + if (imageHref) { background = { - backgroundImage: `url(${imageUrl})`, + backgroundImage: `url(${imageHref})`, backgroundSize: "cover", backgroundPosition: "center", _before: { @@ -74,9 +74,9 @@ const PromotionBanner = (props: IPromotionBanner): JSX.Element => { {...contentAlignment} {...background} > - {name && ( + {title && ( - {name} + {title} )} {description && ( diff --git a/packages/d2c-schematics/promotion-banner/files/src/components/promotion-banner/fetchFeaturedPromotion.ts.template b/packages/d2c-schematics/promotion-banner/files/src/components/promotion-banner/fetchFeaturedPromotion.ts.template deleted file mode 100644 index 95a702b9..00000000 --- a/packages/d2c-schematics/promotion-banner/files/src/components/promotion-banner/fetchFeaturedPromotion.ts.template +++ /dev/null @@ -1,7 +0,0 @@ -// Fetching the data for a specific promotion for the home page promotion-banner -import { getPromotionById } from "../../services/promotions"; - -export const fetchFeaturedPromotion = async (PROMOTION_ID: string) => { - const { data } = await getPromotionById(PROMOTION_ID); - return data; -}; diff --git a/packages/d2c-schematics/promotion-banner/files/src/services/promotions.ts.template b/packages/d2c-schematics/promotion-banner/files/src/services/promotions.ts.template deleted file mode 100644 index 222bae08..00000000 --- a/packages/d2c-schematics/promotion-banner/files/src/services/promotions.ts.template +++ /dev/null @@ -1,13 +0,0 @@ -import type { Promotion, Resource } from "@moltin/sdk"; -import { epccServerClient } from "../lib/epcc-server-client"; - -/** - * Get a promotion by id - * - * This function will only work server side as Promotions requires a client_credentials token - */ -export async function getPromotionById( - promotionId: string -): Promise> { - return await epccServerClient.Promotions.Get(promotionId); -} diff --git a/packages/d2c-schematics/promotion-banner/index.ts b/packages/d2c-schematics/promotion-banner/index.ts index cb3d5897..9b67fdfc 100644 --- a/packages/d2c-schematics/promotion-banner/index.ts +++ b/packages/d2c-schematics/promotion-banner/index.ts @@ -1,26 +1,19 @@ import { - Rule, apply, + applyTemplates, chain, + MergeStrategy, mergeWith, - url, - applyTemplates, - strings, move, - MergeStrategy, + Rule, + strings, + url, } from "@angular-devkit/schematics" import { Schema as PromotionsBannerOptions } from "./schema" -import { addEnvVariables } from "../utility/add-env-variable" - -export type EnvData = Record - -export const PROMO_ENV_KEY = "NEXT_PUBLIC_DEMO_PROMO_ID" export default function (options: PromotionsBannerOptions): Rule { return () => { - const { "promotion-id": promotionId } = options return chain([ - addEnvVariables({ [PROMO_ENV_KEY]: promotionId }), mergeWith( apply(url("./files"), [ applyTemplates({ diff --git a/packages/d2c-schematics/promotion-banner/schema.json b/packages/d2c-schematics/promotion-banner/schema.json index 204a0ab2..e6173505 100644 --- a/packages/d2c-schematics/promotion-banner/schema.json +++ b/packages/d2c-schematics/promotion-banner/schema.json @@ -13,12 +13,6 @@ }, "description": "The path at which to create the component file, relative to the current workspace. Default is a folder with the same name as the component in the project root.", "visible": false - }, - "promotion-id": { - "description": "The id of the EPCC promotion entity you want to use to power the banner", - "type": "string", - "x-prompt": "What is the id of the promotion you want to use for the banner?" } - }, - "required": ["promotion-id"] + } } diff --git a/packages/d2c-schematics/utility/add-env-variable.ts b/packages/d2c-schematics/utility/add-env-variable.ts index 685a0c29..caacd6d1 100644 --- a/packages/d2c-schematics/utility/add-env-variable.ts +++ b/packages/d2c-schematics/utility/add-env-variable.ts @@ -1,8 +1,9 @@ import { Rule, Tree } from "@angular-devkit/schematics" -import { EnvData } from "../promotion-banner" const LOCAL_ENV_FILE_PATH = "/.env.local" +export type EnvData = Record + export function parseEnv(src: string): EnvData { const result: EnvData = {} const lines = src.toString().split("\n") diff --git a/packages/d2c-schematics/utility/index.ts b/packages/d2c-schematics/utility/index.ts index 5ead16bf..24a2e3cf 100644 --- a/packages/d2c-schematics/utility/index.ts +++ b/packages/d2c-schematics/utility/index.ts @@ -16,3 +16,4 @@ export { InstallBehavior, addDependency, } from "./dependency" +export { EnvData } from "./add-env-variable" diff --git a/packages/d2c-schematics/utility/latest-versions/package.json b/packages/d2c-schematics/utility/latest-versions/package.json index 94c45a78..eb78d851 100644 --- a/packages/d2c-schematics/utility/latest-versions/package.json +++ b/packages/d2c-schematics/utility/latest-versions/package.json @@ -36,7 +36,6 @@ "eslint-config-next": "12.2.5", "eslint-config-prettier": "^8.5.0", "eslint-plugin-react": "^7.30.1", - "husky": "^8.0.1", "vite": "^4.2.1", "vitest": "^0.30.1", "@vitest/coverage-istanbul": "^0.30.1", diff --git a/packages/d2c-schematics/workspace/files/__dot__husky/__dot__gitignore.template b/packages/d2c-schematics/workspace/files/__dot__husky/__dot__gitignore.template deleted file mode 100644 index 31354ec1..00000000 --- a/packages/d2c-schematics/workspace/files/__dot__husky/__dot__gitignore.template +++ /dev/null @@ -1 +0,0 @@ -_ diff --git a/packages/d2c-schematics/workspace/files/__dot__husky/pre-commit.template b/packages/d2c-schematics/workspace/files/__dot__husky/pre-commit.template deleted file mode 100644 index 36af2198..00000000 --- a/packages/d2c-schematics/workspace/files/__dot__husky/pre-commit.template +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/sh -. "$(dirname "$0")/_/husky.sh" - -npx lint-staged diff --git a/packages/d2c-schematics/workspace/files/next.config.js.template b/packages/d2c-schematics/workspace/files/next.config.js.template index 0bf2d9af..34bcd623 100644 --- a/packages/d2c-schematics/workspace/files/next.config.js.template +++ b/packages/d2c-schematics/workspace/files/next.config.js.template @@ -5,7 +5,7 @@ **/ const nextConfig = { images: { - domains: ["files-eu.epusercontent.com"], + domains: ["files-eu.epusercontent.com", "files-na.epusercontent.com"], }, i18n: { locales: ["en"], diff --git a/packages/d2c-schematics/workspace/files/package.json.template b/packages/d2c-schematics/workspace/files/package.json.template index f8774376..54525c29 100644 --- a/packages/d2c-schematics/workspace/files/package.json.template +++ b/packages/d2c-schematics/workspace/files/package.json.template @@ -10,8 +10,7 @@ "lint:fix": "next lint --fix", "format:check": "prettier --check .", "format:fix": "prettier --write .", - "type:check": "tsc --noEmit", - "prepare": "husky install"<% if (tests) { %>, + "type:check": "tsc --noEmit"<% if (tests) { %>, "test": "vitest run", "test:coverage": "vitest run --coverage", "test:watch": "vitest", @@ -49,8 +48,7 @@ "eslint": "<%= latestVersions['eslint'] %>", "eslint-config-next": "<%= latestVersions['eslint-config-next'] %>", "eslint-config-prettier": "<%= latestVersions['eslint-config-prettier'] %>", - "eslint-plugin-react": "<%= latestVersions['eslint-plugin-react'] %>", - "husky": "<%= latestVersions['husky'] %>",<% if (tests) { %> + "eslint-plugin-react": "<%= latestVersions['eslint-plugin-react'] %>",<% if (tests) { %> "vite": "<%= latestVersions['vite'] %>", "vitest": "<%= latestVersions['vitest'] %>", "@vitest/coverage-istanbul": "<%= latestVersions['@vitest/coverage-istanbul'] %>", diff --git a/scripts/generate-examples.ts b/scripts/generate-examples.ts index eda0e841..25daedf2 100755 --- a/scripts/generate-examples.ts +++ b/scripts/generate-examples.ts @@ -33,7 +33,7 @@ async function generateExamples({}: GenerateExampleOptions = {}): Promise< await runComposableCli( `${appRoot.path}/packages/composable-cli/dist/bin/composable.js`, - `${appRoot.path}/packages/d2c-schematics/dist:d2c`, + ["generate", "d2c"], simpleLogger ) @@ -44,13 +44,13 @@ async function generateExamples({}: GenerateExampleOptions = {}): Promise< async function runComposableCli( composableCliPath: string, - schematicPath: string, + command: string[], logger: Logger ): Promise { const specs = configuration.specs const promise = specs.map((spec) => { - return d2cGeneratorForSpec(composableCliPath, schematicPath, spec, logger) + return d2cGeneratorForSpec(composableCliPath, command, spec, logger) }) await Promise.all(promise) @@ -63,7 +63,7 @@ type Spec = (typeof configuration.specs)[number] async function d2cGeneratorForSpec( composableCliPath: string, - schematicPath: string, + command: string[], spec: Spec, logger: Logger ): Promise { @@ -72,12 +72,16 @@ async function d2cGeneratorForSpec( const process = await childProcess.fork( composableCliPath, - [schematicPath, ...args], + [...command, spec.name, ...args], { cwd: `${appRoot.path}/examples`, + env: { + // @ts-ignore + NODE_ENV: "CI", + }, } ) - // node ../packages/composable-cli/dist/bin/composable-frontend.js ../packages/d2c-schematics/dist:d2c --dry-run=false --skip-install=true --skip-git=true --skip-config=true --interactive=false --epcc-client-id=$EPCC_CLIENT_ID --epcc-client-secret=$EPCC_CLIENT_SECRET --epcc-endpoint-url=$EPCC_ENDPOINT --plp-type=None --payment-gateway-type="EP Payments" --ep-payments-stripe-account-id=abc123 --ep-payments-stripe-publishable-key=abc123 basic' + // listen for errors as they may prevent the exit event from firing process.on("error", function (err) { reject(err) @@ -86,8 +90,8 @@ async function d2cGeneratorForSpec( // execute the callback once the process has finished running process.on("exit", function (code) { if (code === 0) { - resolve() logger.log(`Generated ${spec.name} example.`) + resolve() } reject(new Error("exit code " + code)) }) diff --git a/yarn.lock b/yarn.lock index 5fbd982d..0c8cc577 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7,6 +7,14 @@ resolved "https://registry.yarnpkg.com/@adobe/css-tools/-/css-tools-4.2.0.tgz#e1a84fca468f4b337816fcb7f0964beb620ba855" integrity sha512-E09FiIft46CmH5Qnjb0wsW54/YQd69LsxeKUOWawmws1XWvyFGURnAChH0mlr7YPFR1ofwvUQfcL0J3lMxXqPA== +"@alcalzone/ansi-tokenize@^0.1.3": + version "0.1.3" + resolved "https://registry.yarnpkg.com/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.1.3.tgz#9f89839561325a8e9a0c32360b8d17e48489993f" + integrity sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw== + dependencies: + ansi-styles "^6.2.1" + is-fullwidth-code-point "^4.0.0" + "@algolia/cache-browser-local-storage@4.15.0": version "4.15.0" resolved "https://registry.npmjs.org/@algolia/cache-browser-local-storage/-/cache-browser-local-storage-4.15.0.tgz" @@ -3922,6 +3930,19 @@ node-localstorage "^2.1.6" throttled-queue "^2.1.4" +"@moltin/sdk@^23.2.0": + version "23.2.0" + resolved "https://registry.yarnpkg.com/@moltin/sdk/-/sdk-23.2.0.tgz#82c3acc2b95b39881f29b43d969c1b98e3bb4a2e" + integrity sha512-oP8Spes1rJuGskANgXep+mTj2tX7nBB0/szfjnU4bB3w0OdZeQuIRep8Uz++VR5IIiOn7THyabXposnDP+m8Vw== + dependencies: + cross-fetch "^3.1.5" + es6-promise "^4.0.5" + form-data "^4.0.0" + inflected "^2.0.1" + js-cookie "^3.0.1" + node-localstorage "^2.1.6" + throttled-queue "^2.1.4" + "@motionone/animation@^10.15.1": version "10.15.1" resolved "https://registry.yarnpkg.com/@motionone/animation/-/animation-10.15.1.tgz#4a85596c31cbc5100ae8eb8b34c459fb0ccf6807" @@ -5316,6 +5337,27 @@ dependencies: "@types/node" "*" +"@types/ink-big-text@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@types/ink-big-text/-/ink-big-text-1.2.1.tgz#c8f5976097e1e9990c287bebe12f5c49c05d5770" + integrity sha512-2J+vxIcVzYkQrWfgqDorcui0ueF8g7j2BgQ+iYSsRkXi8/B3mSRM9fNL4jN3UOPWC2upfDssrumG2bOkRs6aXA== + dependencies: + "@types/react" "*" + +"@types/ink-gradient@^2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@types/ink-gradient/-/ink-gradient-2.0.1.tgz#950d0c0624b6ff1c85b91980a51ead73ddeb1795" + integrity sha512-itcg0raINFVyexBQJVRkPopt/CLgBnDsqUi1JC096WquiD0NEBuKhtVvbvPiqJbtAffMAmtiXU24a5xSS0r1og== + dependencies: + "@types/react" "*" + +"@types/ink@^2.0.3": + version "2.0.3" + resolved "https://registry.yarnpkg.com/@types/ink/-/ink-2.0.3.tgz#5e0f37d103b41440d97aba39c00a6546e78ffbe9" + integrity sha512-DYKIKEJqhsGfQ/jgX0t9BzfHmBJ/9dBBT2MDsHAQRAfOPhEe7LZm5QeNBx1J34/e108StCPuJ3r4bh1y38kCJA== + dependencies: + ink "*" + "@types/inquirer@^8.2.1": version "8.2.6" resolved "https://registry.npmjs.org/@types/inquirer/-/inquirer-8.2.6.tgz" @@ -5587,6 +5629,11 @@ dependencies: "@types/node" "*" +"@types/tinycolor2@^1.4.0": + version "1.4.4" + resolved "https://registry.yarnpkg.com/@types/tinycolor2/-/tinycolor2-1.4.4.tgz#bca7469668247469087d6eba588c02e7709fcab5" + integrity sha512-FYK4mlLxUUajo/mblv7EUDHku20qT6ThYNsGZsTHilcHRvIkF8WXqtZO+DVTYkpHWCaAT97LueV59H/5Ve3bGA== + "@types/tough-cookie@*": version "4.0.2" resolved "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.2.tgz" @@ -5621,6 +5668,13 @@ dependencies: "@types/yargs-parser" "*" +"@types/yargs@^17.0.24": + version "17.0.24" + resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-17.0.24.tgz#b3ef8d50ad4aa6aecf6ddc97c580a00f5aa11902" + integrity sha512-6i0aC7jV6QzQB8ne1joVZ0eSFIstHsCrobmOtghM11yGlH0j43FKL2UhWdELkyps0zuf7qVTUVCCR+tgSlyLLw== + dependencies: + "@types/yargs-parser" "*" + "@types/yargs@^17.0.8": version "17.0.22" resolved "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.22.tgz" @@ -5635,6 +5689,11 @@ dependencies: "@types/node" "*" +"@types/yoga-layout@1.9.2": + version "1.9.2" + resolved "https://registry.yarnpkg.com/@types/yoga-layout/-/yoga-layout-1.9.2.tgz#efaf9e991a7390dc081a0b679185979a83a9639a" + integrity sha512-S9q47ByT2pPvD65IvrWp7qppVMpk9WGMbVq9wbWZOHg6tnXSD4vyhao6nOSBwwfDdV2p3Kx9evA9vI+XWTfDvw== + "@typescript-eslint/eslint-plugin@^5.58.0": version "5.58.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.58.0.tgz#b1d4b0ad20243269d020ef9bbb036a40b0849829" @@ -6082,7 +6141,7 @@ ajv@8.11.0: require-from-string "^2.0.2" uri-js "^4.2.2" -ajv@8.12.0, ajv@^8.0.0, ajv@^8.10.0, ajv@^8.11.0, ajv@^8.11.2, ajv@^8.12.0: +ajv@8.12.0, ajv@^8.0.0, ajv@^8.10.0, ajv@^8.11.0, ajv@^8.11.2, ajv@^8.12.0, ajv@^8.6.3: version "8.12.0" resolved "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz" integrity sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA== @@ -6194,6 +6253,13 @@ ansi-escapes@^5.0.0: dependencies: type-fest "^1.0.2" +ansi-escapes@^6.0.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-6.2.0.tgz#8a13ce75286f417f1963487d86ba9f90dccf9947" + integrity sha512-kzRaCqXnpzWs+3z5ABPQiVke+iq0KXkHo8xiWV4RPTi5Yli0l97BEQuhXV1s7+aSU/fu1kUuxgS4MsQ0fRuygw== + dependencies: + type-fest "^3.0.0" + ansi-regex@^2.0.0: version "2.1.1" resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz" @@ -6243,7 +6309,7 @@ ansi-styles@^5.0.0: resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz" integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== -ansi-styles@^6.0.0, ansi-styles@^6.1.0: +ansi-styles@^6.0.0, ansi-styles@^6.1.0, ansi-styles@^6.2.1: version "6.2.1" resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz" integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug== @@ -6536,11 +6602,21 @@ atomic-sleep@^1.0.0: resolved "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz" integrity sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ== -auto-bind@~4.0.0: +atomically@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/atomically/-/atomically-1.7.0.tgz#c07a0458432ea6dbc9a3506fffa424b48bccaafe" + integrity sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w== + +auto-bind@4.0.0, auto-bind@~4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/auto-bind/-/auto-bind-4.0.0.tgz" integrity sha512-Hdw8qdNiqdJ8LqT0iK0sVzkFbzg6fhnQqqfWhBDxcHZvU75+B+ayzTy8x+k5Ix0Y92XOhOUlx74ps+bA6BeYMQ== +auto-bind@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/auto-bind/-/auto-bind-5.0.1.tgz#50d8e63ea5a1dddcb5e5e36451c1a8266ffbb2ae" + integrity sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg== + available-typed-arrays@^1.0.5: version "1.0.5" resolved "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz" @@ -6560,6 +6636,14 @@ axe-core@^4.6.2: resolved "https://registry.npmjs.org/axe-core/-/axe-core-4.6.3.tgz" integrity sha512-/BQzOX780JhsxDnPpH4ZiyrJAzcd8AfzFPkv+89veFSr1rcMjuq2JDCwypKaPeB6ljHp9KjXhPpjgCvQlWYuqg== +axios@^0.27.0: + version "0.27.2" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.27.2.tgz#207658cc8621606e586c85db4b41a750e756d972" + integrity sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ== + dependencies: + follow-redirects "^1.14.9" + form-data "^4.0.0" + axobject-query@^3.1.1: version "3.1.1" resolved "https://registry.npmjs.org/axobject-query/-/axobject-query-3.1.1.tgz" @@ -7190,6 +7274,14 @@ capital-case@^1.0.4: tslib "^2.0.3" upper-case-first "^2.0.2" +cfonts@^2.8.6: + version "2.10.1" + resolved "https://registry.yarnpkg.com/cfonts/-/cfonts-2.10.1.tgz#3a0d1301418f439a287d7bd3fa0f233eb11356a8" + integrity sha512-l5IcLv4SaOdL/EGR6BpOF5SEro88VcGJJ6+xbvJb+wXi19YC6UeHE/brv7a4vIcLZopnt3Ys3zWeNnyfB04UPg== + dependencies: + chalk "^4" + window-size "^1.1.1" + chai@^4.3.7: version "4.3.7" resolved "https://registry.npmjs.org/chai/-/chai-4.3.7.tgz" @@ -7244,7 +7336,7 @@ chalk@^3.0.0: ansi-styles "^4.1.0" supports-color "^7.1.0" -chalk@^4.0.0, chalk@^4.1.0, chalk@^4.1.1, chalk@^4.1.2: +chalk@^4, chalk@^4.0.0, chalk@^4.1.0, chalk@^4.1.1, chalk@^4.1.2: version "4.1.2" resolved "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz" integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== @@ -7252,6 +7344,11 @@ chalk@^4.0.0, chalk@^4.1.0, chalk@^4.1.1, chalk@^4.1.2: ansi-styles "^4.1.0" supports-color "^7.1.0" +chalk@^5.2.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.3.0.tgz#67c20a7ebef70e7f3970a01f90fa210cb6860385" + integrity sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w== + change-case-all@1.0.15: version "1.0.15" resolved "https://registry.npmjs.org/change-case-all/-/change-case-all-1.0.15.tgz" @@ -7321,6 +7418,11 @@ chownr@^2.0.0: resolved "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz" integrity sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ== +ci-info@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46" + integrity sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ== + ci-info@^3.0.0, ci-info@^3.1.0, ci-info@^3.2.0: version "3.8.0" resolved "https://registry.npmjs.org/ci-info/-/ci-info-3.8.0.tgz" @@ -7362,6 +7464,11 @@ clean-stack@^4.0.0: dependencies: escape-string-regexp "5.0.0" +cli-boxes@^2.2.0: + version "2.2.1" + resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-2.2.1.tgz#ddd5035d25094fce220e9cab40a45840a440318f" + integrity sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw== + cli-boxes@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz" @@ -7506,6 +7613,20 @@ code-block-writer@^11.0.3: resolved "https://registry.npmjs.org/code-block-writer/-/code-block-writer-11.0.3.tgz" integrity sha512-NiujjUFB4SwScJq2bwbYUtXbZhBSlY6vYzm++3Q6oC+U+injTqfPYFK8wS9COOmb2lueqp0ZRB4nK1VYeHgNyw== +code-excerpt@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/code-excerpt/-/code-excerpt-3.0.0.tgz#fcfb6748c03dba8431c19f5474747fad3f250f10" + integrity sha512-VHNTVhd7KsLGOqfX3SyeO8RyYPMp1GJOg194VITk04WMYCv4plV68YWe6TJZxd9MhobjtpMRnVky01gqZsalaw== + dependencies: + convert-to-spaces "^1.0.1" + +code-excerpt@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/code-excerpt/-/code-excerpt-4.0.0.tgz#2de7d46e98514385cb01f7b3b741320115f4c95e" + integrity sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA== + dependencies: + convert-to-spaces "^2.0.1" + code-point-at@^1.0.0: version "1.1.0" resolved "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz" @@ -7744,6 +7865,22 @@ concurrently@^7.6.0: tree-kill "^1.2.2" yargs "^17.3.1" +conf@10.2.0: + version "10.2.0" + resolved "https://registry.yarnpkg.com/conf/-/conf-10.2.0.tgz#838e757be963f1a2386dfe048a98f8f69f7b55d6" + integrity sha512-8fLl9F04EJqjSqH+QjITQfJF8BrOVaYr1jewVgSRAEWePfxT0sku4w2hrGQ60BC/TNLGQ2pgxNlTbWQmMPFvXg== + dependencies: + ajv "^8.6.3" + ajv-formats "^2.1.1" + atomically "^1.7.0" + debounce-fn "^4.0.0" + dot-prop "^6.0.1" + env-paths "^2.2.1" + json-schema-typed "^7.0.3" + onetime "^5.1.2" + pkg-up "^3.1.0" + semver "^7.3.5" + config-chain@^1.1.11: version "1.1.13" resolved "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz" @@ -7811,6 +7948,16 @@ convert-source-map@^2.0.0: resolved "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz" integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== +convert-to-spaces@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/convert-to-spaces/-/convert-to-spaces-1.0.2.tgz#7e3e48bbe6d997b1417ddca2868204b4d3d85715" + integrity sha512-cj09EBuObp9gZNQCzc7hByQyrs6jVGE+o9kSJmeUoj+GiPiJvi5LYqEH/Hmme4+MTLHM+Ejtq+FChpjjEnsPdQ== + +convert-to-spaces@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz#61a6c98f8aa626c16b296b862a91412a33bceb6b" + integrity sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ== + cookie-signature@1.0.6: version "1.0.6" resolved "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz" @@ -8119,6 +8266,13 @@ date-time@^3.1.0: dependencies: time-zone "^1.0.0" +debounce-fn@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/debounce-fn/-/debounce-fn-4.0.0.tgz#ed76d206d8a50e60de0dd66d494d82835ffe61c7" + integrity sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ== + dependencies: + mimic-fn "^3.0.0" + debounce@^1.2.0: version "1.2.1" resolved "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz" @@ -8638,6 +8792,11 @@ dotenv@^16.0.0, dotenv@^16.0.2: resolved "https://registry.npmjs.org/dotenv/-/dotenv-16.0.3.tgz" integrity sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ== +dotenv@^16.3.1: + version "16.3.1" + resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.3.1.tgz#369034de7d7e5b120972693352a3bf112172cc3e" + integrity sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ== + download@^8.0.0: version "8.0.0" resolved "https://registry.npmjs.org/download/-/download-8.0.0.tgz" @@ -8770,7 +8929,7 @@ entities@^4.4.0: resolved "https://registry.yarnpkg.com/entities/-/entities-4.4.0.tgz#97bdaba170339446495e653cfd2db78962900174" integrity sha512-oYp7156SP8LkeGD0GF85ad1X9Ai79WtRsZ2gxJqtBuzH+98YUV6jkHEKlZkMbcrjJjIVJNIDP/3WL9wQkoPbWA== -env-paths@^2.2.0: +env-paths@^2.2.0, env-paths@^2.2.1: version "2.2.1" resolved "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz" integrity sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A== @@ -10225,6 +10384,11 @@ follow-redirects@^1.0.0: resolved "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz" integrity sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA== +follow-redirects@^1.14.9: + version "1.15.3" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.3.tgz#fe2f3ef2690afce7e82ed0b44db08165b207123a" + integrity sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q== + for-each@^0.3.3: version "0.3.3" resolved "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz" @@ -10787,6 +10951,14 @@ graceful-fs@4.2.10, graceful-fs@^4.1.10, graceful-fs@^4.1.11, graceful-fs@^4.1.2 resolved "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz" integrity sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA== +gradient-string@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/gradient-string/-/gradient-string-1.2.0.tgz#93f39f2c7c8dcb095608c2ccf0aac24aa315fbac" + integrity sha512-Lxog7IDMMWNjwo4O0KbdBvSewk4vW6kQe5XaLuuPCyCE65AGQ1P8YqKJa5dq8TYf/Ge31F+KjWzPR5mAJvjlAg== + dependencies: + chalk "^2.4.1" + tinygradient "^0.4.1" + grapheme-splitter@^1.0.4: version "1.0.4" resolved "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz" @@ -11153,11 +11325,6 @@ human-signals@^4.3.0: resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-4.3.1.tgz#ab7f811e851fca97ffbd2c1fe9a958964de321b2" integrity sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ== -husky@^8.0.1: - version "8.0.3" - resolved "https://registry.yarnpkg.com/husky/-/husky-8.0.3.tgz#4936d7212e46d1dea28fef29bb3a108872cd9184" - integrity sha512-+dQSyqPh4x1hlO1swXBiNb2HzTDN1I2IGLQx1GrBuiqFJfoMrnZWwVmatvSiO+Iz8fBUnf+lekwNo4c2LlXItg== - iconv-lite@0.4.24, iconv-lite@^0.4.24: version "0.4.24" resolved "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz" @@ -11266,6 +11433,91 @@ ini@^1.3.2, ini@^1.3.4, ini@~1.3.0: resolved "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz" integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== +ink-big-text@1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/ink-big-text/-/ink-big-text-1.2.0.tgz#81c4e6008547635c88dcdcf869d7b983800e2a33" + integrity sha512-xDfn8oOhiji9c4wojTKSaBnEfgpTTd3KL7jsMYVht4SbpfLdSKvVZiMi3U5v45eSjLm1ycMmeMWAP1G99lWL5Q== + dependencies: + cfonts "^2.8.6" + prop-types "^15.7.2" + +ink-gradient@2: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ink-gradient/-/ink-gradient-2.0.0.tgz#2e2b040ab41f96f61b397d87cd56fd6ce9ef59cc" + integrity sha512-d2BK/EzzBRoDL54NWkS3JGE4J8xtzwRVWxDAIkQ/eQ60XIzrFMtT5JlUqgV05Qlt32Jvk50qW51YqxGJggTuqA== + dependencies: + gradient-string "^1.2.0" + prop-types "^15.7.2" + strip-ansi "^6.0.0" + +ink-link@2: + version "2.0.1" + resolved "https://registry.yarnpkg.com/ink-link/-/ink-link-2.0.1.tgz#5fdb8939f63fce629b929371fb643e5284756bf6" + integrity sha512-244mypIguXjMz+vW9F0fMrgFJyDy8ZEoMUYTMW7FOB2Vlb9IqkVZOtDL7sLaeOSQj28L9of591FJR6JpvsF4lA== + dependencies: + prop-types "^15.7.2" + terminal-link "^2.1.1" + +ink@*: + version "4.4.1" + resolved "https://registry.yarnpkg.com/ink/-/ink-4.4.1.tgz#ae684a141e92524af3eccf740c38f03618b48028" + integrity sha512-rXckvqPBB0Krifk5rn/5LvQGmyXwCUpBfmTwbkQNBY9JY8RSl3b8OftBNEYxg4+SWUhEKcPifgope28uL9inlA== + dependencies: + "@alcalzone/ansi-tokenize" "^0.1.3" + ansi-escapes "^6.0.0" + auto-bind "^5.0.1" + chalk "^5.2.0" + cli-boxes "^3.0.0" + cli-cursor "^4.0.0" + cli-truncate "^3.1.0" + code-excerpt "^4.0.0" + indent-string "^5.0.0" + is-ci "^3.0.1" + is-lower-case "^2.0.2" + is-upper-case "^2.0.2" + lodash "^4.17.21" + patch-console "^2.0.0" + react-reconciler "^0.29.0" + scheduler "^0.23.0" + signal-exit "^3.0.7" + slice-ansi "^6.0.0" + stack-utils "^2.0.6" + string-width "^5.1.2" + type-fest "^0.12.0" + widest-line "^4.0.1" + wrap-ansi "^8.1.0" + ws "^8.12.0" + yoga-wasm-web "~0.3.3" + +ink@3: + version "3.2.0" + resolved "https://registry.yarnpkg.com/ink/-/ink-3.2.0.tgz#434793630dc57d611c8fe8fffa1db6b56f1a16bb" + integrity sha512-firNp1q3xxTzoItj/eOOSZQnYSlyrWks5llCTVX37nJ59K3eXbQ8PtzCguqo8YI19EELo5QxaKnJd4VxzhU8tg== + dependencies: + ansi-escapes "^4.2.1" + auto-bind "4.0.0" + chalk "^4.1.0" + cli-boxes "^2.2.0" + cli-cursor "^3.1.0" + cli-truncate "^2.1.0" + code-excerpt "^3.0.0" + indent-string "^4.0.0" + is-ci "^2.0.0" + lodash "^4.17.20" + patch-console "^1.0.0" + react-devtools-core "^4.19.1" + react-reconciler "^0.26.2" + scheduler "^0.20.2" + signal-exit "^3.0.2" + slice-ansi "^3.0.0" + stack-utils "^2.0.2" + string-width "^4.2.2" + type-fest "^0.12.0" + widest-line "^3.1.0" + wrap-ansi "^6.2.0" + ws "^7.5.5" + yoga-layout-prebuilt "^1.9.6" + inquirer-autocomplete-prompt@^1.0.1: version "1.4.0" resolved "https://registry.npmjs.org/inquirer-autocomplete-prompt/-/inquirer-autocomplete-prompt-1.4.0.tgz" @@ -11474,6 +11726,13 @@ is-callable@^1.1.3, is-callable@^1.1.4, is-callable@^1.2.7: resolved "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz" integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA== +is-ci@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-2.0.0.tgz#6bc6334181810e04b5c22b3d589fdca55026404c" + integrity sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w== + dependencies: + ci-info "^2.0.0" + is-ci@^3.0.1: version "3.0.1" resolved "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz" @@ -12524,6 +12783,11 @@ json-schema-traverse@^1.0.0: resolved "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz" integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== +json-schema-typed@^7.0.3: + version "7.0.3" + resolved "https://registry.yarnpkg.com/json-schema-typed/-/json-schema-typed-7.0.3.tgz#23ff481b8b4eebcd2ca123b4fa0409e66469a2d9" + integrity sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A== + json-stable-stringify-without-jsonify@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz" @@ -13481,6 +13745,11 @@ mimic-fn@^2.1.0: resolved "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz" integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== +mimic-fn@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-3.1.0.tgz#65755145bbf3e36954b949c16450427451d5ca74" + integrity sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ== + mimic-fn@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz" @@ -13987,6 +14256,13 @@ node-fetch@2.6.7: dependencies: whatwg-url "^5.0.0" +node-fetch@2.7.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" + integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== + dependencies: + whatwg-url "^5.0.0" + node-fetch@^1.0.1: version "1.7.3" resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-1.7.3.tgz" @@ -14331,7 +14607,7 @@ onetime@^6.0.0: dependencies: mimic-fn "^4.0.0" -open@^8.0.4: +open@8, open@^8.0.4: version "8.4.2" resolved "https://registry.npmjs.org/open/-/open-8.4.2.tgz" integrity sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ== @@ -14369,7 +14645,7 @@ optionator@^0.9.1: type-check "^0.4.0" word-wrap "^1.2.3" -ora@5.4.1, ora@^5.4.1: +ora@5, ora@5.4.1, ora@^5.4.1: version "5.4.1" resolved "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz" integrity sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ== @@ -14718,6 +14994,16 @@ pascalcase@^0.1.1: resolved "https://registry.npmjs.org/pascalcase/-/pascalcase-0.1.1.tgz" integrity sha512-XHXfu/yOQRy9vYOtUDVMN60OEJjW013GoObG1o+xwQTpB9eYJX/BjXMsdW13ZDPruFhYYn0AG22w0xgQMwl3Nw== +patch-console@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/patch-console/-/patch-console-1.0.0.tgz#19b9f028713feb8a3c023702a8cc8cb9f7466f9d" + integrity sha512-nxl9nrnLQmh64iTzMfyylSlRozL7kAXIaxw1fVcLYdyhNkJCRUzirRZTikXGJsg+hc4fqpneTK6iU2H1Q8THSA== + +patch-console@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/patch-console/-/patch-console-2.0.0.tgz#9023f4665840e66f40e9ce774f904a63167433bb" + integrity sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA== + path-browserify@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz" @@ -14925,6 +15211,13 @@ pkg-types@^1.0.2: mlly "^1.1.1" pathe "^1.1.0" +pkg-up@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/pkg-up/-/pkg-up-3.1.0.tgz#100ec235cc150e4fd42519412596a28512a0def5" + integrity sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA== + dependencies: + find-up "^3.0.0" + playwright-core@1.31.2: version "1.31.2" resolved "https://registry.npmjs.org/playwright-core/-/playwright-core-1.31.2.tgz" @@ -14975,6 +15268,14 @@ postcss@^8.4.12, postcss@^8.4.18, postcss@^8.4.21: picocolors "^1.0.0" source-map-js "^1.0.2" +posthog-node@^3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/posthog-node/-/posthog-node-3.1.2.tgz#89bbf099f2101b96907e2da2126db312083fd4bd" + integrity sha512-atPGYjiK+QvtseKKsrUxMrzN84sIVs9jTa7nx5hl999gJly1S3J5r0DApwZ69NKfJkVIeLTCJyT0kyS+7WqDSw== + dependencies: + axios "^0.27.0" + rusha "^0.8.14" + preact@^10.10.0: version "10.13.2" resolved "https://registry.yarnpkg.com/preact/-/preact-10.13.2.tgz#2c40c73d57248b57234c4ae6cd9ab9d8186ebc0a" @@ -15380,6 +15681,14 @@ react-device-detect@^2.2.2: dependencies: ua-parser-js "^1.0.33" +react-devtools-core@^4.19.1: + version "4.28.0" + resolved "https://registry.yarnpkg.com/react-devtools-core/-/react-devtools-core-4.28.0.tgz#3fa18709b24414adddadac33b6b9cea96db60f2f" + integrity sha512-E3C3X1skWBdBzwpOUbmXG8SgH6BtsluSMe+s6rRcujNKG1DGi8uIfhdhszkgDpAsMoE55hwqRUzeXCmETDBpTg== + dependencies: + shell-quote "^1.6.1" + ws "^7" + react-dom@^18.2.0: version "18.2.0" resolved "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz" @@ -15453,6 +15762,23 @@ react-is@^18.0.0: resolved "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz" integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w== +react-reconciler@^0.26.2: + version "0.26.2" + resolved "https://registry.yarnpkg.com/react-reconciler/-/react-reconciler-0.26.2.tgz#bbad0e2d1309423f76cf3c3309ac6c96e05e9d91" + integrity sha512-nK6kgY28HwrMNwDnMui3dvm3rCFjZrcGiuwLc5COUipBK5hWHLOxMJhSnSomirqWwjPBJKV1QcbkI0VJr7Gl1Q== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + scheduler "^0.20.2" + +react-reconciler@^0.29.0: + version "0.29.0" + resolved "https://registry.yarnpkg.com/react-reconciler/-/react-reconciler-0.29.0.tgz#ee769bd362915076753f3845822f2d1046603de7" + integrity sha512-wa0fGj7Zht1EYMRhKWwoo1H9GApxYLBuhoAuXN0TlltESAjDssB+Apf0T/DngVqaMyPypDmabL37vw/2aRM98Q== + dependencies: + loose-envify "^1.1.0" + scheduler "^0.23.0" + react-refresh@^0.14.0: version "0.14.0" resolved "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.0.tgz" @@ -15486,9 +15812,9 @@ react-style-singleton@^2.2.1: invariant "^2.2.4" tslib "^2.0.0" -react@^18.2.0: +react@18, react@^18.2.0: version "18.2.0" - resolved "https://registry.npmjs.org/react/-/react-18.2.0.tgz" + resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5" integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ== dependencies: loose-envify "^1.1.0" @@ -16026,6 +16352,14 @@ safe-stable-stringify@^2.3.1: resolved "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== +scheduler@^0.20.2: + version "0.20.2" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.20.2.tgz#4baee39436e34aa93b4874bddcbf0fe8b8b50e91" + integrity sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + scheduler@^0.23.0: version "0.23.0" resolved "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz" @@ -16195,6 +16529,11 @@ shebang-regex@^3.0.0: resolved "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz" integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== +shell-quote@^1.6.1: + version "1.8.1" + resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.8.1.tgz#6dbf4db75515ad5bac63b4f1894c3a154c766680" + integrity sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA== + shell-quote@^1.7.3: version "1.8.0" resolved "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.0.tgz" @@ -16291,6 +16630,14 @@ slice-ansi@^5.0.0: ansi-styles "^6.0.0" is-fullwidth-code-point "^4.0.0" +slice-ansi@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-6.0.0.tgz#f08a1e6703e3598256b667f015ccef9f12c59f7c" + integrity sha512-6bn4hRfkTvDfUoEQYkERg0BVF1D0vrX9HEkMl08uDiNWvVvjylLHvZFZWkDo6wjT8tUctbYl1nCOuE66ZTaUtA== + dependencies: + ansi-styles "^6.2.1" + is-fullwidth-code-point "^4.0.0" + slide@^1.1.5: version "1.1.6" resolved "https://registry.npmjs.org/slide/-/slide-1.1.6.tgz" @@ -16525,7 +16872,7 @@ stack-trace@0.0.x: resolved "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz" integrity sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg== -stack-utils@^2.0.3: +stack-utils@^2.0.2, stack-utils@^2.0.3, stack-utils@^2.0.6: version "2.0.6" resolved "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz" integrity sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ== @@ -16660,7 +17007,7 @@ string-width@^1.0.1: is-fullwidth-code-point "^1.0.0" strip-ansi "^3.0.0" -"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: version "4.2.3" resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -17158,6 +17505,19 @@ tinybench@^2.5.0: resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.5.0.tgz#4711c99bbf6f3e986f67eb722fed9cddb3a68ba5" integrity sha512-kRwSG8Zx4tjF9ZiyH4bhaebu+EDz1BOx9hOigYHlUW4xxI/wKIUQUqo018UlU4ar6ATPBsaMrdbKZ+tmPdohFA== +tinycolor2@^1.0.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.6.0.tgz#f98007460169b0263b97072c5ae92484ce02d09e" + integrity sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw== + +tinygradient@^0.4.1: + version "0.4.3" + resolved "https://registry.yarnpkg.com/tinygradient/-/tinygradient-0.4.3.tgz#0a8dfde56f8865deec4c435a51bd5b0c0dec59fa" + integrity sha512-tBPYQSs6eWukzzAITBSmqcOwZCKACvRa/XjPPh1mj4mnx4G3Drm51HxyCTU/TKnY8kG4hmTe5QlOh9O82aNtJQ== + dependencies: + "@types/tinycolor2" "^1.4.0" + tinycolor2 "^1.0.0" + tinypool@^0.4.0: version "0.4.0" resolved "https://registry.npmjs.org/tinypool/-/tinypool-0.4.0.tgz" @@ -17499,6 +17859,11 @@ type-detect@4.0.8, type-detect@^4.0.0, type-detect@^4.0.5: resolved "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz" integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== +type-fest@^0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.12.0.tgz#f57a27ab81c68d136a51fd71467eff94157fa1ee" + integrity sha512-53RyidyjvkGpnWPMF9bQgFtWp+Sl8O2Rp13VavmJgfAP9WWG6q6TkrKU8iyJdnwnfgHI6k2hTlgqH4aSdjoTbg== + type-fest@^0.13.1: version "0.13.1" resolved "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz" @@ -17534,6 +17899,11 @@ type-fest@^2.0.0, type-fest@^2.11.2, type-fest@^2.12.2, type-fest@^2.13.0, type- resolved "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz" integrity sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA== +type-fest@^3.0.0: + version "3.13.1" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-3.13.1.tgz#bb744c1f0678bea7543a2d1ec24e83e68e8c8706" + integrity sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g== + type-is@~1.6.18: version "1.6.18" resolved "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz" @@ -18287,6 +18657,13 @@ wide-align@^1.1.2: dependencies: string-width "^1.0.2 || 2 || 3 || 4" +widest-line@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-3.1.0.tgz#8292333bbf66cb45ff0de1603b136b7ae1496eca" + integrity sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg== + dependencies: + string-width "^4.0.0" + widest-line@^4.0.1: version "4.0.1" resolved "https://registry.npmjs.org/widest-line/-/widest-line-4.0.1.tgz" @@ -18294,6 +18671,14 @@ widest-line@^4.0.1: dependencies: string-width "^5.0.1" +window-size@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/window-size/-/window-size-1.1.1.tgz#9858586580ada78ab26ecd6978a6e03115c1af20" + integrity sha512-5D/9vujkmVQ7pSmc0SCBmHXbkv6eaHwXEx65MywhmUMsI8sGqJ972APq1lotfcwMKPFLuCFfL8xGHLIp7jaBmA== + dependencies: + define-property "^1.0.0" + is-number "^3.0.0" + windows-release@^5.0.1: version "5.1.0" resolved "https://registry.npmjs.org/windows-release/-/windows-release-5.1.0.tgz" @@ -18377,7 +18762,7 @@ wrap-ansi@^7.0.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^8.0.1: +wrap-ansi@^8.0.1, wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz" integrity sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ== @@ -18423,7 +18808,7 @@ ws@8.12.1, ws@^8.12.0, ws@^8.12.1: resolved "https://registry.npmjs.org/ws/-/ws-8.12.1.tgz" integrity sha512-1qo+M9Ba+xNhPB+YTWUlK6M17brTut5EXbcBaMRN5pH5dFrXz7lzz1ChFSUq3bOUl8yEvSenhHmYUNJxFzdJew== -ws@^7.3.1: +ws@^7, ws@^7.3.1, ws@^7.5.5: version "7.5.9" resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.9.tgz#54fa7db29f4c7cec68b1ddd3a89de099942bb591" integrity sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q== @@ -18555,6 +18940,19 @@ yargs@^17.0.0, yargs@^17.1.1, yargs@^17.3.1, yargs@^17.6.0: y18n "^5.0.5" yargs-parser "^21.1.1" +yargs@^17.7.2: + version "17.7.2" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269" + integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w== + 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.5" + yargs-parser "^21.1.1" + yauzl@^2.10.0, yauzl@^2.4.2: version "2.10.0" resolved "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz" @@ -18578,6 +18976,18 @@ yocto-queue@^1.0.0: resolved "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.0.0.tgz" integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g== +yoga-layout-prebuilt@^1.9.6: + version "1.10.0" + resolved "https://registry.yarnpkg.com/yoga-layout-prebuilt/-/yoga-layout-prebuilt-1.10.0.tgz#2936fbaf4b3628ee0b3e3b1df44936d6c146faa6" + integrity sha512-YnOmtSbv4MTf7RGJMK0FvZ+KD8OEe/J5BNnR0GHhD8J/XcG/Qvxgszm0Un6FTHWW4uHlTgP0IztiXQnGyIR45g== + dependencies: + "@types/yoga-layout" "1.9.2" + +yoga-wasm-web@~0.3.3: + version "0.3.3" + resolved "https://registry.yarnpkg.com/yoga-wasm-web/-/yoga-wasm-web-0.3.3.tgz#eb8e9fcb18e5e651994732f19a220cb885d932ba" + integrity sha512-N+d4UJSJbt/R3wqY7Coqs5pcV0aUj2j9IaQ3rNj9bVCLld8tTGKRa2USARjnvZJWVx1NDmQev8EknoczaOQDOA== + yup@^1.0.0-beta.7: version "1.1.0" resolved "https://registry.yarnpkg.com/yup/-/yup-1.1.0.tgz#9e2439156970410f13c0aa842379c3a7240127ca" @@ -18617,3 +19027,8 @@ zod@^3.21.4: version "3.21.4" resolved "https://registry.npmjs.org/zod/-/zod-3.21.4.tgz" integrity sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw== + +zod@^3.22.2: + version "3.22.2" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.2.tgz#3add8c682b7077c05ac6f979fea6998b573e157b" + integrity sha512-wvWkphh5WQsJbVk1tbx1l1Ly4yg+XecD+Mq280uBGt9wa5BKSWf4Mhp6GmrkPixhMxmabYY7RbzlwVP32pbGCg==