diff --git a/README.md b/README.md index 9a3ead5..8e4c767 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Create C1 App +# Create C1 App A powerful CLI tool that setups Generative UI examples with C1 by Thesys @@ -6,9 +6,6 @@ A powerful CLI tool that setups Generative UI examples with C1 by Thesys ✨ **Interactive Project Setup** - - - ## Quick Start ```bash @@ -24,14 +21,15 @@ npx create-c1-app my-thesys-project --template template-c1-component-next --api- ## CLI Options -| Option | Alias | Description | Default | -|--------|-------|-------------|---------| -| `[project-name]` | | Name of the project to create (positional argument) | Interactive prompt | -| `--project-name` | `-n` | Name of the project to create (alternative to positional argument) | Interactive prompt | -| `--template` | `-t` | Next.js template to use (`template-c1-component-next` or `template-c1-next`) | Interactive prompt | -| `--api-key` | `-k` | Thesys API key to use for the project | Interactive prompt | -| `--debug` | `-d` | Enable debug logging | `false` | -| `--disable-telemetry` | | Disable anonymous telemetry for current session | `false` | +| Option | Alias | Description | Default | +| --------------------- | ----- | ------------------------------------------------------------------------------------------------------ | ----------------------- | +| `[project-name]` | | Name of the project to create (positional argument) | Interactive prompt | +| `--project-name` | `-n` | Name of the project to create (alternative to positional argument) | Interactive prompt | +| `--template` | `-t` | Next.js template to use (`template-c1-component-next` or `template-c1-next`) | Interactive prompt | +| `--api-key` | `-k` | Thesys API key to use for the project | Interactive prompt | +| `--debug` | `-d` | Enable debug logging | `false` | +| `--non-interactive` | | Run without prompts; fails fast if required options are missing. Auto-enabled in CI or non-TTY shells. | `false` (auto-detected) | +| `--disable-telemetry` | | Disable anonymous telemetry for current session | `false` | ## Usage Examples @@ -57,6 +55,29 @@ npx create-c1-app my-project --template template-c1-next --api-key your-api-key- npx create-c1-app --api-key your-api-key-here ``` +### Non-Interactive / CI / Agent Usage + +When running in CI pipelines, automated scripts, or AI agent shells (e.g. Cursor, Copilot, Devin), interactive prompts will hang. The CLI supports a fully non-interactive mode: + +```bash +# Explicit flag +npx create-c1-app my-project --template template-c1-next --api-key YOUR_API_KEY --non-interactive + +# Or just provide all required flags — non-interactive mode is auto-detected +# when stdin is not a TTY (pipes, agents, CI) or when CI env vars are set +npx create-c1-app my-project --template template-c1-next --api-key YOUR_API_KEY +``` + +**Auto-detection:** The CLI automatically enables non-interactive mode when: + +- `stdin` is not a TTY (piped input, background process, agent shell) + +**Behavior in non-interactive mode:** + +- `--api-key` is **required** (OAuth browser flow is skipped) +- `--project-name` defaults to `my-c1-app` if not provided +- `--template` defaults to `template-c1-next` if not provided +- The CLI will **fail immediately** with a clear error if required options are missing, instead of hanging on a prompt ## Development @@ -70,7 +91,6 @@ pnpm run build pnpm link ``` - ## Authentication Options Create C1 App supports two authentication methods: @@ -84,6 +104,7 @@ npx create-c1-app ``` This method will: + - Open your browser for secure authentication - Generate an API key automatically after successful login - Store the API key in your project's `.env` file @@ -119,6 +140,7 @@ To get an API key manually: ### Common Issues **Error: "Project directory already exists"** + ```bash # Choose a different name or remove the existing directory rm -rf existing-project-name @@ -126,12 +148,14 @@ npx create-c1-app ``` **Error: "Failed to download template"** + ```bash # Check your internet connection and try again npx create-c1-app ``` **Error: "Failed to install dependencies"** + ```bash # Navigate to your project and install manually cd your-project-name diff --git a/package.json b/package.json index 7d585fb..e50e026 100644 --- a/package.json +++ b/package.json @@ -1,69 +1,69 @@ { - "name": "create-c1-app", - "version": "1.1.6", - "description": "A CLI tool that creates C1 projects with API authentication", - "type": "module", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "bin": { - "create-c1-app": "bin/create-c1-app.js" - }, - "scripts": { - "build": "tsc", - "dev": "node --loader ts-node/esm src/index.ts --debug --disable-telemetry", - "start": "node bin/create-c1-app.js", - "prepublishOnly": "npm run build", - "lint": "eslint 'src/**/*.ts' 'bin/create-c1-app.js'", - "lint:fix": "eslint 'src/**/*.ts' 'bin/create-c1-app.js' --fix" - }, - "keywords": [ - "generative-ui", - "examples" - ], - "author": "engineering@thesys.dev", - "license": "MIT", - "dependencies": { - "@inquirer/prompts": "^7.8.4", - "chalk": "^4.1.2", - "dotenv": "^16.3.1", - "execa": "^5.1.1", - "nanoid": "^5.1.5", - "open": "^10.2.0", - "openid-client": "^6.8.1", - "ora": "^5.4.1", - "posthog-node": "^5.8.4", - "unzipper": "^0.10.14", - "validate-npm-package-name": "^5.0.0", - "yargs": "^17.7.2" - }, - "devDependencies": { - "@types/jest": "^29.5.8", - "@types/node": "^20.10.0", - "@types/unzipper": "^0.10.9", - "@types/validate-npm-package-name": "^4.0.2", - "@types/yargs": "^17.0.32", - "@typescript-eslint/eslint-plugin": "^6.13.1", - "@typescript-eslint/parser": "^6.13.1", - "eslint": "^9.0.0", - "eslint-config-standard-with-typescript": "^40.0.0", - "eslint-plugin-import": "^2.29.0", - "eslint-plugin-n": "^16.4.0", - "eslint-plugin-promise": "^6.1.1", - "ts-node": "^10.9.1", - "typescript": "^5.3.2" - }, - "engines": { - "node": ">=20.9.0" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/thesysdev/create-c1-app.git" - }, - "bugs": { - "url": "https://github.com/thesysdev/create-c1-app/issues" - }, - "homepage": "https://github.com/thesysdev/create-c1-app#readme", - "publishConfig": { - "access": "public" - } + "name": "create-c1-app", + "version": "1.2.6", + "description": "A CLI tool that creates C1 projects with API authentication", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "bin": { + "create-c1-app": "bin/create-c1-app.js" + }, + "scripts": { + "build": "tsc", + "dev": "node --loader ts-node/esm src/index.ts --debug --disable-telemetry", + "start": "node bin/create-c1-app.js", + "prepublishOnly": "npm run build", + "lint": "eslint 'src/**/*.ts' 'bin/create-c1-app.js'", + "lint:fix": "eslint 'src/**/*.ts' 'bin/create-c1-app.js' --fix" + }, + "keywords": [ + "generative-ui", + "examples" + ], + "author": "engineering@thesys.dev", + "license": "MIT", + "dependencies": { + "@inquirer/prompts": "^7.8.4", + "chalk": "^4.1.2", + "dotenv": "^16.3.1", + "execa": "^5.1.1", + "nanoid": "^5.1.5", + "open": "^10.2.0", + "openid-client": "^6.8.1", + "ora": "^5.4.1", + "posthog-node": "^5.8.4", + "unzipper": "^0.10.14", + "validate-npm-package-name": "^5.0.0", + "yargs": "^17.7.2" + }, + "devDependencies": { + "@types/jest": "^29.5.8", + "@types/node": "^20.10.0", + "@types/unzipper": "^0.10.9", + "@types/validate-npm-package-name": "^4.0.2", + "@types/yargs": "^17.0.32", + "@typescript-eslint/eslint-plugin": "^6.13.1", + "@typescript-eslint/parser": "^6.13.1", + "eslint": "^9.0.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "eslint-plugin-import": "^2.29.0", + "eslint-plugin-n": "^16.4.0", + "eslint-plugin-promise": "^6.1.1", + "ts-node": "^10.9.1", + "typescript": "^5.3.2" + }, + "engines": { + "node": ">=20.9.0" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/thesysdev/create-c1-app.git" + }, + "bugs": { + "url": "https://github.com/thesysdev/create-c1-app/issues" + }, + "homepage": "https://github.com/thesysdev/create-c1-app#readme", + "publishConfig": { + "access": "public" + } } diff --git a/src/index.ts b/src/index.ts index da5294b..58966ee 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,542 +1,645 @@ -import { input } from '@inquirer/prompts' -import yargs from 'yargs' -import { hideBin } from 'yargs/helpers' -import { createRequire } from 'module' -import logger from './utils/logger.js' -import SpinnerManager from './utils/spinner.js' -import * as Validator from './utils/validation.js' -import { type CLIOptions, type CreateC1AppConfig, type AuthenticationResult } from './types/index.js' -import { ProjectGenerator } from './generators/project.js' -import { EnvironmentManager } from './env/envManager.js' -import telemetry from './utils/telemetry.js' -import { fetchUserInfo } from 'openid-client' -import Authenticator from './auth/authenticator.js' +import { input } from "@inquirer/prompts"; +import yargs from "yargs"; +import { hideBin } from "yargs/helpers"; +import { createRequire } from "module"; +import logger from "./utils/logger.js"; +import SpinnerManager from "./utils/spinner.js"; +import * as Validator from "./utils/validation.js"; +import { + type CLIOptions, + type CreateC1AppConfig, + type AuthenticationResult, +} from "./types/index.js"; +import { ProjectGenerator } from "./generators/project.js"; +import { EnvironmentManager } from "./env/envManager.js"; +import telemetry from "./utils/telemetry.js"; +import { fetchUserInfo } from "openid-client"; +import Authenticator from "./auth/authenticator.js"; // Load package.json for version info (ESM workaround) -const require = createRequire(import.meta.url) -const packageJson = require('../package.json') - -const THESYS_API_URL = 'https://api.app.thesys.dev' -const THESYS_ISSUER_URL = 'https://api.app.thesys.dev/oidc' -const THESYS_CLIENT_ID = 'create-c1-app' +const require = createRequire(import.meta.url); +const packageJson = require("../package.json"); +const THESYS_API_URL = "https://api.app.thesys.dev"; +const THESYS_ISSUER_URL = "https://api.app.thesys.dev/oidc"; +const THESYS_CLIENT_ID = "create-c1-app"; // HTTP request helper function -async function makeHttpRequest(url: string, headers?: Record, data?: string): Promise<{ statusCode: number; body: string }> { - const fetchOptions: RequestInit = { - method: data ? 'POST' : 'GET', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - ...headers - } - } - - if (data) { - fetchOptions.body = data - } - - try { - const response = await fetch(url, fetchOptions) - const body = await response.text() - - return { - statusCode: response.status, - body - } - } catch (error) { - throw new Error(`HTTP request failed: ${error instanceof Error ? error.message : 'Unknown error'}`) - } +async function makeHttpRequest( + url: string, + headers?: Record, + data?: string, +): Promise<{ statusCode: number; body: string }> { + const fetchOptions: RequestInit = { + method: data ? "POST" : "GET", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + ...headers, + }, + }; + + if (data) { + fetchOptions.body = data; + } + + try { + const response = await fetch(url, fetchOptions); + const body = await response.text(); + + return { + statusCode: response.status, + body, + }; + } catch (error) { + throw new Error( + `HTTP request failed: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } } // Check Node.js version before doing anything else function checkNodeVersion(): void { - const nodeVersion = process.version - const majorVersion = parseInt(nodeVersion.slice(1).split('.')[0]) - const minorVersion = parseInt(nodeVersion.slice(1).split('.')[1]) - - // Check if version is greater than 20.19.0 - const isVersionSupported = - majorVersion > 20 || - (majorVersion === 20 && minorVersion >= 9) - - if (!isVersionSupported) { - console.error(`āŒ Node.js version ${nodeVersion} is not supported.`) - console.error(`šŸ“‹ This package requires Node.js version >= 20.9.0`) - console.error(`šŸ”„ Please upgrade your Node.js version and try again.`) - console.error(``) - console.error(`šŸ’” You can download the latest Node.js from: https://nodejs.org/`) - process.exit(1) - } + const nodeVersion = process.version; + const majorVersion = parseInt(nodeVersion.slice(1).split(".")[0]); + const minorVersion = parseInt(nodeVersion.slice(1).split(".")[1]); + + // Check if version is greater than 20.19.0 + const isVersionSupported = + majorVersion > 20 || (majorVersion === 20 && minorVersion >= 9); + + if (!isVersionSupported) { + console.error(`āŒ Node.js version ${nodeVersion} is not supported.`); + console.error(`šŸ“‹ This package requires Node.js version >= 20.9.0`); + console.error(`šŸ”„ Please upgrade your Node.js version and try again.`); + console.error(``); + console.error( + `šŸ’” You can download the latest Node.js from: https://nodejs.org/`, + ); + process.exit(1); + } } -const TOTAL_STEPS = 3 -class CreateC1App { - private readonly spinner: SpinnerManager - private config: CreateC1AppConfig - - constructor() { - this.spinner = new SpinnerManager() - this.config = { - projectName: '', - template: 'app' - } - } +/** + * Detect whether we're running in a non-interactive environment. + * Returns true if any of the following hold: + * - The `--non-interactive` flag was passed + * - stdin is not a TTY (piped input, cron, backgrounded process, agent shell) + */ +function isNonInteractiveEnvironment(explicitFlag: boolean): boolean { + if (explicitFlag) return true; + if (!process.stdin.isTTY) return true; + return false; +} - async main(): Promise { +const TOTAL_STEPS = 3; +class CreateC1App { + private readonly spinner: SpinnerManager; + private config: CreateC1AppConfig; + private nonInteractive: boolean; + + constructor() { + this.spinner = new SpinnerManager(); + this.config = { + projectName: "", + template: "app", + }; + this.nonInteractive = false; + } + + async main(): Promise { + try { + // Check Node.js version first + checkNodeVersion(); + + // Parse CLI arguments first to check for debug mode + const options = await this.parseArguments(); + + // If we get here, it means help/version weren't called (they would have exited) + + if (options.debug === true) { + logger.debugMode = true; + logger.info("šŸ” Debug mode enabled "); + } + + // Handle telemetry disable option + if (options.disableTelemetry === true) { + telemetry.disableTelemetry(); + logger.info("šŸ“Š Telemetry disabled for this session"); + } + + // Detect non-interactive mode + this.nonInteractive = isNonInteractiveEnvironment( + options.nonInteractive === true, + ); + if (this.nonInteractive) { + logger.info("šŸ¤– Running in non-interactive mode"); + } + + // Track app start + await telemetry.track("started_create_c1_app"); + + logger.info("šŸ§™ā€ā™‚ļø Welcome to Create C1 App!"); + logger.newLine(); + + // If no options were provided, log instructions for AI agents + const hasOptions = + options.projectName || options.template || options.apiKey; + if (!hasOptions) { + logger.info( + "[AI Agent Instructions] This CLI requires interactive input by default. If you are an AI agent or automated tool, run `npx create-c1-app --help` to see all available options and pass them as flags to avoid interactive prompts.", + ); + logger.newLine(); + } + + // Show welcome message and steps + this.showWelcome(); + + // Handle authentication flow + let authResult: AuthenticationResult; + if ( + options.apiKey !== undefined && + options.apiKey !== null && + options.apiKey.trim().length > 0 + ) { + // Use provided API key + const apiKey = options.apiKey.trim(); + logger.info(`šŸ”‘ Using provided API key: ${apiKey.substring(0, 8)}...`); + authResult = { apiKey }; + await telemetry.track("provided_api_key"); + } else if (this.nonInteractive) { + // In non-interactive mode, we cannot open a browser or prompt for input + throw new Error( + "An API key is required in non-interactive mode. " + + "Provide one with --api-key .\n" + + " Example: npx create-c1-app my-project --template template-c1-next --api-key \n" + + " Get a key at: https://console.thesys.dev/keys", + ); + } else { + // Perform OAuth authentication flow try { - // Check Node.js version first - checkNodeVersion() - - // Parse CLI arguments first to check for debug mode - const options = await this.parseArguments() - - // If we get here, it means help/version weren't called (they would have exited) - - if (options.debug === true) { - logger.debugMode = true - logger.info('šŸ” Debug mode enabled ') - } - - // Handle telemetry disable option - if (options.disableTelemetry === true) { - telemetry.disableTelemetry() - logger.info('šŸ“Š Telemetry disabled for this session') - } - - // Track app start - await telemetry.track('started_create_c1_app') - - logger.info('šŸ§™ā€ā™‚ļø Welcome to Create C1 App!') - logger.newLine() - - // Show welcome message and steps - this.showWelcome() - - // Handle authentication flow - let authResult: AuthenticationResult - if (options.apiKey !== undefined && options.apiKey !== null && options.apiKey.trim().length > 0) { - // Use provided API key - const apiKey = options.apiKey.trim() - logger.info(`šŸ”‘ Using provided API key: ${apiKey.substring(0, 8)}...`) - authResult = { apiKey } - await telemetry.track('provided_api_key') - } else { - // Perform OAuth authentication flow - try { - authResult = await this.authenticateAndGenerateAPIKey() - } catch (error) { - console.log(error) - const errorMessage = error instanceof Error ? error.message : 'Unknown error' - logger.error(`Authentication failed: ${errorMessage}`) - logger.newLine() - - // Fallback to manual API key input - logger.info('šŸ’” Falling back to manual API key input...') - const apiKey = await this.promptForApiKey() - - authResult = { apiKey } - } - await telemetry.track('oauth_authentication') - } - - // Step 1: Gather project configuration - await this.gatherProjectConfig(options) - - // Step 2: Create project - await this.createProject() - - // Step 3: Setup environment with dotenv - await this.setupEnvironment(authResult.apiKey) - - // Track successful completion - await telemetry.track('completed_create_c1_app', { - template: this.config.template, - }) - - // Success message - this.showSuccessMessage() - - // Flush and shutdown telemetry before exit - await telemetry.flush() - await telemetry.shutdown() - this.spinner.stop() + authResult = await this.authenticateAndGenerateAPIKey(); } catch (error) { - // Track error - await telemetry.track('failed_create_c1_app') + console.log(error); + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + logger.error(`Authentication failed: ${errorMessage}`); + logger.newLine(); - // Flush and shutdown telemetry before exit - await telemetry.flush() - await telemetry.shutdown() + // Fallback to manual API key input + logger.info("šŸ’” Falling back to manual API key input..."); + const apiKey = await this.promptForApiKey(); - logger.error(`Create C1 App failed: ${error instanceof Error ? error.message : 'Unknown error'}`) - process.exit(1) + authResult = { apiKey }; } - } - - private async parseArguments(): Promise { - const argv = await yargs(hideBin(process.argv)) - .scriptName('create-c1-app') - .usage('Usage: $0 [project-name] [options]') - .command('$0 [project-name]', 'Create a new C1 app', (yargs) => { - yargs.positional('project-name', { - type: 'string', - description: 'Name of the project to create' - }) - }) - .option('project-name', { - alias: 'n', - type: 'string', - description: 'Name of the project to create (alternative to positional argument)' - }) - .option('template', { - alias: 't', - type: 'string', - choices: ['template-c1-component-next', 'template-c1-next'] as const, - description: 'Next.js template to use' - }) - .option('debug', { - alias: 'd', - type: 'boolean', - description: 'Enable debug logging' - }) - .option('api-key', { - alias: 'k', - type: 'string', - description: 'API key to use (skips authentication and key generation)' - }) - .option('skip-auth', { - type: 'boolean', - description: 'Skip authentication and key generation', - default: false - }) - .option('disable-telemetry', { - type: 'boolean', - description: 'Disable anonymous telemetry collection', - default: false - }) - .help('help', 'Show help') - .alias('help', 'h') - .version(packageJson.version) - .alias('version', 'v') - .exitProcess(true) - .parseAsync() - - return argv as CLIOptions - } - - private async promptForApiKey(): Promise { - logger.newLine() - logger.info('šŸ”‘ API Key Required') - logger.newLine() - logger.info('To use Create C1 App, you need a Thesys API key.') - logger.info('Follow these steps to generate one:') - logger.newLine() - logger.info('1. 🌐 Visit: https://console.thesys.dev/keys') - logger.info('2. šŸ” Sign in to your Thesys account') - logger.info('3. šŸ†• Click "Create New API Key"') - logger.info('4. šŸ“ Give your key a descriptive name') - logger.info('5. šŸ“‹ Copy the generated API key') - logger.newLine() - logger.info('šŸ’” Tip: Keep your API key secure and never share it publicly!') - logger.newLine() - - await telemetry.track('prompted_for_api_key') - - const apiKey = await input({ - message: 'Please paste your API key here:', - validate: (input: string) => { - if (input === undefined || input.trim().length === 0) { - return 'API key cannot be empty. Please paste your API key.' - } - if (input.trim().length < 10) { - return 'API key seems too short. Please check and paste the complete key.' - } - return true - }, - transformer: (input: string) => { - // Hide most of the API key for security, showing only first few chars - return input.length > 8 ? `${input.substring(0, 8)}${'*'.repeat(Math.min(input.length - 8, 32))}` : input - } - }) - - const trimmedKey = apiKey.trim() - logger.info(`šŸ”‘ API key received: ${trimmedKey.substring(0, 8)}****`) - logger.newLine() - - return trimmedKey - } - - private async authenticateAndGenerateAPIKey(): Promise { - logger.info('šŸ” Starting OAuth authentication...') - logger.newLine() + await telemetry.track("oauth_authentication"); + } - // Configuration for Thesys OAuth (these would be real values in production) - const authConfig = { - issuerUrl: THESYS_ISSUER_URL, - clientId: THESYS_CLIENT_ID - } + // Step 1: Gather project configuration + await this.gatherProjectConfig(options); - const authenticator = new Authenticator(authConfig) + // Step 2: Create project + await this.createProject(); - // Initialize the OAuth client - const initResult = await authenticator.initialize() - if (!initResult.success) { - throw new Error(initResult.error || 'Failed to initialize authentication') - } + // Step 3: Setup environment with dotenv + await this.setupEnvironment(authResult.apiKey); - // Perform OAuth authentication - const authResult = await authenticator.authenticate() - if (!authResult.success || !authResult.data) { - throw new Error(authResult.error || 'Authentication failed') - } + // Track successful completion + await telemetry.track("completed_create_c1_app", { + template: this.config.template, + }); + // Success message + this.showSuccessMessage(); - const { userInfo, accessToken } = authResult.data + // Flush and shutdown telemetry before exit + await telemetry.flush(); + await telemetry.shutdown(); + this.spinner.stop(); + } catch (error) { + // Track error + await telemetry.track("failed_create_c1_app"); - const userInfoResponse = await fetchUserInfo(authenticator.getClientConfig(), accessToken, userInfo?.sub as string) + // Flush and shutdown telemetry before exit + await telemetry.flush(); + await telemetry.shutdown(); - logger.success('āœ… Authentication successful!') - if (userInfo?.email) { - logger.info(`šŸ‘¤ Authenticated as: ${userInfo.email}`) + logger.error( + `Create C1 App failed: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + process.exit(1); + } + } + + private async parseArguments(): Promise { + const argv = await yargs(hideBin(process.argv)) + .scriptName("create-c1-app") + .usage("Usage: $0 [project-name] [options]") + .command("$0 [project-name]", "Create a new C1 app", (yargs) => { + yargs.positional("project-name", { + type: "string", + description: "Name of the project to create", + }); + }) + .option("project-name", { + alias: "n", + type: "string", + description: + "Name of the project to create (alternative to positional argument)", + }) + .option("template", { + alias: "t", + type: "string", + choices: ["template-c1-component-next", "template-c1-next"] as const, + description: "Next.js template to use", + }) + .option("debug", { + alias: "d", + type: "boolean", + description: "Enable debug logging", + }) + .option("api-key", { + alias: "k", + type: "string", + description: "API key to use (skips authentication and key generation)", + }) + .option("skip-auth", { + type: "boolean", + description: "Skip authentication and key generation", + default: false, + }) + .option("disable-telemetry", { + type: "boolean", + description: "Disable anonymous telemetry collection", + default: false, + }) + .option("non-interactive", { + type: "boolean", + description: + "Run in non-interactive mode (fails if required options are missing). Auto-enabled in CI environments or non-TTY shells.", + default: false, + }) + .help("help", "Show help") + .alias("help", "h") + .version(packageJson.version) + .alias("version", "v") + .epilogue( + "Getting an API key manually:\n" + + " 1. Visit https://console.thesys.dev/keys\n" + + " 2. Sign in to your Thesys account (or create one)\n" + + ' 3. Click "Create New API Key" and give it a name\n' + + " 4. Copy the generated key and pass it with --api-key\n\n" + + "Example:\n" + + " $ npx create-c1-app my-app --api-key ", + ) + .exitProcess(true) + .parseAsync(); + + return argv as CLIOptions; + } + + private async promptForApiKey(): Promise { + logger.newLine(); + logger.info("šŸ”‘ API Key Required"); + logger.newLine(); + logger.info("To use Create C1 App, you need a Thesys API key."); + logger.info("Follow these steps to generate one:"); + logger.newLine(); + logger.info("1. 🌐 Visit: https://console.thesys.dev/keys"); + logger.info("2. šŸ” Sign in to your Thesys account"); + logger.info('3. šŸ†• Click "Create New API Key"'); + logger.info("4. šŸ“ Give your key a descriptive name"); + logger.info("5. šŸ“‹ Copy the generated API key"); + logger.newLine(); + logger.info( + "šŸ’” Tip: Keep your API key secure and never share it publicly!", + ); + logger.newLine(); + + await telemetry.track("prompted_for_api_key"); + + const apiKey = await input({ + message: "Please paste your API key here:", + validate: (input: string) => { + if (input === undefined || input.trim().length === 0) { + return "API key cannot be empty. Please paste your API key."; } - logger.newLine() - - logger.debug('Choosing first org') - const orgId = (userInfoResponse['org_claims'] as { orgId: string }[])?.[0]?.orgId - logger.debug(`Org ID: ${orgId}`) - - // Create API key using the authenticated credentials via HTTP call - logger.info('šŸ”‘ Creating API key...') - - const apiUrl = THESYS_API_URL - const endpoint = `${apiUrl}/application/application.createApiKeyWithOidc` - - const requestData = { - name: 'Create C1 App', - orgId: orgId, - usageType: 'C1' + if (input.trim().length < 10) { + return "API key seems too short. Please check and paste the complete key."; } + return true; + }, + transformer: (input: string) => { + // Hide most of the API key for security, showing only first few chars + return input.length > 8 + ? `${input.substring(0, 8)}${"*".repeat(Math.min(input.length - 8, 32))}` + : input; + }, + }); + + const trimmedKey = apiKey.trim(); + logger.info(`šŸ”‘ API key received: ${trimmedKey.substring(0, 8)}****`); + logger.newLine(); + + return trimmedKey; + } + + private async authenticateAndGenerateAPIKey(): Promise { + logger.info("šŸ” Starting OAuth authentication..."); + logger.newLine(); + + // Configuration for Thesys OAuth (these would be real values in production) + const authConfig = { + issuerUrl: THESYS_ISSUER_URL, + clientId: THESYS_CLIENT_ID, + }; + + const authenticator = new Authenticator(authConfig); + + // Initialize the OAuth client + const initResult = await authenticator.initialize(); + if (!initResult.success) { + throw new Error( + initResult.error || "Failed to initialize authentication", + ); + } - logger.debug(`Making API call to: ${endpoint}`) - logger.debug(`Using orgId: ${orgId}`) - - const response = await makeHttpRequest( - endpoint, - { - 'Authorization': `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - 'Accept': 'application/json' - }, - JSON.stringify(requestData) - ) - - if (response.statusCode >= 400) { - throw new Error(`API call failed with status ${response.statusCode}: ${response.body}`) - } + // Perform OAuth authentication + const authResult = await authenticator.authenticate(); + if (!authResult.success || !authResult.data) { + throw new Error(authResult.error || "Authentication failed"); + } - const responseData = JSON.parse(response.body) - const apiKey = responseData.apiKey + const { userInfo, accessToken } = authResult.data; - if (!apiKey) { - throw new Error('No API key returned from server') - } + const userInfoResponse = await fetchUserInfo( + authenticator.getClientConfig(), + accessToken, + userInfo?.sub as string, + ); - logger.success('šŸŽ‰ API key created successfully!') - logger.newLine() + logger.success("āœ… Authentication successful!"); + if (userInfo?.email) { + logger.info(`šŸ‘¤ Authenticated as: ${userInfo.email}`); + } + logger.newLine(); + + logger.debug("Choosing first org"); + const orgId = (userInfoResponse["org_claims"] as { orgId: string }[])?.[0] + ?.orgId; + logger.debug(`Org ID: ${orgId}`); + + // Create API key using the authenticated credentials via HTTP call + logger.info("šŸ”‘ Creating API key..."); + + const apiUrl = THESYS_API_URL; + const endpoint = `${apiUrl}/application/application.createApiKeyWithOidc`; + + const requestData = { + name: "Create C1 App", + orgId: orgId, + usageType: "C1", + }; + + logger.debug(`Making API call to: ${endpoint}`); + logger.debug(`Using orgId: ${orgId}`); + + const response = await makeHttpRequest( + endpoint, + { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + JSON.stringify(requestData), + ); + + if (response.statusCode >= 400) { + throw new Error( + `API call failed with status ${response.statusCode}: ${response.body}`, + ); + } - return { - apiKey, - accessToken, - userInfo - } + const responseData = JSON.parse(response.body); + const apiKey = responseData.apiKey; + if (!apiKey) { + throw new Error("No API key returned from server"); } - private showWelcome(): void { - logger.info('This tool will help you:') - logger.info(' 1. Authenticate and generate an API key') - logger.info(' 2. Create a new Thesys project') - logger.info(' 3. Setup environment') - - logger.newLine() + logger.success("šŸŽ‰ API key created successfully!"); + logger.newLine(); + + return { + apiKey, + accessToken, + userInfo, + }; + } + + private showWelcome(): void { + logger.info("This tool will help you:"); + logger.info(" 1. Authenticate and generate an API key"); + logger.info(" 2. Create a new Thesys project"); + logger.info(" 3. Setup environment"); + + logger.newLine(); + } + + private async gatherProjectConfig(options: CLIOptions): Promise { + logger.step(1, TOTAL_STEPS, "Project Configuration"); + + let projectName = options.projectName; + let template = options.template; + + // Project name + if (projectName === undefined) { + if (this.nonInteractive) { + // Use default in non-interactive mode + projectName = "my-c1-app"; + logger.info(`šŸ“ Using default project name: ${projectName}`); + } else { + projectName = await input({ + message: "What is your project name?", + default: "my-c1-app", + prefill: "editable", + validate: (input: string) => { + const validation = Validator.validateProjectName(input); + if (!validation.isValid) { + return validation.errors[0]; + } + return true; + }, + transformer: (input: string) => Validator.sanitizeProjectName(input), + }); + } } - private async gatherProjectConfig(options: CLIOptions): Promise { - logger.step(1, TOTAL_STEPS, 'Project Configuration') - - let projectName = options.projectName - let template = options.template - - // Project name - projectName ??= await input({ - message: 'What is your project name?', - default: 'my-c1-app', - prefill: 'editable', - validate: (input: string) => { - const validation = Validator.validateProjectName(input) - if (!validation.isValid) { - return validation.errors[0] - } - return true + // Template selection + if (template === undefined) { + if (this.nonInteractive) { + // Use default in non-interactive mode + template = "template-c1-next"; + logger.info(`šŸ“¦ Using default template: ${template}`); + } else { + const { select } = await import("@inquirer/prompts"); + template = await select({ + message: "Which Next.js template would you like to use?", + choices: [ + { + name: "C1 with Next.js (Recommended)", + value: "template-c1-next", + description: "Next.js Generative UI app powered by C1", }, - transformer: (input: string) => Validator.sanitizeProjectName(input) - }) - - // Template selection - if (template === undefined) { - const { select } = await import('@inquirer/prompts') - template = await select({ - message: 'Which Next.js template would you like to use?', - choices: [ - { - name: 'C1 with Next.js (Recommended)', - value: 'template-c1-next', - description: 'Next.js Generative UI app powered by C1' - }, - ], - default: 'template-c1-next' - }) - } + ], + default: "template-c1-next", + }); + } + } - // Update config with answers and CLI options - this.config = { - projectName, - template: template || 'template-c1-component-next' - } + // Update config with answers and CLI options + this.config = { + projectName, + template: template || "template-c1-component-next", + }; - // Track project configuration - await telemetry.track('project_configured', { - template: this.config.template - }) + // Track project configuration + await telemetry.track("project_configured", { + template: this.config.template, + }); - logger.success(`Project "${this.config.projectName}" will be created with:`) - logger.info(` Template: ${this.config.template} `) - logger.newLine() - } + logger.success( + `Project "${this.config.projectName}" will be created with:`, + ); + logger.info(` Template: ${this.config.template} `); + logger.newLine(); + } - private async createProject(): Promise { - logger.step(2, TOTAL_STEPS, 'Creating template') + private async createProject(): Promise { + logger.step(2, TOTAL_STEPS, "Creating template"); - this.spinner.start('Setting up your template...') + this.spinner.start("Setting up your template..."); - try { - const generator = new ProjectGenerator() - - const result = await generator.createProject({ - name: this.config.projectName, - template: this.config.template, - directory: process.cwd() - }) - - if (result.success) { - this.spinner.succeed('Template created successfully!') - // Track successful project creation - await telemetry.track('project_created', { - template: this.config.template, - }) - } else { - throw new Error(result.error ?? 'Failed to create template') - } - } catch (error) { - this.spinner.fail('Failed to create template') - - // Track project creation error - await telemetry.track('project_creation_error', { - template: this.config.template, - }) + try { + const generator = new ProjectGenerator(); + + const result = await generator.createProject({ + name: this.config.projectName, + template: this.config.template, + directory: process.cwd(), + }); + + if (result.success) { + this.spinner.succeed("Template created successfully!"); + // Track successful project creation + await telemetry.track("project_created", { + template: this.config.template, + }); + } else { + throw new Error(result.error ?? "Failed to create template"); + } + } catch (error) { + this.spinner.fail("Failed to create template"); - throw error - } + // Track project creation error + await telemetry.track("project_creation_error", { + template: this.config.template, + }); - logger.newLine() + throw error; } - private async setupEnvironment(apiKey: string): Promise { - logger.step(3, TOTAL_STEPS, 'Environment Setup') + logger.newLine(); + } - this.spinner.start('Setting up environment...') + private async setupEnvironment(apiKey: string): Promise { + logger.step(3, TOTAL_STEPS, "Environment Setup"); - try { - const envManager = new EnvironmentManager() + this.spinner.start("Setting up environment..."); - const result = await envManager.setupEnvironment(this.config.projectName, apiKey) + try { + const envManager = new EnvironmentManager(); - if (result.success) { - // Track successful environment setup - await telemetry.track('environment_setup_completed') - } else { - throw new Error(result.error ?? 'Failed to setup environment') - } + const result = await envManager.setupEnvironment( + this.config.projectName, + apiKey, + ); - this.spinner.succeed('Environment setup completed') - } catch (error) { - this.spinner.fail('Failed to setup environment') + if (result.success) { + // Track successful environment setup + await telemetry.track("environment_setup_completed"); + } else { + throw new Error(result.error ?? "Failed to setup environment"); + } - // Track environment setup error - await telemetry.track('environment_setup_error') + this.spinner.succeed("Environment setup completed"); + } catch (error) { + this.spinner.fail("Failed to setup environment"); - throw error - } + // Track environment setup error + await telemetry.track("environment_setup_error"); - logger.newLine() + throw error; } - private showSuccessMessage(): void { - logger.success('šŸŽ‰ Create C1 App completed successfully!') - logger.info('Your API key is stored in .env file.') - logger.newLine() + logger.newLine(); + } - logger.info('Your project is ready! Next steps:') - logger.info(` 1. cd ${this.config.projectName}`) - logger.info(' 2. npm run dev') - logger.newLine() + private showSuccessMessage(): void { + logger.success("šŸŽ‰ Create C1 App completed successfully!"); + logger.info("Your API key is stored in .env file."); + logger.newLine(); - logger.info('Happy coding! šŸš€') - logger.newLine() - } + logger.info("Your project is ready! Next steps:"); + logger.info(` 1. cd ${this.config.projectName}`); + logger.info(" 2. npm run dev"); + logger.newLine(); + + logger.info("Happy coding! šŸš€"); + logger.newLine(); + } } export async function main(): Promise { - // Check Node.js version before instantiating anything - checkNodeVersion() + // Check Node.js version before instantiating anything + checkNodeVersion(); - const app = new CreateC1App() - await app.main() + const app = new CreateC1App(); + await app.main(); - process.exit(0) + process.exit(0); } // Handle process exit to ensure telemetry is flushed -process.on('exit', () => { - // Note: We can't use async operations in exit handler - // Telemetry should be flushed in main() before exit -}) - -process.on('SIGINT', async () => { - console.log('\n\nšŸ‘‹ Goodbye!') - await telemetry.flush() - await telemetry.shutdown() - process.exit(0) -}) - -process.on('SIGTERM', async () => { - await telemetry.flush() - await telemetry.shutdown() - process.exit(0) -}) +process.on("exit", () => { + // Note: We can't use async operations in exit handler + // Telemetry should be flushed in main() before exit +}); + +process.on("SIGINT", async () => { + console.log("\n\nšŸ‘‹ Goodbye!"); + await telemetry.flush(); + await telemetry.shutdown(); + process.exit(0); +}); + +process.on("SIGTERM", async () => { + await telemetry.flush(); + await telemetry.shutdown(); + process.exit(0); +}); // Export for testing -export { CreateC1App } +export { CreateC1App }; // Execute main function when run directly // ESM equivalent of require.main === module -import { fileURLToPath } from 'url' -const isMainModule = process.argv[1] === fileURLToPath(import.meta.url) +import { fileURLToPath } from "url"; +const isMainModule = process.argv[1] === fileURLToPath(import.meta.url); if (isMainModule) { - main().catch((error) => { - console.error('Error:', error.message) - process.exit(1) - }) + main().catch((error) => { + console.error("Error:", error.message); + process.exit(1); + }); } diff --git a/src/types/index.ts b/src/types/index.ts index 9125cc8..c9b89da 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,93 +1,94 @@ export interface CreateC1AppConfig { - projectName: string - template: string + projectName: string; + template: string; } export interface AuthCredentials { - email?: string - password?: string - token?: string + email?: string; + password?: string; + token?: string; } export interface AuthSession { - token: string - refreshToken?: string + token: string; + refreshToken?: string; } export interface BrowserAuthRequest { - authUrl: string - requestToken: string - expiresAt: Date + authUrl: string; + requestToken: string; + expiresAt: Date; } export interface BrowserAuthResponse { - token: string - expiresAt: Date - refreshToken?: string + token: string; + expiresAt: Date; + refreshToken?: string; } export interface OAuthConfig { - issuerUrl: string - clientId: string - redirectUri?: string - scopes?: string[] + issuerUrl: string; + clientId: string; + redirectUri?: string; + scopes?: string[]; } export interface AuthenticationResult { - apiKey: string - keyId?: string - accessToken?: string - refreshToken?: string - userInfo?: Record | undefined + apiKey: string; + keyId?: string; + accessToken?: string; + refreshToken?: string; + userInfo?: Record | undefined; } export interface ApiKeyResponse { - key: string + key: string; } export interface ProjectGenerationOptions { - name: string - template: string - directory: string + name: string; + template: string; + directory: string; } export interface EnvironmentConfig { - apiKey: string - projectId?: string + apiKey: string; + projectId?: string; } export interface CLIOptions { - projectName?: string - template?: 'template-c1-component-next' | 'template-c1-next' - debug?: boolean - apiKey?: string - disableTelemetry?: boolean - skipAuth?: boolean + projectName?: string; + template?: "template-c1-component-next" | "template-c1-next"; + debug?: boolean; + apiKey?: string; + disableTelemetry?: boolean; + skipAuth?: boolean; + nonInteractive?: boolean; } export interface StepResult { - success: boolean - data?: T - error?: string + success: boolean; + data?: T; + error?: string; } export interface CreateC1AppStep { - name: string - description: string - execute: () => Promise + name: string; + description: string; + execute: () => Promise; } export interface ApiError { - message: string - code?: string - statusCode?: number + message: string; + code?: string; + statusCode?: number; } export interface ValidationResult { - isValid: boolean - errors: string[] + isValid: boolean; + errors: string[]; } // Utility types -export type PartialBy = Omit & Partial> -export type RequiredBy = T & Required> +export type PartialBy = Omit & Partial>; +export type RequiredBy = T & Required>;