Skip to content

Commit

Permalink
🎁 CLI flow [2/N] (#13)
Browse files Browse the repository at this point in the history
  • Loading branch information
janjakubnanista authored Nov 15, 2023
1 parent e6e96d8 commit 65c2868
Show file tree
Hide file tree
Showing 18 changed files with 598 additions and 40 deletions.
5 changes: 5 additions & 0 deletions .changeset/forty-teachers-rescue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"create-lz-oapp": minor
---

Add ability to download & install OApp & OFT examples
8 changes: 8 additions & 0 deletions packages/create-lz-oapp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,13 @@
"./cli.cjs",
"./dist"
],
"dependencies": {
"yoga-wasm-web": "~0.3.3"
},
"devDependencies": {
"@sindresorhus/tsconfig": "^5.0.0",
"@tanstack/react-query": "^5.8.3",
"@types/prompts": "^2.4.8",
"@types/react": "^18.0.32",
"commander": "^11.1.0",
"eslint-plugin-react": "^7.32.2",
Expand All @@ -44,8 +49,11 @@
"ink-gradient": "^3.0.0",
"ink-select-input": "^5.0.0",
"ink-spinner": "^5.0.0",
"ink-text-input": "^5.0.1",
"prompts": "^2.4.2",
"react": "^18.2.0",
"react-devtools-core": "^4.28.5",
"tiged": "^2.12.5",
"ts-node": "^10.9.1",
"typescript": "^5.2.2"
}
Expand Down
8 changes: 7 additions & 1 deletion packages/create-lz-oapp/src/components/branding.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { Text } from "ink"
import { Box, Text } from "ink"
import Gradient from "ink-gradient"
import { stdout } from "process"
import React, { useEffect, useState } from "react"

export const Header: React.FC = () => (
<Box justifyContent="center" marginBottom={5}>
<Logo />
</Box>
)

export const Logo: React.FC = () => {
const [columns, setColumns] = useState<number>(stdout.columns ?? 80)
const logo = columns >= 130 ? LOGO_LARGE : LOGO_SMALL
Expand Down
25 changes: 25 additions & 0 deletions packages/create-lz-oapp/src/components/config.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import React from "react"
import { Box, Text } from "ink"
import { Config } from "@/types.js"
import { resolve } from "path"

interface Props {
value: Config
}

export const ConfigSummary: React.FC<Props> = ({ value }) => {
return (
<Box flexDirection="column">
<Text>
Will create a project in <Text bold>{value.destination || "current"}</Text> directory (
<Text bold>{resolve(value.destination)}</Text>)
</Text>
<Text>
Will use the <Text bold>{value.example.label}</Text> example
</Text>
<Text>
Will use <Text bold>{value.packageManager.label}</Text> to install dependencies
</Text>
</Box>
)
}
15 changes: 0 additions & 15 deletions packages/create-lz-oapp/src/components/placeholder.tsx

This file was deleted.

39 changes: 39 additions & 0 deletions packages/create-lz-oapp/src/components/progress.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import React from "react"
import { Box, Text } from "ink"
import Spinner from "ink-spinner"
import { UseMutationResult } from "@tanstack/react-query"

type ErrorComponent = React.ComponentType<{ error: unknown }>

interface Props {
error: ErrorComponent
message: string
mutation: Pick<UseMutationResult, "isPending" | "isSuccess" | "error">
}

export const Progress: React.FC<Props> = ({ mutation, message, error: Error }) => {
const { isPending, isSuccess, error } = mutation

return (
<Box flexDirection="column">
<Box>
{isPending ? (
<Spinner />
) : isSuccess ? (
<Text color="green"></Text>
) : error ? (
<Text color="red">𐄂</Text>
) : (
<Text color="yellow"></Text>
)}
<Text> {message}</Text>
</Box>

{error == null ? null : (
<Box marginLeft={2}>
<Error error={error} />
</Box>
)}
</Box>
)
}
8 changes: 8 additions & 0 deletions packages/create-lz-oapp/src/components/providers.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import React, { useState } from "react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"

export const Providers: React.FC<React.PropsWithChildren> = ({ children }) => {
const [queryClient] = useState(() => new QueryClient())

return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
}
91 changes: 91 additions & 0 deletions packages/create-lz-oapp/src/components/setup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import React, { useEffect } from "react"
import type { Config } from "@/types.js"
import { Box, Text } from "ink"
import { useMutation } from "@tanstack/react-query"
import { DestinationNotEmptyError, DownloadError, MissingGitRefError, cloneExample } from "@/utilities/cloning.js"
import { Progress } from "./progress.js"
import { installDependencies } from "@/utilities/installation.js"

interface Props {
config: Config
}

export const Setup: React.FC<Props> = ({ config }) => {
const clone = useMutation({
mutationKey: ["setup", "clone"],
mutationFn: () => cloneExample(config),
})

const install = useMutation({
mutationKey: ["setup", "install"],
mutationFn: () => installDependencies(config),
})

const { mutate: setup } = useMutation({
mutationKey: ["setup", "flow"],
mutationFn: async () => {
await clone.mutateAsync()
await install.mutateAsync()
},
})

useEffect(() => setup(), [setup])

return (
<Box flexDirection="column">
<Progress
message="Getting example source code"
mutation={clone}
error={({ error }) => <ErrorMessage config={config} error={error} />}
/>
<Progress
message="Installing dependencies"
mutation={install}
error={({ error }) => <ErrorMessage config={config} error={error} />}
/>
</Box>
)
}

interface ErrorMessageProps {
config: Config
error?: unknown
}

const ErrorMessage: React.FC<ErrorMessageProps> = ({ config, error }) => {
if (error == null) return null

switch (true) {
case error instanceof DestinationNotEmptyError:
return (
<Text color="red">
Destination directory <Text bold>{config.destination}</Text> is not empty
</Text>
)

case error instanceof MissingGitRefError:
return (
<Text color="red">
The example <Text bold>{config.example.label}</Text> does not seem to exist in the repository
</Text>
)

case error instanceof DownloadError:
return (
<Box flexDirection="column">
<Text color="red">There was a problem downloading the example</Text>
<Text>○ Please check your internet connection</Text>
<Text>
○ Please check that the example exists (<Text bold>{config.example.repository}</Text>)
</Text>
</Box>
)

default:
return (
<Text color="red">
An unknown error happened: <Text bold>{String(error)}</Text>
</Text>
)
}
}
35 changes: 35 additions & 0 deletions packages/create-lz-oapp/src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { Example, PackageManager } from "@/types.js"

export const EXAMPLES: Example[] = [
{
id: "oft",
label: "OFT",
repository: "[email protected]:LayerZero-Labs/lz-examples",
directory: "packages/oft",
},
{
id: "oapp",
label: "OApp",
repository: "[email protected]:LayerZero-Labs/lz-examples",
directory: "packages/oapp",
},
]

export const PACKAGE_MANAGERS: PackageManager[] = [
{
command: "npm",
label: "npm",
},
{
command: "yarn",
label: "yarn",
},
{
command: "pnpm",
label: "pnpm",
},
{
command: "bun",
label: "bun",
},
]
34 changes: 28 additions & 6 deletions packages/create-lz-oapp/src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,41 @@
import React from "react"
import { render } from "ink"
import { Command } from "commander"
import { Placeholder } from "./components/placeholder.js"
import { altScreen } from "./utilities/terminal.js"
import { promptForConfig, promptForContinue } from "@/utilities/prompts.js"
import { Header } from "@/components/branding.js"
import { ConfigSummary } from "@/components/config.js"
import { Setup } from "@/components/setup.js"
import { Providers } from "@/components/providers.js"

new Command("create-lz-oapp")
.description("Create LayerZero OApp with one command")
.action(async () => {
const exitAltScreen = await altScreen()
// const exitAltScreen = await altScreen()

try {
const { waitUntilExit } = render(<Placeholder />)
await waitUntilExit()
render(<Header />).unmount()

// First we get the config from the user
const config = await promptForConfig()
render(<ConfigSummary value={config} />).unmount()

// Then we confirm we want to do this after showing the user what they have specified
const continuePlease = await promptForContinue()
if (!continuePlease) {
return
}

// Then the last step is to show the setup flow
const setup = render(
<Providers>
<Setup config={config} />
</Providers>
)

// And wait for it to exit
await setup.waitUntilExit()
} finally {
await exitAltScreen()
// await exitAltScreen()
}
})
.parseAsync()
17 changes: 17 additions & 0 deletions packages/create-lz-oapp/src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export interface Config {
destination: string
example: Example
packageManager: PackageManager
}

export interface Example {
id: string
label: string
repository: string
directory?: string
}

export interface PackageManager {
command: string
label: string
}
63 changes: 63 additions & 0 deletions packages/create-lz-oapp/src/utilities/cloning.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { Config } from "@/types.js"
import { rm } from "fs/promises"
import tiged from "tiged"

export const cloneExample = async ({ example, destination }: Config) => {
const qualifier = example.directory ? `${example.repository}/${example.directory}` : example.repository
const emitter = tiged(qualifier, {
disableCache: true,
mode: "git",
verbose: true,
})

try {
return await emitter.clone(destination)
} catch (error: unknown) {
try {
// Let's make sure to clean up after us
await rm(destination, { recursive: true, force: true })
} catch {
// If the cleanup fails let's just do nothing for now
}

if (error instanceof Error && "code" in error) {
switch (error.code) {
case "DEST_NOT_EMPTY":
throw new DestinationNotEmptyError()

case "ENOENT":
case "MISSING_REF":
throw new MissingGitRefError()

case "COULD_NOT_DOWNLOAD":
throw new DownloadError()
}
}

throw new CloningError()
}
}

export class CloningError extends Error {
constructor(message: string = "Unknown error during example cloning") {
super(message)
}
}

export class DestinationNotEmptyError extends CloningError {
constructor(message: string = "Project destination directory is not empty") {
super(message)
}
}

export class MissingGitRefError extends CloningError {
constructor(message: string = "Could not find the example repository or branch") {
super(message)
}
}

export class DownloadError extends CloningError {
constructor(message: string = "Could not download the example from repository") {
super(message)
}
}
Loading

0 comments on commit 65c2868

Please sign in to comment.