Wasp Studio [experimental] (#1483)

This commit is contained in:
Mihovil Ilakovac 2023-10-10 12:17:05 +02:00 committed by GitHub
parent 88d9534fd3
commit 03cfcdfaaf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
44 changed files with 9708 additions and 1 deletions

View File

@ -21,6 +21,10 @@ export default defineConfig({
⚠️ Be careful when changing the dev server port, you'll need to update the `WASP_WEB_CLIENT_URL` env var in your `.env.server` file.
### 🚧 [Experimental Feature] Wasp Studio
Running `wasp studio` in the root of your project starts Wasp Studio which visualises your application and shows you the relationships between pieces of your app. It is an experimental feature which is not yet fully ready, but we are working on it and will be adding more features to it in the future.
## 0.11.5
### 🐞 Bug fixes / 🔧 small improvements

View File

@ -27,6 +27,7 @@ import Wasp.Cli.Command.Dockerfile (printDockerfile)
import Wasp.Cli.Command.Info (info)
import Wasp.Cli.Command.Start (start)
import qualified Wasp.Cli.Command.Start.Db as Command.Start.Db
import Wasp.Cli.Command.Studio (studio)
import qualified Wasp.Cli.Command.Telemetry as Telemetry
import Wasp.Cli.Command.Test (test)
import Wasp.Cli.Command.Uninstall (uninstall)
@ -56,6 +57,7 @@ main = withUtf8 . (`E.catch` handleInternalErrors) $ do
["deps"] -> Command.Call.Deps
["dockerfile"] -> Command.Call.Dockerfile
["info"] -> Command.Call.Info
["studio"] -> Command.Call.Studio
["completion"] -> Command.Call.PrintBashCompletionInstruction
["completion:generate"] -> Command.Call.GenerateBashCompletionScript
["completion:list"] -> Command.Call.BashCompletionListCommands
@ -84,6 +86,7 @@ main = withUtf8 . (`E.catch` handleInternalErrors) $ do
Command.Call.Compile -> runCommand compile
Command.Call.Db dbArgs -> dbCli dbArgs
Command.Call.Version -> printVersion
Command.Call.Studio -> runCommand studio
Command.Call.Uninstall -> runCommand uninstall
Command.Call.Build -> runCommand build
Command.Call.Telemetry -> runCommand Telemetry.telemetry

View File

@ -14,6 +14,7 @@ data Call
| Deps
| Dockerfile
| Info
| Studio
| PrintBashCompletionInstruction
| GenerateBashCompletionScript
| BashCompletionListCommands

View File

@ -0,0 +1,193 @@
module Wasp.Cli.Command.Studio
( studio,
)
where
import Control.Arrow ()
import Control.Monad.Except (throwError)
import Control.Monad.IO.Class (liftIO)
import Data.Aeson (object, (.=))
import Data.Aeson.Encode.Pretty (encodePretty)
import qualified Data.ByteString.Lazy as BSL
import Data.Maybe (fromMaybe, isJust)
import StrongPath (relfile, (</>))
import qualified StrongPath as SP
import StrongPath.Operations ()
import qualified System.Directory as Dir
import qualified Wasp.AppSpec as AS
import qualified Wasp.AppSpec.Api as AS.Api
import qualified Wasp.AppSpec.App as AS.App
import qualified Wasp.AppSpec.App.Auth as AS.App.Auth
import qualified Wasp.AppSpec.App.Db as AS.App.Db
import qualified Wasp.AppSpec.Job as AS.Job
import Wasp.AppSpec.Operation (Operation (..))
import qualified Wasp.AppSpec.Operation as Operation
import qualified Wasp.AppSpec.Page as AS.Page
import qualified Wasp.AppSpec.Route as AS.Route
import qualified Wasp.AppSpec.Valid as ASV
import Wasp.Cli.Command (Command, CommandError (CommandError))
import Wasp.Cli.Command.Compile (analyze)
import Wasp.Cli.Command.Message (cliSendMessageC)
import Wasp.Cli.Command.Require (InWaspProject (InWaspProject), require)
import qualified Wasp.Cli.Common as Common
import qualified Wasp.Message as Msg
import qualified Wasp.Project.Studio
studio :: Command ()
studio = do
InWaspProject waspDir <- require
appSpec <- analyze waspDir
let (appName, app) = ASV.getApp appSpec
let appInfoJson =
object
[ "pages"
.= map
( \(name, page) ->
object
[ "name" .= name,
"authRequired" .= AS.Page.authRequired page
-- "operations" .= [] -- TODO: Add operations that page uses. Not easy.
]
)
(AS.getPages appSpec),
"routes"
.= map
( \(name, route) ->
object
[ "name" .= name,
"path" .= AS.Route.path route,
"toPage"
.= object
[ "name" .= fst (AS.resolveRef appSpec $ AS.Route.to route)
]
]
)
(AS.getRoutes appSpec),
"apis"
.= map
( \(name, api) ->
object
[ "name" .= name,
"httpRoute"
.= let (method, path) = AS.Api.httpRoute api
in object
[ "method" .= show method,
"path" .= path
],
"auth" .= AS.Api.auth api,
"entities" .= getLinkedEntitiesData appSpec (AS.Api.entities api)
]
)
(AS.getApis appSpec),
"jobs"
.= map
( \(name, job) ->
object
[ "name" .= name,
"schedule" .= (AS.Job.cron <$> AS.Job.schedule job),
"entities" .= getLinkedEntitiesData appSpec (AS.Job.entities job)
]
)
(AS.getJobs appSpec),
"operations"
.= map
( \operation ->
object
[ "type" .= case operation of
_op@(QueryOp _ _) -> "query" :: String
_op@(ActionOp _ _) -> "action",
"name" .= Operation.getName operation,
"entities"
.= getLinkedEntitiesData appSpec (Operation.getEntities operation),
"auth" .= Operation.getAuth operation
]
)
(AS.getOperations appSpec),
"entities"
.= map
( \(name, _entity) ->
object
[ "name" .= name
]
)
(AS.getEntities appSpec),
"app"
.= object
[ "name" .= (appName :: String),
"auth" .= getAuthInfo appSpec app,
"db" .= getDbInfo app
]
-- TODO: Add CRUDs.
]
let generatedProjectDir =
waspDir </> Common.dotWaspDirInWaspProjectDir
</> Common.generatedCodeDirInDotWaspDir
let waspStudioDataJsonFilePath = generatedProjectDir </> [relfile|.wasp-studio-data.json|]
liftIO $ do
Dir.createDirectoryIfMissing True $ SP.fromAbsDir $ SP.parent waspStudioDataJsonFilePath
BSL.writeFile (SP.fromAbsFile waspStudioDataJsonFilePath) (encodePretty appInfoJson)
cliSendMessageC . Msg.Info $
unlines
[ "✨ Starting Wasp Studio ✨",
"",
"➜ Open in your browser: http://localhost:4000",
"",
"Wasp Studio visualises your app and lets you understand how different parts of your app are connected."
]
result <- liftIO $ do
Wasp.Project.Studio.startStudio $ SP.toFilePath waspStudioDataJsonFilePath
either (throwError . CommandError "Studio command failed") return result
where
getLinkedEntitiesData spec entityRefs =
map
( \(entityName, _entity) ->
object ["name" .= entityName]
)
$ resolveEntities spec entityRefs
resolveEntities spec entityRefs =
AS.resolveRef spec <$> fromMaybe [] entityRefs
getDbInfo app = do
db <- AS.App.db app
return $
object
[ "system" .= (show <$> AS.App.Db.system db)
]
getAuthInfo spec app = do
auth <- AS.App.auth app
return $
object
[ "userEntity"
.= object
[ "name" .= fst (AS.resolveRef spec $ AS.App.Auth.userEntity auth)
],
"methods"
.= let methods = AS.App.Auth.methods auth
in -- TODO: Make this type safe, so it gives compile time error/warning if
-- new field is added to AuthMethods and we haven't covered it here.
-- Best to use TH here to generate this object from AuthMethods?
concat
[ [ "usernameAndPassword"
| isJust $ AS.App.Auth.usernameAndPassword methods
],
[ "google"
| isJust $ AS.App.Auth.google methods
],
[ "gitHub"
| isJust $ AS.App.Auth.gitHub methods
],
[ "email"
| isJust $ AS.App.Auth.email methods
]
] ::
[String]
]

View File

@ -5,7 +5,7 @@ app pgVectorExample {
title: "PG Vector Example",
dependencies: [
("openai", "^4.5.0"),
("react-hook-form", "^7.43.1"),
("react-hook-form", "^7.45.4"),
("@nextui-org/react", "^2.1.10"),
("framer-motion", "^10.16.4"),
("pgvector", "0.1.5"),

4
waspc/packages/studio/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules/
dist/
.env
public/

View File

@ -0,0 +1,44 @@
# Wasp Studio
Wasp Studio has two components:
- the server
- the client
To develop the studio, you need to run both of them. First, run the server, then the client.
### Server
Install dependencies:
```bash
npm install
```
Running the server for some data file:
```bash
npm run dev -- -- -d <path_to_data_file>
```
For example, running the server with the data file from the `examples` directory:
```bash
npm run dev -- -- -d ../../examples/crud-testing/.wasp/out/.wasp-studio-data.json
```
### Client
Install dependencies:
```bash
cd client
npm install
```
Running the client:
```bash
npm run dev
```
Then open `http://localhost:5173` in your browser.

View File

@ -0,0 +1,18 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}

24
waspc/packages/studio/client/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@ -0,0 +1,17 @@
# Wasp Studio Client
It's a React + Vite app that connects via WebSocket to the Wasp Studio Server.
### Development
Install deps:
```
npm install
```
Make sure you are running the Wasp Studio Server locally. And then run:
```
npm run dev
```

View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Wasp Studio</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,37 @@
{
"name": "wasp-studio-client",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"copy": "npm run build && cp -r ./dist/* ../public",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@nextui-org/react": "^2.1.12",
"elkjs": "^0.8.2",
"framer-motion": "^10.16.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"reactflow": "^11.8.3",
"socket.io-client": "^4.7.2"
},
"devDependencies": {
"@types/react": "^18.2.15",
"@types/react-dom": "^18.2.7",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"@vitejs/plugin-react-swc": "^3.3.2",
"autoprefixer": "^10.4.15",
"eslint": "^8.45.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.3",
"postcss": "^8.4.29",
"tailwindcss": "^3.3.3",
"typescript": "^5.0.2",
"vite": "^4.4.5"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@ -0,0 +1,58 @@
import { lazy, Suspense } from "react";
import {
Navbar,
NavbarBrand,
NavbarContent,
NavbarItem,
} from "@nextui-org/react";
import "reactflow/dist/style.css";
import { useSocket } from "./socket";
import { Logo } from "./Logo";
const Flow = lazy(() => import("./Flow"));
export default function App() {
const { data, isConnected } = useSocket();
return (
<div className="h-full">
<Navbar position="static">
<NavbarBrand>
<Logo className="w-8 h-8" />
<p className="font-bold text-inherit ml-4">{data?.app.name}</p>
</NavbarBrand>
<NavbarContent justify="end">
<NavbarItem>
<div className="text-sm p-2 w-35">
{isConnected ? (
/* Green dot */ <span className="flex items-center">
Connected
<span className="w-2 h-2 bg-green-500 rounded-full inline-block ml-2"></span>
</span>
) : (
/* Red dot */ <span className="flex items-center">
Connecting
<span className="w-2 h-2 bg-yellow-500 rounded-full inline-block ml-2"></span>
</span>
)}
</div>
</NavbarItem>
</NavbarContent>
</Navbar>
<div className="flow-container">
<Suspense fallback={<Loading />}>
{data ? <Flow data={data} /> : <Loading />}
</Suspense>
</div>
</div>
);
}
function Loading() {
return (
<div className="flex items-center justify-center h-full">
<p className="text-2xl text-gray-500">Loading...</p>
</div>
);
}

View File

@ -0,0 +1,316 @@
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useState,
} from "react";
import { Data } from "./types";
import ReactFlow, {
Background,
Node,
Edge,
useEdgesState,
useNodesState,
useReactFlow,
} from "reactflow";
import ELK, { type ElkNode } from "elkjs/lib/elk.bundled.js";
import { PageNode } from "./graph/Page";
import { EntityNode } from "./graph/Entity";
import { ActionNode, QueryNode } from "./graph/Operation";
import {
createActionNode,
createApiNode,
createAppNode,
createEdge,
createEntityNode,
createJobNode,
createPageNode,
createQueryNode,
createRouteNode,
} from "./graph/factories";
import { AppNode } from "./graph/App";
import { RouteNode } from "./graph/Route";
import { ApiNode } from "./graph/Api";
import { JobNode } from "./graph/Job";
const elk = new ELK();
const getLayoutedElements = (nodes: Node[], edges: Edge[]) => {
const graph = {
id: "root",
// Elk has a *huge* amount of options to configure. To see everything you can
// tweak check out:
//
// - https://www.eclipse.org/elk/reference/algorithms.html
// - https://www.eclipse.org/elk/reference/options.html
layoutOptions: {
// Alternative layout:
// "elk.spacing.nodeNode": "30.0",
// "elk.algorithm": "elk.layered",
// "elk.layered.spacing.nodeNodeBetweenLayers": "100.0",
// "elk.layered.thoroughness": "7",
// "elk.direction": "RIGHT",
// "elk.edgeRouting": "POLYLINE",
// "elk.aspectRatio": "1.0f",
"elk.algorithm": "layered",
"elk.direction": "RIGHT",
"elk.edgeRouting": "POLYLINE",
// "elk.hierarchyHandling": "INCLUDE_CHILDREN",
"elk.layered.crossingMinimization.semiInteractive": true,
},
children: nodes.map((node: Node) => ({
...node,
width: getNodeWidth(node),
height: getNodeHeight(node),
})),
edges: edges,
};
return (
elk
// Hack
.layout(graph as unknown as ElkNode)
.then((layoutedGraph) => {
if (!layoutedGraph.children) {
return null;
}
return {
nodes: layoutedGraph.children.map((node) => ({
...node,
position: { x: node.x, y: node.y },
})),
edges: layoutedGraph.edges,
};
})
.catch(console.error)
);
};
export default function Flow({ data }: { data: Data }) {
// NOTE: This is not used. But it might be useful in the future.
const [selectedNode] = useState<Node | null>(null);
const [nodes, setNodes] = useNodesState([]);
const [edges, setEdges] = useEdgesState([]);
const { fitView } = useReactFlow();
const nodeTypes = useMemo(
() => ({
pageNode: PageNode,
entityNode: EntityNode,
queryNode: QueryNode,
actionNode: ActionNode,
appNode: AppNode,
routeNode: RouteNode,
apiNode: ApiNode,
jobNode: JobNode,
}),
[]
);
const onLayout = useCallback(() => {
const initialNodes: Node[] = [
// ASSUMPTION: The names are of everything is unique.
createAppNode(
generateId(data.app.name, "app"),
data.app.name,
data.app,
selectedNode
),
...data.pages.map((page) =>
createPageNode(
generateId(page.name, "page"),
page.name,
page,
selectedNode
)
),
...data.operations
.filter((operation) => operation.type === "query")
.map((query) =>
createQueryNode(
generateId(query.name, "query"),
query.name,
query,
selectedNode
)
),
...data.operations
.filter((operation) => operation.type === "action")
.map((action) =>
createActionNode(
generateId(action.name, "action"),
action.name,
action,
selectedNode
)
),
...data.entities.map((entity) =>
createEntityNode(
generateId(entity.name, "entity"),
entity.name,
entity.name === data.app.auth?.userEntity.name,
entity,
selectedNode
)
),
...data.routes.map((route) =>
createRouteNode(
generateId(route.path, "route"),
route.path,
route,
selectedNode
)
),
...data.apis.map((api) =>
createApiNode(generateId(api.name, "api"), api.name, api, selectedNode)
),
...data.jobs.map((job) =>
createJobNode(generateId(job.name, "job"), job.name, job, selectedNode)
),
];
const initialEdges: Edge[] = [
...data.entities.map((entity) =>
createEdge(
generateId(entity.name, "entity"),
generateId(data.app.name, "app"),
selectedNode
)
),
...data.routes.map((route) =>
createEdge(
generateId(route.path, "route"),
generateId(route.toPage.name, "page"),
selectedNode
)
),
...data.operations.flatMap((operation) =>
operation.entities.map((entity) =>
// ASSUMPTION: operation.type is either "query" or "action"
createEdge(
generateId(operation.name, operation.type),
generateId(entity.name, "entity"),
selectedNode
)
)
),
...data.apis.flatMap((api) =>
api.entities.map((entity) =>
createEdge(
generateId(api.name, "api"),
generateId(entity.name, "entity"),
selectedNode
)
)
),
...data.jobs.flatMap((job) =>
job.entities.map((entity) =>
createEdge(
generateId(job.name, "job"),
generateId(entity.name, "entity"),
selectedNode
)
)
),
...data.routes.map((route) =>
createEdge(
generateId(data.app.name, "app"),
generateId(route.path, "route"),
selectedNode
)
),
];
getLayoutedElements(initialNodes, initialEdges).then((result) => {
if (!result) {
return;
}
const { nodes: layoutedNodes, edges: layoutedEdges } = result;
if (!layoutedNodes || !layoutedEdges) {
return;
}
// Hack
setNodes(layoutedNodes as Node[]);
// Hack
setEdges(layoutedEdges as unknown as Edge[]);
window.requestAnimationFrame(() => fitView());
});
}, [data, setNodes, setEdges, fitView, selectedNode]);
// Calculate the initial layout on mount.
useLayoutEffect(() => {
onLayout();
}, [onLayout]);
useEffect(() => {
setTimeout(() => {
fitView();
}, 100);
}, [fitView, selectedNode]);
useEffect(() => {
fitView();
}, [fitView]);
return (
<div style={{ height: "100%" }}>
<ReactFlow nodes={nodes} edges={edges} fitView nodeTypes={nodeTypes}>
<Background
style={{
backgroundColor: `hsl(var(--nextui-background)`,
}}
color={`#444`}
/>
</ReactFlow>
</div>
);
}
function getNodeHeight(node: Node) {
if (node.type === "apiNode") {
return 100;
}
if (node.type === "jobNode" && node.data.schedule) {
return 100;
}
if (node.type === "appNode") {
const authMethods = node.data.auth?.methods ?? [];
return 100 + authMethods.length * 50;
}
return 50;
}
function getNodeWidth(node: Node) {
const textCandidates = [
node.data?.label,
node.data?.name,
node.data?.path,
node.data?.schedule,
// Auth methods
...getAuthMethods(node),
]
.filter(Boolean)
.map((text) => text.length);
const longestText = Math.max(...textCandidates);
const width = Math.max(150, longestText * 10 + 40);
return width;
}
function generateId(name: string, type: string): string {
return `${type}:${name}`;
}
function getAuthMethods(node: Node) {
if (node.type !== "appNode") {
return [];
}
return (
node.data?.auth?.methods.map((method: string) => `Auth: ${method}`) ?? []
);
}

View File

@ -0,0 +1,52 @@
import { SVGProps } from "react";
export function Logo(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 161 161"
width="1em"
height="1em"
{...props}
>
<defs>
<style>{".cls-2{fill-rule:evenodd}"}</style>
</defs>
<g>
<g>
<circle
cx={80.5}
cy={80.5}
r={79}
style={{
fill: "#f5cc05",
}}
/>
<g>
<g>
<path
d="M88.67 114.33h2.91q6 0 7.87-1.89c1.22-1.25 1.83-3.9 1.83-7.93V93.89c0-4.46.65-7.7 1.93-9.73s3.51-3.43 6.67-4.2q-4.69-1.08-6.65-4.12c-1.3-2-2-5.28-2-9.77V55.44q0-6-1.83-7.93t-7.87-1.88h-2.86V39.5h2.65q10.65 0 14.24 3.15t3.59 12.62v10.29c0 4.28.77 7.24 2.29 8.87s4.3 2.44 8.32 2.44h2.74V83h-2.74q-6 0-8.32 2.49c-1.52 1.65-2.29 4.64-2.29 9v10.25q0 9.47-3.59 12.64t-14.24 3.12h-2.65Z"
className="cls-2"
/>
<path
d="M88.67 114.33h2.91q6 0 7.87-1.89c1.22-1.25 1.83-3.9 1.83-7.93V93.89c0-4.46.65-7.7 1.93-9.73s3.51-3.43 6.67-4.2q-4.69-1.08-6.65-4.12c-1.3-2-2-5.28-2-9.77V55.44q0-6-1.83-7.93t-7.87-1.88h-2.86V39.5h2.65q10.65 0 14.24 3.15t3.59 12.62v10.29c0 4.28.77 7.24 2.29 8.87s4.3 2.44 8.32 2.44h2.74V83h-2.74q-6 0-8.32 2.49c-1.52 1.65-2.29 4.64-2.29 9v10.25q0 9.47-3.59 12.64t-14.24 3.12h-2.65Z"
className="cls-2"
/>
</g>
<g>
<g>
<path
d="M38.5 85.15h37.33v7.58H38.5Zm0-17.88h37.33v7.49H38.5Z"
className="cls-2"
/>
<path
d="M38.5 85.15h37.33v7.58H38.5Zm0-17.88h37.33v7.49H38.5Z"
className="cls-2"
/>
</g>
</g>
</g>
</g>
</g>
</svg>
);
}

View File

@ -0,0 +1,45 @@
import { Handle, NodeProps, Position } from "reactflow";
export const ApiNode = ({
data,
sourcePosition = Position.Right,
isConnectable,
}: NodeProps) => (
<div
className={`
py-3 px-6 rounded bg-slate-900 text-white text-center
`}
>
<Handle
type="source"
position={sourcePosition}
isConnectable={isConnectable}
/>
<div className="text-xs bg-slate-300 text-slate-900 rounded px-1 absolute -top-1 left-1/2 -translate-x-1/2 flex items-center">
<span className="mr-1">API</span>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-3 h-3"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14.25 9.75L16.5 12l-2.25 2.25m-4.5 0L7.5 12l2.25-2.25M6 20.25h12A2.25 2.25 0 0020.25 18V6A2.25 2.25 0 0018 3.75H6A2.25 2.25 0 003.75 6v12A2.25 2.25 0 006 20.25z"
/>
</svg>
</div>
<div className="font-bold">{data?.label}</div>
<div className="flex justify-center items-center mt-2">
<div className="text-xs bg-foreground text-background rounded px-1">
<span>
<strong className="font-bold">{data.httpRoute.method}</strong>{" "}
{data.httpRoute.path}
</span>
</div>
</div>
</div>
);

View File

@ -0,0 +1,53 @@
import { Handle, NodeProps, Position } from "reactflow";
export const AppNode = ({
data,
isConnectable,
targetPosition = Position.Left,
sourcePosition = Position.Right,
}: NodeProps) => (
<div className="py-3 px-6 rounded bg-cyan-900 text-white text-center">
<Handle
type="target"
position={targetPosition}
isConnectable={isConnectable}
/>
<div className="text-xs bg-cyan-300 text-cyan-900 rounded px-1 absolute -top-1 left-1/2 -translate-x-1/2 flex items-center">
<span className="mr-1">App</span>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-3 h-3"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8.25 3v1.5M4.5 8.25H3m18 0h-1.5M4.5 12H3m18 0h-1.5m-15 3.75H3m18 0h-1.5M8.25 19.5V21M12 3v1.5m0 15V21m3.75-18v1.5m0 15V21m-9-1.5h10.5a2.25 2.25 0 002.25-2.25V6.75a2.25 2.25 0 00-2.25-2.25H6.75A2.25 2.25 0 004.5 6.75v10.5a2.25 2.25 0 002.25 2.25zm.75-12h9v9h-9v-9z"
/>
</svg>
</div>
<div className="font-bold">{data?.label}</div>
<Handle
type="source"
position={sourcePosition}
isConnectable={isConnectable}
/>
<div className="flex justify-center flex-col items-center mt-2 gap-2">
<div className="text-xs bg-foreground text-background rounded px-1">
<span>{data.db?.system || "SQLite"}</span>
</div>
{data.auth &&
data.auth.methods.map((method: string) => (
<div
className="text-xs bg-foreground text-background rounded px-1"
key={method}
>
<span>Auth: {method}</span>
</div>
))}
</div>
</div>
);

View File

@ -0,0 +1,48 @@
import { Handle, NodeProps, Position } from "reactflow";
export const EntityNode = ({
data,
isConnectable,
targetPosition = Position.Left,
sourcePosition = Position.Right,
}: NodeProps) => (
<div
className={`
py-3 px-6 rounded bg-yellow-900 text-white text-center
${data.isUserEntity ? "border-3 border-yellow-300" : ""}
`}
>
<Handle
type="source"
position={sourcePosition}
isConnectable={isConnectable}
/>
<div
className={`
text-xs bg-yellow-300 text-yellow-900 rounded px-1 absolute -top-1 left-1/2 -translate-x-1/2 flex items-center
`}
>
<span className="mr-1">Entity</span>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-3 h-3"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"
/>
</svg>
</div>
<div className="font-bold">{data?.label}</div>
<Handle
type="target"
position={targetPosition}
isConnectable={isConnectable}
/>
</div>
);

View File

@ -0,0 +1,40 @@
import { Handle, NodeProps, Position } from "reactflow";
export const JobNode = ({
data,
isConnectable,
sourcePosition = Position.Right,
}: NodeProps) => (
<div className="py-3 px-6 rounded bg-violet-900 text-white text-center">
<Handle
type="source"
position={sourcePosition}
isConnectable={isConnectable}
/>
<div className="text-xs bg-violet-300 text-violet-900 rounded px-1 absolute -top-1 left-1/2 -translate-x-1/2 flex items-center">
<span className="mr-1">Job</span>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-3 h-3"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19.5 12c0-1.232-.046-2.453-.138-3.662a4.006 4.006 0 00-3.7-3.7 48.678 48.678 0 00-7.324 0 4.006 4.006 0 00-3.7 3.7c-.017.22-.032.441-.046.662M19.5 12l3-3m-3 3l-3-3m-12 3c0 1.232.046 2.453.138 3.662a4.006 4.006 0 003.7 3.7 48.656 48.656 0 007.324 0 4.006 4.006 0 003.7-3.7c.017-.22.032-.441.046-.662M4.5 12l3 3m-3-3l-3 3"
/>
</svg>
</div>
<div className="font-bold">{data?.label}</div>
{data.schedule && (
<div className="flex justify-center items-center mt-2">
<div className="text-xs bg-foreground text-background rounded px-1">
<span>Schedule: {data.schedule}</span>
</div>
</div>
)}
</div>
);

View File

@ -0,0 +1,84 @@
import { Handle, NodeProps, Position } from "reactflow";
export const QueryNode = (props: NodeProps) => (
<OperationNode
{...props}
label={
<div className="flex items-center">
<span className="mr-1">Query</span>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-3 h-3"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z"
/>
</svg>
</div>
}
color="emerald"
/>
);
export const ActionNode = (props: NodeProps) => (
<OperationNode
{...props}
label={
<div className="flex items-center">
<span className="mr-1">Action</span>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-3 h-3"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M11.25 4.5l7.5 7.5-7.5 7.5m-6-15l7.5 7.5-7.5 7.5"
/>
</svg>
</div>
}
color="pink"
/>
);
export const OperationNode = ({
data,
isConnectable,
targetPosition = Position.Top,
sourcePosition = Position.Bottom,
label = "Operation",
color = "emerald",
}: NodeProps & {
label?: React.ReactNode;
color?: string;
}) => (
<div className={`py-3 px-6 rounded bg-${color}-900 text-white`}>
<Handle
type="target"
position={targetPosition}
isConnectable={isConnectable}
/>
<div
className={`text-xs bg-${color}-300 text-${color}-900 rounded px-1 absolute -top-1 left-1/2 -translate-x-1/2`}
>
{label}
</div>
<div className="font-bold">{data?.label}</div>
<Handle
type="source"
position={sourcePosition}
isConnectable={isConnectable}
/>
</div>
);

View File

@ -0,0 +1,40 @@
import { Handle, NodeProps, Position } from "reactflow";
export const PageNode = ({
data,
isConnectable,
targetPosition = Position.Right,
}: NodeProps) => (
<div className="py-3 px-6 rounded bg-sky-900 text-white">
<Handle
type="target"
position={targetPosition}
isConnectable={isConnectable}
/>
<div className="text-xs bg-sky-300 text-sky-900 rounded px-1 absolute -top-1 left-1/2 -translate-x-1/2 flex items-center">
<span className="mr-1">Page</span>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-3 h-3"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25"
/>
</svg>
</div>
<div className="font-bold">
{data?.label}
{data.authRequired ? (
<span className="ml-1" role="img" aria-label="Auth Required">
🔒
</span>
) : null}
</div>
</div>
);

View File

@ -0,0 +1,39 @@
import { Handle, NodeProps, Position } from "reactflow";
export const RouteNode = ({
data,
isConnectable,
sourcePosition = Position.Left,
targetPosition = Position.Right,
}: NodeProps) => (
<div className="py-3 px-6 rounded bg-rose-900 text-white text-center">
<Handle
type="target"
position={targetPosition}
isConnectable={isConnectable}
/>
<div className="text-xs bg-rose-300 text-rose-900 rounded px-1 absolute -top-1 left-1/2 -translate-x-1/2 flex items-center">
<span className="mr-1">Route</span>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-3 h-3"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.042 21.672L13.684 16.6m0 0l-2.51 2.225.569-9.47 5.227 7.917-3.286-.672zM12 2.25V4.5m5.834.166l-1.591 1.591M20.25 10.5H18M7.757 14.743l-1.59 1.59M6 10.5H3.75m4.007-4.243l-1.59-1.59"
/>
</svg>
</div>
<div className="font-bold">{data?.label}</div>
<Handle
type="source"
position={sourcePosition}
isConnectable={isConnectable}
/>
</div>
);

View File

@ -0,0 +1,157 @@
import { Position, Node, Edge } from "reactflow";
let id = 1;
export function generateId() {
return `${id++}`;
}
type AnyData = {
[key: string]: unknown;
};
export function createPageNode(
id: string,
name: string,
data: AnyData,
selectedNode: Node | null
) {
return {
id,
type: "pageNode",
data: { label: name, ...data },
position: { x: 0, y: 0 },
targetPosition: Position.Left,
selected: selectedNode?.id === id,
} satisfies Node;
}
export function createActionNode(
id: string,
name: string,
data: AnyData,
selectedNode: Node | null
) {
return {
id,
data: { label: name, ...data },
position: { x: 0, y: 0 },
sourcePosition: Position.Right,
type: "actionNode",
selected: selectedNode?.id === id,
} satisfies Node;
}
export function createQueryNode(
id: string,
name: string,
data: AnyData,
selectedNode: Node | null
) {
return {
id,
data: { label: name, ...data },
position: { x: 0, y: 0 },
sourcePosition: Position.Right,
type: "queryNode",
selected: selectedNode?.id === id,
} satisfies Node;
}
export function createRouteNode(
id: string,
name: string,
data: AnyData,
selectedNode: Node | null
) {
return {
id,
data: { label: name, ...data },
position: { x: 0, y: 0 },
sourcePosition: Position.Right,
targetPosition: Position.Left,
type: "routeNode",
selected: selectedNode?.id === id,
} satisfies Node;
}
export function createEntityNode(
id: string,
name: string,
isUserEntity: boolean,
data: AnyData,
selectedNode: Node | null
) {
return {
id,
data: { label: name, isUserEntity, ...data },
position: { x: 0, y: 0 },
type: "entityNode",
targetPosition: Position.Left,
sourcePosition: Position.Right,
selected: selectedNode?.id === id,
} satisfies Node;
}
export function createApiNode(
id: string,
name: string,
data: AnyData,
selectedNode: Node | null
) {
return {
id,
data: { label: name, ...data },
position: { x: 0, y: 0 },
type: "apiNode",
sourcePosition: Position.Right,
selected: selectedNode?.id === id,
} satisfies Node;
}
export function createJobNode(
id: string,
name: string,
data: AnyData,
selectedNode: Node | null
) {
return {
id,
data: { label: name, ...data },
position: { x: 0, y: 0 },
type: "jobNode",
sourcePosition: Position.Right,
selected: selectedNode?.id === id,
} satisfies Node;
}
export function createAppNode(
id: string,
name: string,
data: AnyData,
selectedNode: Node | null
) {
return {
id,
data: { label: name, ...data },
position: { x: 0, y: 0 },
type: "appNode",
targetPosition: Position.Left,
sourcePosition: Position.Right,
selected: selectedNode?.id === id,
} satisfies Node;
}
export function createEdge(
source: string,
target: string,
selectedNode: Node | null
) {
return {
id: `${source}-${target}`,
source,
target,
animated: true,
selected: selectedNode?.id === source || selectedNode?.id === target,
} satisfies Edge;
}

View File

@ -0,0 +1,8 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
.flow-container {
height: calc(100vh - 4rem);
width: 100vw;
}

View File

@ -0,0 +1,18 @@
import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import App from "./App.tsx";
import { NextUIProvider } from "@nextui-org/react";
import { ReactFlowProvider } from "reactflow";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<ReactFlowProvider>
<NextUIProvider>
<div className="dark text-foreground bg-background">
<App />
</div>
</NextUIProvider>
</ReactFlowProvider>
</React.StrictMode>
);

View File

@ -0,0 +1,44 @@
import { useEffect, useState } from "react";
import { io } from "socket.io-client";
import { Data } from "./types";
export const socket = io("http://localhost:4000");
export function useSocket() {
const [isConnected, setIsConnected] = useState(false);
const [data, setData] = useState<Data | null>(null);
function onData(data: string) {
try {
setData(JSON.parse(data) as Data);
} catch (e: unknown) {
console.error(e);
}
}
function onConnect() {
setIsConnected(true);
}
function onDisconnect() {
setIsConnected(false);
}
useEffect(() => {
socket.on("connect", onConnect);
socket.on("disconnect", onDisconnect);
socket.on("data", onData);
return () => {
socket.off("data", onData);
socket.off("connect", onConnect);
socket.off("disconnect", onDisconnect);
};
}, []);
return {
data,
socket,
isConnected,
};
}

View File

@ -0,0 +1,54 @@
export type Data = {
entities: {
name: string;
}[];
operations: {
entities: {
name: string;
}[];
name: string;
type: "query" | "action";
auth: string;
}[];
apis: {
entities: {
name: string;
}[];
httpRoute: {
method: string;
path: string;
};
name: string;
auth: string;
}[];
jobs: {
schedule: string;
entities: {
name: string;
}[];
name: string;
}[];
pages: {
authRequired: string;
name: string;
}[];
routes: {
name: string;
toPage: {
name: string;
};
path: string;
}[];
app: {
name: string;
auth: {
userEntity: {
name: string;
};
methods: string[];
};
db: {
system: string;
};
};
};

View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -0,0 +1,23 @@
import { nextui } from "@nextui-org/react";
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
"./node_modules/@nextui-org/theme/dist/**/*.{js,ts,jsx,tsx}",
],
safelist: [
"bg-pink-900",
"bg-pink-300",
"text-pink-900",
"bg-emerald-900",
"bg-emerald-300",
"text-emerald-900",
],
theme: {
extend: {},
},
darkMode: "class",
plugins: [nextui()],
};

View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
})

View File

@ -0,0 +1,7 @@
{
"watch": [
"./src/**/*.ts",
".env"
],
"exec": "ts-node -r dotenv/config"
}

1903
waspc/packages/studio/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,35 @@
{
"name": "wasp-studio-server",
"version": "1.0.0",
"description": "",
"type": "module",
"main": "index.js",
"scripts": {
"start": "tsc --noEmit && ts-node -r dotenv/config ./src/index.ts",
"dev": "nodemon ./src/index.ts",
"build:client": "npm --prefix ./client install && npm --prefix ./client run copy",
"build": "npm run build:client && rm -rf dist && tsc && cp -r ./public ./dist/public"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@swc/core": "^1.3.52",
"@swc/helpers": "^0.5.0",
"@tsconfig/node18": "^1.0.1",
"@types/node": "^18.15.12",
"nodemon": "^2.0.22",
"regenerator-runtime": "^0.13.11",
"ts-node": "^10.9.1",
"typescript": "^5.0.2"
},
"dependencies": {
"@fastify/cors": "^8.3.0",
"@fastify/static": "^6.11.2",
"commander": "^11.0.0",
"dotenv": "^16.0.3",
"fastify": "^4.23.2",
"fastify-socket.io": "^4.0.0",
"socket.io": "^4.7.2"
}
}

View File

View File

@ -0,0 +1,66 @@
import * as fs from "fs";
import Fastify from "fastify";
import FastifySocketIO from "fastify-socket.io";
import FastifyStatic from "@fastify/static";
import cors from "@fastify/cors";
import { Command } from "commander";
function getUrlFromRelativePathToCwd(path: string) {
return new URL(path, `file://${process.cwd()}/`);
}
const program = new Command();
program
.requiredOption("-d, --data-file <path>", "Path to data file")
.parse(process.argv);
const options = program.opts<{
dataFile: string;
}>();
const fastify = Fastify({
logger: true,
});
fastify.register(FastifySocketIO, {
cors: {
origin: "*",
},
});
fastify.register(cors, {
origin: true,
});
fastify.register(FastifyStatic, {
root: new URL("./public", import.meta.url).pathname,
});
const pathToDataFile = getUrlFromRelativePathToCwd(options.dataFile);
function readFile() {
return fs.readFileSync(pathToDataFile, "utf8");
}
let data = readFile();
fs.watch(pathToDataFile, () => {
data = readFile();
fastify.io.emit("data", data);
});
fastify.ready((err) => {
if (err) throw err;
fastify.io.on("connection", (socket) => {
console.log("Client connected");
socket.emit("data", data);
socket.on("disconnect", () => {
console.log("Client disconnected");
});
});
});
try {
await fastify.listen({ port: 4000 });
} catch (err) {
fastify.log.error(err);
process.exit(1);
}

View File

@ -0,0 +1,14 @@
{
"extends": "@tsconfig/node18/tsconfig.json",
"compilerOptions": {
"module": "esnext",
"target": "es2017",
"outDir": "dist",
"rootDir": "src",
},
"ts-node": {
"esm": true,
"swc": true,
},
"include": ["src"]
}

View File

@ -9,6 +9,7 @@ module Wasp.AppSpec
refName,
getActions,
getQueries,
getOperations,
getApis,
getEntities,
getPages,
@ -39,6 +40,8 @@ import Wasp.AppSpec.Entity (Entity)
import Wasp.AppSpec.ExternalCode (SourceExternalCodeDir)
import qualified Wasp.AppSpec.ExternalCode as ExternalCode
import Wasp.AppSpec.Job (Job)
import Wasp.AppSpec.Operation (Operation)
import qualified Wasp.AppSpec.Operation as AS.Operation
import Wasp.AppSpec.Page (Page)
import Wasp.AppSpec.Query (Query)
import Wasp.AppSpec.Route (Route)
@ -94,6 +97,11 @@ getQueries = getDecls
getActions :: AppSpec -> [(String, Action)]
getActions = getDecls
getOperations :: AppSpec -> [Operation]
getOperations spec =
map (uncurry AS.Operation.QueryOp) (getQueries spec)
<> map (uncurry AS.Operation.ActionOp) (getActions spec)
getApis :: AppSpec -> [(String, Api)]
getApis = getDecls

View File

@ -24,6 +24,7 @@ import Wasp.Node.Version (getAndCheckNodeVersion)
data Package
= DeployPackage
| TsInspectPackage
| WaspStudioPackage
data PackagesDir
@ -37,6 +38,7 @@ packagesDirInDataDir = [reldir|packages|]
packageDirInPackagesDir :: Package -> Path' (Rel PackagesDir) (Dir PackageDir)
packageDirInPackagesDir DeployPackage = [reldir|deploy|]
packageDirInPackagesDir TsInspectPackage = [reldir|ts-inspect|]
packageDirInPackagesDir WaspStudioPackage = [reldir|studio|]
scriptInPackageDir :: Path' (Rel PackageDir) (File PackageScript)
scriptInPackageDir = [relfile|dist/index.js|]

View File

@ -0,0 +1,34 @@
module Wasp.Project.Studio
( startStudio,
)
where
import System.Exit (ExitCode (..))
import qualified System.Process as P
import Wasp.NodePackageFFI (Package (WaspStudioPackage), getPackageProcessOptions)
startStudio ::
-- | Path to the data JSON file.
FilePath ->
-- | All arguments from the Wasp CLI.
IO (Either String ())
startStudio pathToDataFile = do
let startStudioArgs = ["--data-file", pathToDataFile]
cp <- getPackageProcessOptions WaspStudioPackage startStudioArgs
-- Set up the process so that it:
-- - Inherits handles from the waspc process (it will print and read from stdin/out/err)
-- - Delegates Ctrl+C: when waspc receives Ctrl+C while this process is running,
-- it will properly shut-down the child process.
-- See https://hackage.haskell.org/package/process-1.6.17.0/docs/System-Process.html#g:4.
let cpInheritHandles =
cp
{ P.std_in = P.Inherit,
P.std_out = P.Inherit,
P.std_err = P.Inherit,
P.delegate_ctlc = True
}
exitCode <- P.withCreateProcess cpInheritHandles $ \_ _ _ ph -> P.waitForProcess ph
case exitCode of
ExitSuccess -> return $ Right ()
ExitFailure code -> return $ Left $ "Studio command failed with exit code: " ++ show code

View File

@ -62,6 +62,12 @@ data-files:
packages/ts-inspect/dist/**/*.js
packages/ts-inspect/package.json
packages/ts-inspect/package-lock.json
packages/studio/dist/**/*.js
packages/studio/dist/**/*.html
packages/studio/dist/**/*.css
packages/studio/dist/**/*.png
packages/studio/package.json
packages/studio/package-lock.json
data-dir: data/
source-repository head
@ -320,6 +326,7 @@ library
Wasp.Project.Deployment
Wasp.Project.Env
Wasp.Project.WebApp
Wasp.Project.Studio
Wasp.Project.Vite
Wasp.NpmDependency
Wasp.Psl.Ast.Model
@ -415,6 +422,7 @@ library cli-lib
, filepath
, time
, aeson
, aeson-pretty
, mtl
, async
, exceptions
@ -424,6 +432,7 @@ library cli-lib
, optparse-applicative ^>=0.17.0.0
, path
, path-io
, pretty-simple ^>= 4.1.2.0
, process
, strong-path
, text
@ -466,6 +475,7 @@ library cli-lib
Wasp.Cli.Command.Deploy
Wasp.Cli.Command.Dockerfile
Wasp.Cli.Command.Info
Wasp.Cli.Command.Studio
Wasp.Cli.Command.Require
Wasp.Cli.Command.Start
Wasp.Cli.Command.Start.Db