diff --git a/.changeset/cyan-animals-walk.md b/.changeset/cyan-animals-walk.md new file mode 100644 index 000000000..614312a84 --- /dev/null +++ b/.changeset/cyan-animals-walk.md @@ -0,0 +1,5 @@ +--- +"@workflow/cli": patch +--- + +Add cli init command for setting up workflows diff --git a/packages/cli/package.json b/packages/cli/package.json index 28f6ee595..e0e87e759 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -42,12 +42,13 @@ "@workflow/tsconfig": "workspace:*" }, "dependencies": { + "@clack/prompts": "1.0.0-alpha.6", "@oclif/core": "^4.0.0", "@oclif/plugin-help": "^6.0.0", "@swc/core": "1.11.24", - "@workflow/swc-plugin": "workspace:*", - "@workflow/errors": "workspace:*", "@workflow/core": "workspace:*", + "@workflow/errors": "workspace:*", + "@workflow/swc-plugin": "workspace:*", "@workflow/web": "workspace:*", "@workflow/world": "workspace:*", "@workflow/world-local": "workspace:*", diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 99aa25c6e..23f635e74 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -1,8 +1,26 @@ +import { exec } from 'node:child_process'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { + cancel, + confirm, + intro, + isCancel, + log, + outro, + select, + tasks, + text, +} from '@clack/prompts'; import { Flags } from '@oclif/core'; +import chalk from 'chalk'; import { BaseCommand } from '../base.js'; +const execAsync = promisify(exec); + export default class Init extends BaseCommand { - static description = 'Initialize a new workflow project (Coming soon)'; + static description = 'Initialize a new workflow project'; static examples = [ '$ workflow init', @@ -13,7 +31,8 @@ export default class Init extends BaseCommand { static flags = { template: Flags.string({ description: 'template to use', - options: ['standalone', 'nextjs', 'express'], + options: ['next', 'hono', 'nitro'], + default: 'next', }), yes: Flags.boolean({ char: 'y', @@ -21,31 +40,239 @@ export default class Init extends BaseCommand { }), }; + /** + * + * @returns true if the current directory is a Next.js app + */ + private isNextApp(): boolean { + const configFiles = [ + 'next.config.js', + 'next.config.mjs', + 'next.config.ts', + 'next.config.cjs', + ]; + + return configFiles.some((file) => + existsSync(path.join(process.cwd(), file)) + ); + } + public async run(): Promise { - const { flags } = await this.parse(Init); - - this.logInfo('🚧 Project initialization is coming soon!'); - this.logInfo(''); - this.logInfo('This command will provide:'); - this.logInfo('• Interactive project setup'); - this.logInfo('• Multiple project templates'); - this.logInfo('• Automatic configuration file generation'); - this.logInfo('• Dependency installation'); - this.logInfo('• Example workflows and steps'); - this.logInfo('Or we might do a create-workflow-app package'); - this.logInfo(''); - - if (flags.template) { - this.logInfo(`Selected template: ${flags.template}`); + const { args, flags } = await this.parse(Init); + + intro('workflow init'); + + let template = flags.template ?? 'next'; + + const isNextApp = this.isNextApp(); + + let createNewProject = true; + + if (isNextApp) { + log.info('Detected Next.js app'); + + createNewProject = (await confirm({ + message: 'Create a new project?', + initialValue: true, + })) as boolean; + + if (isCancel(createNewProject)) { + cancel('Cancelled workflow setup'); + return; + } + } + + let projectName = 'my-workflow-app'; + + if (createNewProject) { + projectName = + args.projectName || + ((await text({ + message: 'What is your project name?', + placeholder: 'my-workflow-app', + defaultValue: 'my-workflow-app', + })) as string); + + if (isCancel(projectName)) { + cancel('Cancelled workflow setup'); + return; + } + + template = (await select({ + message: 'What template would you like to use?', + options: [ + { label: 'Hono', value: 'hono', hint: 'via Nitro v3' }, + { label: 'Next.js', value: 'next' }, + { + label: 'Nitro', + value: 'nitro', + }, + ], + initialValue: 'next', + })) as string; + + if (isCancel(template)) { + cancel('Cancelled workflow setup'); + return; + } } - if (flags.yes) { - this.logInfo('Auto-accept mode enabled'); + const useTsPlugin = + flags.yes || + ((await confirm({ + message: `Configure TypeScript intellisense? ${chalk.dim('(recommended)')}`, + initialValue: true, + })) as boolean); + + if (isCancel(useTsPlugin)) { + cancel('Cancelled workflow setup'); + return; } - this.logInfo(''); - this.logInfo( - 'For now, manually create your workflow files in a ./workflows directory' + const projectPath = createNewProject + ? path.join(process.cwd(), projectName) + : process.cwd(); + + await tasks([ + { + title: + template === 'next' ? 'Creating Next.js app' : 'Creating Hono app', + enabled: createNewProject, + task: async (message) => { + message('Creating a new Next.js app'); + await execAsync(`npx create-next-app@latest ${projectName} --yes`); + return `Created Next.js app in ${chalk.cyan(projectPath)}`; + }, + }, + { + title: 'Installing `workflow` package', + task: async (message) => { + message(`Installing \`workflow\` package`); + await execAsync(`cd ${projectPath} && pnpm add workflow`); + return `Installed \`workflow\` package`; + }, + }, + { + title: 'Configuring TypeScript intellisense', + enabled: useTsPlugin, + task: async (message) => { + message(`Configuring TypeScript intellisense`); + const tsConfig = JSON.parse( + readFileSync(path.join(projectPath, 'tsconfig.json'), 'utf8') + ); + tsConfig.compilerOptions.plugins.push({ + name: 'workflow', + }); + writeFileSync( + path.join(projectPath, 'tsconfig.json'), + JSON.stringify(tsConfig, null, 2) + ); + return 'Configured TypeScript intellisense'; + }, + }, + { + title: 'Configuring Next.js config', + task: async (message) => { + message(`Configuring Next.js config`); + let nextConfig = readFileSync( + path.join(projectPath, 'next.config.ts'), + 'utf8' + ); + nextConfig = nextConfig.replace( + /import type { NextConfig } from "next";/g, + "import type { NextConfig } from 'next';\nimport { withWorkflow } from 'workflow/next';" + ); + nextConfig = nextConfig.replace( + /export default nextConfig;/g, + 'export default withWorkflow(nextConfig);' + ); + writeFileSync(path.join(projectPath, 'next.config.ts'), nextConfig); + return 'Configured Next.js config'; + }, + }, + { + title: 'Creating example workflow', + task: async (message) => { + message(`Creating example workflow`); + const workflowsPath = path.join(projectPath, 'workflows'); + mkdirSync(workflowsPath); + const workflowContent = `import { FatalError, sleep } from "workflow"; + +export async function handleUserSignup(email: string) { + "use workflow"; + + const user = await createUser(email); + await sendWelcomeEmail(user); + + await sleep("5s"); // Pause for 5s - doesn't consume any resources + await sendOnboardingEmail(user); + + return { userId: user.id, status: "onboarded" }; +} + +async function createUser(email: string) { + "use step"; + console.log(\`Creating user with email: \${email}\`); + // Full Node.js access - database calls, APIs, etc. + return { id: crypto.randomUUID(), email }; +} +async function sendWelcomeEmail(user: { id: string; email: string; }) { + "use step"; + console.log(\`Sending welcome email to user: \${user.id}\`); + if (Math.random() < 0.3) { + // By default, steps will be retried for unhandled errors + throw new Error("Retryable!"); + } +} +async function sendOnboardingEmail(user: { id: string; email: string}) { + "use step"; + if (!user.email.includes("@")) { + // To skip retrying, throw a FatalError instead + throw new FatalError("Invalid Email"); + } + console.log(\`Sending onboarding email to user: \${user.id}\`); +}`; + writeFileSync( + path.join(workflowsPath, 'user-signup.ts'), + workflowContent + ); + return `Created example workflow in ${chalk.cyan(path.join(workflowsPath, 'user-signup.ts'))}`; + }, + }, + { + title: 'Creating API route handler', + task: async (message) => { + message(`Creating API route handler`); + const apiPath = path.join(projectPath, 'app', 'api', 'signup'); + mkdirSync(apiPath, { recursive: true }); + writeFileSync( + path.join(apiPath, 'route.ts'), + `import { start } from 'workflow/api'; +import { handleUserSignup } from "@/workflows/user-signup"; +import { NextResponse } from "next/server"; + +export async function POST(request: Request) { + const { email } = await request.json(); + + // Executes asynchronously and doesn't block your app + await start(handleUserSignup, [email]); + + return NextResponse.json({ + message: "User signup workflow started", + }); +}` + ); + return `Created API route handler in ${chalk.cyan(path.join(projectPath, 'app', 'api', 'signup', 'route.ts'))}`; + }, + }, + ]); + + cancel('Cancelled workflow setup'); + + outro( + `${chalk.green('Success!')} Next steps: + Run ${chalk.dim(`${createNewProject ? `cd ${projectName} && ` : ''}npm run dev`)} to start the development server + Trigger the workflow: ${chalk.dim('curl -X POST --json \'{"email":"hello@example.com"}\' http://localhost:3000/api/signup')}` ); } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 31101e688..9daade475 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -271,6 +271,9 @@ importers: packages/cli: dependencies: + '@clack/prompts': + specifier: 1.0.0-alpha.6 + version: 1.0.0-alpha.6 '@oclif/core': specifier: ^4.0.0 version: 4.5.1 @@ -1520,6 +1523,12 @@ packages: '@chevrotain/utils@11.0.3': resolution: {integrity: sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==} + '@clack/core@1.0.0-alpha.6': + resolution: {integrity: sha512-eG5P45+oShFG17u9I1DJzLkXYB1hpUgTLi32EfsMjSHLEqJUR8BOBCVFkdbUX2g08eh/HCi6UxNGpPhaac1LAA==} + + '@clack/prompts@1.0.0-alpha.6': + resolution: {integrity: sha512-75NCtYOgDHVBE2nLdKPTDYOaESxO0GLAKC7INREp5VbS988Xua1u+588VaGlcvXiLc/kSwc25Cd+4PeTSpY6QQ==} + '@cloudflare/kv-asset-handler@0.4.0': resolution: {integrity: sha512-+tv3z+SPp+gqTIcImN9o0hqE9xyfQjI1XD9pL6NuKjua9B1y7mNYv0S9cP+QEbA4ppVgGZEmKOvHX5G5Ei1CVA==} engines: {node: '>=18.0.0'} @@ -10396,6 +10405,17 @@ snapshots: '@chevrotain/utils@11.0.3': {} + '@clack/core@1.0.0-alpha.6': + dependencies: + picocolors: 1.1.1 + sisteransi: 1.0.5 + + '@clack/prompts@1.0.0-alpha.6': + dependencies: + '@clack/core': 1.0.0-alpha.6 + picocolors: 1.1.1 + sisteransi: 1.0.5 + '@cloudflare/kv-asset-handler@0.4.0': dependencies: mime: 3.0.0