diff --git a/README.md b/README.md index 842acaf..9a3ead5 100644 --- a/README.md +++ b/README.md @@ -15,15 +15,19 @@ A powerful CLI tool that setups Generative UI examples with C1 by Thesys # Run the tool npx create-c1-app -# Or with options -npx create-c1-app --project-name my-thesys-project --template template-c1-component-next --api-key your-api-key +# With project name +npx create-c1-app my-thesys-project + +# With project name and options +npx create-c1-app my-thesys-project --template template-c1-component-next --api-key your-api-key ``` ## CLI Options | Option | Alias | Description | Default | |--------|-------|-------------|---------| -| `--project-name` | `-n` | Name of the project to create | Interactive prompt | +| `[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` | @@ -34,14 +38,20 @@ npx create-c1-app --project-name my-thesys-project --template template-c1-compon ### Basic Usage ```bash -# Interactive mode (recommended) +# Interactive mode with OAuth authentication (recommended) npx create-c1-app +# Quick setup with project name as positional argument +npx create-c1-app my-thesys-project + # Quick setup with project name, template, and API key +npx create-c1-app my-thesys-component --template template-c1-component-next --api-key your-api-key-here + +# Using flag instead of positional argument npx create-c1-app --project-name my-thesys-component --template template-c1-component-next --api-key your-api-key-here # With specific template choice -npx create-c1-app --template template-c1-next --api-key your-api-key-here +npx create-c1-app my-project --template template-c1-next --api-key your-api-key-here # Interactive with API key provided npx create-c1-app --api-key your-api-key-here @@ -61,9 +71,40 @@ pnpm link ``` -## Getting Your Thesys API Key +## Authentication Options + +Create C1 App supports two authentication methods: + +### Option 1: OAuth 2.0 Authentication (Recommended) + +The CLI will automatically open your browser and guide you through the OAuth authentication process: + +```bash +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 + +### Option 2: Manual API Key + +If you prefer to provide your API key manually or skip OAuth authentication: + +```bash +npx create-c1-app --skip-auth +``` + +Or provide your existing API key directly: + +```bash +npx create-c1-app --api-key your-api-key-here +``` + +## Getting Your Thesys API Key (Manual Method) -To use Create C1 App, you'll need a Thesys API key: +To get an API key manually: 1. 🌐 Visit: https://console.thesys.dev/keys 2. 🔐 Sign in to your Thesys account diff --git a/bin/create-c1-app b/bin/create-c1-app.js similarity index 77% rename from bin/create-c1-app rename to bin/create-c1-app.js index 750b184..a052d5c 100755 --- a/bin/create-c1-app +++ b/bin/create-c1-app.js @@ -1,7 +1,7 @@ #!/usr/bin/env node // Entry point for the Create C1 App CLI -const { main } = require('../dist/index.js') +import { main } from '../dist/index.js' main().catch((error) => { console.error('Error:', error.message) diff --git a/eslint.config.js b/eslint.config.js index 7615b5b..03cd79b 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,7 +1,7 @@ -const tseslint = require('@typescript-eslint/eslint-plugin') -const tsparser = require('@typescript-eslint/parser') +import tseslint from '@typescript-eslint/eslint-plugin' +import tsparser from '@typescript-eslint/parser' -module.exports = [ +export default [ // Global ignores { ignores: ['dist/', 'node_modules/'] @@ -9,19 +9,15 @@ module.exports = [ // Base configuration for JavaScript/Node files { - files: ['bin/create-c1-app'], + files: ['bin/create-c1-app.js'], languageOptions: { ecmaVersion: 2020, - sourceType: 'script', + sourceType: 'module', globals: { console: 'readonly', process: 'readonly', Buffer: 'readonly', - __dirname: 'readonly', - __filename: 'readonly', - global: 'readonly', - module: 'readonly', - require: 'readonly' + global: 'readonly' } }, rules: { @@ -66,14 +62,14 @@ module.exports = [ 'no-console': 'off', 'prefer-const': 'error', 'no-var': 'error', - + // Disable style rules - focus on functionality 'semi': 'off', 'quotes': 'off', 'comma-dangle': 'off', 'space-before-function-paren': 'off', 'indent': 'off', - + // TypeScript specific rules - only important ones '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], '@typescript-eslint/explicit-function-return-type': 'off', diff --git a/package.json b/package.json index a000c69..6d2764b 100644 --- a/package.json +++ b/package.json @@ -2,18 +2,19 @@ "name": "create-c1-app", "version": "1.1.5", "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" + "create-c1-app": "bin/create-c1-app.js" }, "scripts": { "build": "tsc", - "dev": "ts-node src/index.ts", - "start": "node bin/create-c1-app", + "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'", - "lint:fix": "eslint 'src/**/*.ts' 'bin/create-c1-app' --fix" + "lint": "eslint 'src/**/*.ts' 'bin/create-c1-app.js'", + "lint:fix": "eslint 'src/**/*.ts' 'bin/create-c1-app.js' --fix" }, "keywords": [ "generative-ui", @@ -27,6 +28,8 @@ "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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7904429..264c581 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,12 @@ importers: nanoid: specifier: ^5.1.5 version: 5.1.5 + open: + specifier: ^10.2.0 + version: 10.2.0 + openid-client: + specifier: ^6.8.1 + version: 6.8.1 ora: specifier: ^5.4.1 version: 5.4.1 @@ -557,6 +563,10 @@ packages: builtins@5.1.0: resolution: {integrity: sha512-SW9lzGTLvWTP1AY8xeAMZimqDrIaSdLQUcVr9DMef51niJ022Ri87SwRRKYm4A6iHfkPaiVUu/Duw2Wc4J7kKg==} + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -659,6 +669,14 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + default-browser-id@5.0.0: + resolution: {integrity: sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==} + engines: {node: '>=18'} + + default-browser@5.2.1: + resolution: {integrity: sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==} + engines: {node: '>=18'} + defaults@1.0.4: resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} @@ -666,6 +684,10 @@ packages: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + define-properties@1.2.1: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} @@ -1083,6 +1105,11 @@ packages: resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} engines: {node: '>= 0.4'} + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -1103,6 +1130,11 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + is-interactive@1.0.0: resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} engines: {node: '>=8'} @@ -1167,6 +1199,10 @@ packages: resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} engines: {node: '>= 0.4'} + is-wsl@3.1.0: + resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==} + engines: {node: '>=16'} + isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} @@ -1196,6 +1232,9 @@ packages: resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jose@6.1.0: + resolution: {integrity: sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -1292,6 +1331,9 @@ packages: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} + oauth4webapi@3.8.2: + resolution: {integrity: sha512-FzZZ+bht5X0FKe7Mwz3DAVAmlH1BV5blSak/lHMBKz0/EBMhX6B10GlQYI51+oRp8ObJaX0g6pXrAxZh5s8rjw==} + object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -1323,6 +1365,13 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} + open@10.2.0: + resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} + engines: {node: '>=18'} + + openid-client@6.8.1: + resolution: {integrity: sha512-VoYT6enBo6Vj2j3Q5Ec0AezS+9YGzQo1f5Xc42lreMGlfP4ljiXPKVDvCADh+XHCV/bqPu/wWSiCVXbJKvrODw==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -1446,6 +1495,10 @@ packages: deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -1704,6 +1757,10 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + wsl-utils@0.1.0: + resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} + engines: {node: '>=18'} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -2235,6 +2292,10 @@ snapshots: dependencies: semver: 7.7.2 + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -2329,6 +2390,13 @@ snapshots: deep-is@0.1.4: {} + default-browser-id@5.0.0: {} + + default-browser@5.2.1: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.0 + defaults@1.0.4: dependencies: clone: 1.0.4 @@ -2339,6 +2407,8 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + define-lazy-prop@3.0.0: {} + define-properties@1.2.1: dependencies: define-data-property: 1.1.4 @@ -2879,6 +2949,8 @@ snapshots: call-bound: 1.0.4 has-tostringtag: 1.0.2 + is-docker@3.0.0: {} + is-extglob@2.1.1: {} is-finalizationregistry@1.1.1: @@ -2898,6 +2970,10 @@ snapshots: dependencies: is-extglob: 2.1.1 + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + is-interactive@1.0.0: {} is-map@2.0.3: {} @@ -2954,6 +3030,10 @@ snapshots: call-bound: 1.0.4 get-intrinsic: 1.3.0 + is-wsl@3.1.0: + dependencies: + is-inside-container: 1.0.0 + isarray@1.0.0: {} isarray@2.0.5: {} @@ -2997,6 +3077,8 @@ snapshots: graceful-fs: 4.2.11 picomatch: 2.3.1 + jose@6.1.0: {} + js-tokens@4.0.0: {} js-yaml@4.1.0: @@ -3076,6 +3158,8 @@ snapshots: dependencies: path-key: 3.1.1 + oauth4webapi@3.8.2: {} + object-inspect@1.13.4: {} object-keys@1.1.1: {} @@ -3117,6 +3201,18 @@ snapshots: dependencies: mimic-fn: 2.1.0 + open@10.2.0: + dependencies: + default-browser: 5.2.1 + define-lazy-prop: 3.0.0 + is-inside-container: 1.0.0 + wsl-utils: 0.1.0 + + openid-client@6.8.1: + dependencies: + jose: 6.1.0 + oauth4webapi: 3.8.2 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -3251,6 +3347,8 @@ snapshots: dependencies: glob: 7.2.3 + run-applescript@7.1.0: {} + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -3583,6 +3681,10 @@ snapshots: wrappy@1.0.2: {} + wsl-utils@0.1.0: + dependencies: + is-wsl: 3.1.0 + y18n@5.0.8: {} yargs-parser@21.1.1: {} diff --git a/src/auth/authenticator.ts b/src/auth/authenticator.ts new file mode 100644 index 0000000..4a6fcd0 --- /dev/null +++ b/src/auth/authenticator.ts @@ -0,0 +1,295 @@ +import http from 'http' +import { discovery, randomPKCECodeVerifier, calculatePKCECodeChallenge, buildAuthorizationUrl, authorizationCodeGrant, Configuration } from 'openid-client' +import logger from '../utils/logger.js' +import { type StepResult } from '../types/index.js' +import open from 'open' + + +export interface AuthConfig { + issuerUrl: string + clientId: string + redirectUri?: string + scopes?: string[] +} + +export interface AuthResult { + accessToken: string + refreshToken?: string | undefined + idToken?: string | undefined + userInfo?: Record | undefined +} + +export class Authenticator { + private readonly config: AuthConfig + private clientConfig?: Configuration + private codeVerifier?: string + + constructor(config: AuthConfig) { + this.config = { + redirectUri: config.redirectUri || 'http://localhost:0/cb', // 0 = any available port + scopes: ['openid', 'profile', 'email'], + ...config + } + } + + getClientConfig(): Configuration { + if (!this.clientConfig) { + throw new Error('Client not initialized. Call initialize() first.') + } + return this.clientConfig + } + + /** + * Initialize the OAuth client by discovering the authorization server + */ + async initialize(): Promise> { + logger.debug('🔍 Discovering authorization server...') + + this.clientConfig = await discovery( + new URL(this.config.issuerUrl), + this.config.clientId, + + ) + + logger.debug('✅ Authorization server discovered successfully') + + return { success: true } + } + + /** + * Start the OAuth 2.0 with PKCE authentication flow + */ + async authenticate(): Promise> { + if (!this.clientConfig) { + return { + success: false, + error: 'Client not initialized. Call initialize() first.' + } + } + + try { + logger.info('🔐 Starting authentication...') + + // Generate PKCE parameters + this.codeVerifier = randomPKCECodeVerifier() + const codeChallenge = await calculatePKCECodeChallenge(this.codeVerifier) + + // Start authentication flow with dynamic port handling + const authResult = await this.handleBrowserAuth(codeChallenge) + + if (!authResult.success) { + return authResult + } + + logger.success('🎉 Authentication completed successfully!') + + return authResult + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + logger.error(`Authentication failed: ${errorMessage}`) + + return { + success: false, + error: `Authentication failed: ${errorMessage}` + } + } + } + + /** + * Handle the browser-based OAuth flow + */ + private async handleBrowserAuth(codeChallenge: string): Promise> { + return new Promise((resolve) => { + let serverClosed = false + let actualPort: number + + // Create a temporary HTTP server to handle the callback + const server = http.createServer(async (req, res) => { + try { + if (req.url?.startsWith('/cb')) { + logger.debug('📨 Received callback from authorization server') + + // Create URL object from the callback request + const callbackUrl = new URL(req.url, `http://localhost:${actualPort}`) + + if (!this.clientConfig || !this.codeVerifier) { + throw new Error('Client not properly initialized') + } + + // Exchange authorization code for tokens + const tokenResponse = await authorizationCodeGrant( + this.clientConfig, + callbackUrl, + { + pkceCodeVerifier: this.codeVerifier + } + ) + + logger.debug('🎫 Tokens received and validated') + + // Get user info if available (this would require userinfo endpoint call) + let userInfo: Record | undefined + try { + // For now, we'll get user info from ID token claims if available + const claims = tokenResponse.claims() + if (claims) { + userInfo = claims as Record + logger.debug('👤 User info retrieved from ID token') + } + } catch (error) { + logger.debug('⚠️ Could not retrieve user info') + } + + // Prepare result + const authResult: AuthResult = { + accessToken: tokenResponse.access_token || '', + refreshToken: tokenResponse.refresh_token || undefined, + idToken: tokenResponse.id_token || undefined, + userInfo + } + + // Send success response to browser + res.writeHead(200, { 'Content-Type': 'text/html', 'Connection': 'close' }) + res.end(` + + + + Authentication Successful + + + +
+

Authentication Successful!

+

You have been successfully authenticated.

+

You can now close this window and return to your terminal.

+
+ + + `) + + // Close server and resolve + serverClosed = true + server.close() + resolve({ success: true, data: authResult }) + + } else { + // Handle other requests + res.writeHead(404, { 'Connection': 'close' }) + res.end('Not found') + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + logger.error(`Token exchange failed: ${errorMessage}`) + + // Send error response to browser + res.writeHead(200, { 'Content-Type': 'text/html', 'Connection': 'close' }) + res.end(` + + + + Authentication Failed + + + +
+

❌ Authentication Failed

+

There was an error during authentication. Please try again.

+

Error: ${errorMessage}

+
+ + + `) + + // Close server and resolve with error + serverClosed = true + server.close() + resolve({ + success: false, + error: `Token exchange failed: ${errorMessage}` + }) + } + }) + + // Handle server errors + server.on('error', (error) => { + if (serverClosed) return + + const errorMessage = error.message + logger.error(`Server error: ${errorMessage}`) + + serverClosed = true + resolve({ + success: false, + error: `Server error: ${errorMessage}` + }) + }) + + // Start the server on any available port + server.listen(0, async () => { + try { + // Get the actual port that was assigned + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('Failed to get server address') + } + actualPort = address.port + + if (!this.clientConfig) { + throw new Error('Client not initialized') + } + + // Generate authorization URL with the correct redirect URI + const authorizationUrl = buildAuthorizationUrl(this.clientConfig, { + redirect_uri: `http://localhost:${actualPort}/cb`, + scope: this.config.scopes?.join(' ') || 'openid profile email', + code_challenge: codeChallenge, + code_challenge_method: 'S256', + prompt: 'consent', + }) + + logger.info(`🌐 Started callback server on port ${actualPort}`) + logger.info('🌐 Opening browser for authentication...') + logger.info('💡 If the browser doesn\'t open automatically, please visit:') + logger.info(` ${authorizationUrl.toString()}`) + logger.newLine() + + // Open browser with authorization URL + await open(authorizationUrl.toString()) + + logger.info('⏳ Waiting for authentication to complete...') + logger.info(' Please complete the authentication in your browser.') + + } catch (error) { + console.log(error) + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + logger.error(`Failed to open browser: ${errorMessage}`) + logger.info('💡 Please manually visit the authorization URL shown above') + } + }) + + // Set a timeout to avoid hanging indefinitely + setTimeout(() => { + if (!serverClosed) { + logger.error('⏰ Authentication timed out after 5 minutes') + serverClosed = true + server.close() + resolve({ + success: false, + error: 'Authentication timed out. Please try again.' + }) + } + }, 5 * 60 * 1000) // 5 minutes timeout + }) + } +} + +export default Authenticator diff --git a/src/env/envManager.ts b/src/env/envManager.ts index 53324a3..7167a0a 100644 --- a/src/env/envManager.ts +++ b/src/env/envManager.ts @@ -1,10 +1,10 @@ import fs from 'fs/promises' import path from 'path' -import logger from '../utils/logger' -import { type StepResult } from '../types/index' +import logger from '../utils/logger.js' +import { type StepResult } from '../types/index.js' export class EnvironmentManager { - async setupEnvironment (projectName: string, apiKey: string): Promise { + async setupEnvironment(projectName: string, apiKey: string): Promise { try { const projectPath = path.join(process.cwd(), projectName) diff --git a/src/generators/project.ts b/src/generators/project.ts index 54cd537..73500d1 100644 --- a/src/generators/project.ts +++ b/src/generators/project.ts @@ -2,13 +2,13 @@ import execa from 'execa' import fs, { createReadStream, createWriteStream } from 'fs' import path from 'path' -import logger from '../utils/logger' -import { type ProjectGenerationOptions, type StepResult } from '../types/index' +import logger from '../utils/logger.js' +import { type ProjectGenerationOptions, type StepResult } from '../types/index.js' import { Extract } from 'unzipper' -import telemetry from '../utils/telemetry' +import telemetry from '../utils/telemetry.js' export class ProjectGenerator { - async createProject (options: ProjectGenerationOptions): Promise { + async createProject(options: ProjectGenerationOptions): Promise { try { const projectPath = path.join(options.directory, options.name) @@ -46,7 +46,7 @@ export class ProjectGenerator { } } - private async downloadTemplate (options: ProjectGenerationOptions): Promise { + private async downloadTemplate(options: ProjectGenerationOptions): Promise { if (await this.downloadViaHttp(options)) { return true } @@ -54,7 +54,7 @@ export class ProjectGenerator { return false } - private async downloadViaHttp (options: ProjectGenerationOptions): Promise { + private async downloadViaHttp(options: ProjectGenerationOptions): Promise { try { const zipUrl = `https://github.com/thesysdev/${options.template}/archive/refs/heads/main.zip` const tempZipPath = path.join(options.directory, `${options.template}-temp.zip`) @@ -109,7 +109,7 @@ export class ProjectGenerator { } } - private async downloadFile (url: string, destination: string): Promise { + private async downloadFile(url: string, destination: string): Promise { const response = await fetch(url) if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`) @@ -118,19 +118,19 @@ export class ProjectGenerator { const fileStream = createWriteStream(destination) await new Promise((resolve, reject) => { - if (response.body === null) { - throw new Error('Response body is null') - } + if (response.body === null) { + throw new Error('Response body is null') + } response.body.pipeTo( new WritableStream({ - write (chunk) { + write(chunk) { fileStream.write(chunk) }, - close () { + close() { fileStream.end() resolve() }, - abort (error) { + abort(error) { fileStream.destroy() reject(error) } @@ -139,7 +139,7 @@ export class ProjectGenerator { }) } - private async extractZip (zipPath: string, destination: string): Promise { + private async extractZip(zipPath: string, destination: string): Promise { await new Promise((resolve, reject) => { createReadStream(zipPath) .pipe(Extract({ path: destination })) @@ -148,7 +148,7 @@ export class ProjectGenerator { }) } - private async installDependencies (projectPath: string): Promise { + private async installDependencies(projectPath: string): Promise { // Check if package.json exists in the project directory const packageJsonPath = path.join(projectPath, 'package.json') try { @@ -183,7 +183,7 @@ export class ProjectGenerator { } } - private async enhanceProjectStructure (_options: ProjectGenerationOptions, projectPath: string): Promise { + private async enhanceProjectStructure(_options: ProjectGenerationOptions, projectPath: string): Promise { logger.debug('Enhancing project structure...') // Setup git with proper .gitignore @@ -192,7 +192,7 @@ export class ProjectGenerator { logger.debug('Project structure enhanced') } - private async setupGit (projectPath: string): Promise { + private async setupGit(projectPath: string): Promise { try { // Ensure environment files are in .gitignore const gitignorePath = path.join(projectPath, '.gitignore') @@ -225,7 +225,7 @@ export class ProjectGenerator { } } - private async createGitignoreFile (projectPath: string): Promise { + private async createGitignoreFile(projectPath: string): Promise { const gitignoreContent = ` # Environment variables .env diff --git a/src/index.ts b/src/index.ts index 8575077..da5294b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,13 +1,53 @@ import { input } from '@inquirer/prompts' import yargs from 'yargs' import { hideBin } from 'yargs/helpers' -import logger from './utils/logger' -import SpinnerManager from './utils/spinner' -import * as Validator from './utils/validation' -import { type CLIOptions, type CreateC1AppConfig } from './types/index' -import { ProjectGenerator } from './generators/project' -import { EnvironmentManager } from './env/envManager' -import telemetry from './utils/telemetry' +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' + + +// 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'}`) + } +} // Check Node.js version before doing anything else function checkNodeVersion(): void { @@ -73,18 +113,33 @@ class CreateC1App { // Show welcome message and steps this.showWelcome() - // Store provided API key if given and validate it, or prompt for one - let apiKey = '' + // Handle authentication flow + let authResult: AuthenticationResult if (options.apiKey !== undefined && options.apiKey !== null && options.apiKey.trim().length > 0) { - apiKey = options.apiKey.trim() + // 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 { - // Prompt user to generate and provide API key - apiKey = await this.promptForApiKey() + // 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') } - await telemetry.track('provided_api_key') - // Step 1: Gather project configuration await this.gatherProjectConfig(options) @@ -92,7 +147,7 @@ class CreateC1App { await this.createProject() // Step 3: Setup environment with dotenv - await this.setupEnvironment(apiKey) + await this.setupEnvironment(authResult.apiKey) // Track successful completion await telemetry.track('completed_create_c1_app', { @@ -122,11 +177,17 @@ class CreateC1App { private async parseArguments(): Promise { const argv = await yargs(hideBin(process.argv)) .scriptName('create-c1-app') - .usage('Usage: $0 [options]') + .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' + description: 'Name of the project to create (alternative to positional argument)' }) .option('template', { alias: 't', @@ -144,6 +205,11 @@ class CreateC1App { 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', @@ -151,7 +217,7 @@ class CreateC1App { }) .help('help', 'Show help') .alias('help', 'h') - .version('version', 'Show version number') + .version(packageJson.version) .alias('version', 'v') .exitProcess(true) .parseAsync() @@ -201,10 +267,96 @@ class CreateC1App { 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') + } + + // Perform OAuth authentication + const authResult = await authenticator.authenticate() + if (!authResult.success || !authResult.data) { + throw new Error(authResult.error || 'Authentication failed') + } + + + const { userInfo, accessToken } = authResult.data + + const userInfoResponse = await fetchUserInfo(authenticator.getClientConfig(), accessToken, userInfo?.sub as string) + + 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}`) + } + + const responseData = JSON.parse(response.body) + const apiKey = responseData.apiKey + + if (!apiKey) { + throw new Error('No API key returned from server') + } + + 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. Create a new Thesys project') - logger.info(' 2. Authenticate and generate an API key') + logger.info(' 1. Authenticate and generate an API key') + logger.info(' 2. Create a new Thesys project') logger.info(' 3. Setup environment') logger.newLine() @@ -219,7 +371,7 @@ class CreateC1App { // Project name projectName ??= await input({ message: 'What is your project name?', - default: 'thesys-project', + default: 'my-c1-app', prefill: 'editable', validate: (input: string) => { const validation = Validator.validateProjectName(input) @@ -238,15 +390,10 @@ class CreateC1App { message: 'Which Next.js template would you like to use?', choices: [ { - name: 'C1 Next (Recommended)', + name: 'C1 with Next.js (Recommended)', value: 'template-c1-next', description: 'Next.js Generative UI app powered by C1' }, - { - name: 'C1 Component Next', - value: 'template-c1-component-next', - description: 'Next.js Chat with C1 components' - }, ], default: 'template-c1-next' }) @@ -356,6 +503,8 @@ export async function main(): Promise { const app = new CreateC1App() await app.main() + + process.exit(0) } // Handle process exit to ensure telemetry is flushed @@ -381,7 +530,11 @@ process.on('SIGTERM', async () => { export { CreateC1App } // Execute main function when run directly -if (require.main === module) { +// ESM equivalent of require.main === module +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) diff --git a/src/types/index.ts b/src/types/index.ts index d86cfa8..9125cc8 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -26,6 +26,21 @@ export interface BrowserAuthResponse { refreshToken?: string } +export interface OAuthConfig { + issuerUrl: string + clientId: string + redirectUri?: string + scopes?: string[] +} + +export interface AuthenticationResult { + apiKey: string + keyId?: string + accessToken?: string + refreshToken?: string + userInfo?: Record | undefined +} + export interface ApiKeyResponse { key: string } @@ -47,6 +62,7 @@ export interface CLIOptions { debug?: boolean apiKey?: string disableTelemetry?: boolean + skipAuth?: boolean } export interface StepResult { diff --git a/src/utils/telemetry.ts b/src/utils/telemetry.ts index cec7b77..3c23663 100644 --- a/src/utils/telemetry.ts +++ b/src/utils/telemetry.ts @@ -2,7 +2,7 @@ import { PostHog } from 'posthog-node' import os from 'os' import fs from 'fs' import path from 'path' -import logger from './logger' +import logger from './logger.js' export interface TelemetryEvent { event: string diff --git a/src/utils/validation.ts b/src/utils/validation.ts index f2ea959..ce4e593 100644 --- a/src/utils/validation.ts +++ b/src/utils/validation.ts @@ -1,7 +1,7 @@ import validateNpmPackageName from 'validate-npm-package-name' -import { type ValidationResult } from '../types/index' +import { type ValidationResult } from '../types/index.js' -export function validateProjectName (name: string): ValidationResult { +export function validateProjectName(name: string): ValidationResult { const errors: string[] = [] if (name === null || name === undefined || name.trim().length === 0) { @@ -41,7 +41,7 @@ export function validateProjectName (name: string): ValidationResult { } } -export function sanitizeProjectName (name: string): string { +export function sanitizeProjectName(name: string): string { return name .trim() .toLowerCase() diff --git a/tsconfig.json b/tsconfig.json index 00c4717..3dd6699 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "target": "ES2020", - "module": "commonjs", + "module": "ES2020", "lib": [ "ES2020", "DOM" @@ -33,7 +33,7 @@ "noEmit": false }, "ts-node": { - "esm": false, + "esm": true, "moduleTypes": {} }, "include": [