diff --git a/src/cli/args.ts b/src/cli/args.ts index f6059e0..ca1dcbc 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -26,6 +26,13 @@ interface CLIArgs { serverUrl?: string; logout?: boolean; listPlans?: boolean; + env?: string[]; + envFile?: string[]; + ignore?: string[]; + quiet?: boolean; + verbose?: boolean; + json?: boolean; + dryRun?: boolean; } const parsePlan = (planType: string): Plans | undefined => { @@ -125,7 +132,56 @@ const optionsDefinition: ArgumentConfig = { alias: 'u', optional: true }, - confDir: { type: String, alias: 'c', optional: true } + confDir: { type: String, alias: 'c', optional: true }, + env: { + type: String, + alias: 'E', + optional: true, + multiple: true, + description: + 'Set environment variable (can be used multiple times): -E KEY=VALUE' + }, + envFile: { + type: String, + optional: true, + multiple: true, + description: + 'Path to .env file (can be used multiple times): --envFile .env.production' + }, + ignore: { + type: String, + alias: 'I', + optional: true, + multiple: true, + description: + 'Ignore pattern for files (can be used multiple times): -I "*.log" -I "node_modules"' + }, + quiet: { + type: Boolean, + alias: 'q', + defaultValue: false, + optional: true, + description: 'Suppress non-essential output' + }, + verbose: { + type: Boolean, + alias: 'V', + defaultValue: false, + optional: true, + description: 'Show detailed output for debugging' + }, + json: { + type: Boolean, + defaultValue: false, + optional: true, + description: 'Output results in JSON format (useful for scripting)' + }, + dryRun: { + type: Boolean, + defaultValue: false, + optional: true, + description: 'Show what would be deployed without actually deploying' + } }; const parseOptions: ParseOptions = { diff --git a/src/cli/messages.ts b/src/cli/messages.ts index 8019717..0248aa4 100644 --- a/src/cli/messages.ts +++ b/src/cli/messages.ts @@ -3,35 +3,134 @@ import { Languages } from '@metacall/protocol/language'; import { ProtocolError } from '@metacall/protocol/protocol'; import chalk from 'chalk'; +// Output mode configuration +let outputMode: 'normal' | 'quiet' | 'verbose' | 'json' = 'normal'; + +export const setOutputMode = ( + mode: 'normal' | 'quiet' | 'verbose' | 'json' +): void => { + outputMode = mode; +}; + +export const getOutputMode = (): 'normal' | 'quiet' | 'verbose' | 'json' => + outputMode; + +export const isQuiet = (): boolean => outputMode === 'quiet'; +export const isVerbose = (): boolean => outputMode === 'verbose'; +export const isJson = (): boolean => outputMode === 'json'; + +/** + * Log informational message (suppressed in quiet mode) + */ export const info = (message: string): void => { + if (isQuiet()) return; + if (isJson()) return; // eslint-disable-next-line no-console console.log(chalk.cyanBright.bold('i') + ' ' + chalk.cyan(message)); }; +/** + * Log warning message (shown in all modes except json) + */ export const warn = (message: string): void => { + if (isJson()) return; // eslint-disable-next-line no-console console.warn(chalk.yellowBright.bold('!') + ' ' + chalk.yellow(message)); }; -export const error = (message: string, exitCode = 1): never => { +/** + * Log debug/verbose message (only shown in verbose mode) + */ +export const debug = (message: string): void => { + if (!isVerbose()) return; // eslint-disable-next-line no-console - console.error(chalk.redBright.bold('X') + ' ' + chalk.red(message)); + console.log(chalk.gray(' [debug] ' + message)); +}; + +/** + * Log success message + */ +export const success = (message: string): void => { + if (isQuiet()) return; + if (isJson()) return; + // eslint-disable-next-line no-console + console.log(chalk.greenBright.bold('✓') + ' ' + chalk.green(message)); +}; + +/** + * Log error and exit (always shown) + */ +export const error = (message: string, exitCode = 1): never => { + if (isJson()) { + // eslint-disable-next-line no-console + console.error(JSON.stringify({ error: message, exitCode })); + } else { + // eslint-disable-next-line no-console + console.error(chalk.redBright.bold('X') + ' ' + chalk.red(message)); + } return process.exit(exitCode); }; + +/** + * Log API error and exit (always shown) + */ export const apiError = (err: ProtocolError): never => { - // eslint-disable-next-line no-console - console.error( - chalk.redBright.bold('X') + - chalk.redBright( - ` Server responded with error code: ${ - err.response?.status || '' - } ${err.response?.data as string}` - ) - ); + const status = err.response?.status || 'unknown'; + const data = err.response?.data as string; + + if (isJson()) { + // eslint-disable-next-line no-console + console.error( + JSON.stringify({ + error: 'API Error', + status, + message: data, + exitCode: 1 + }) + ); + } else { + // eslint-disable-next-line no-console + console.error( + chalk.redBright.bold('X') + + chalk.redBright( + ` Server responded with error code: ${status} ${data}` + ) + ); + } return process.exit(1); }; +/** + * Output JSON data (only in json mode) + */ +export const jsonOutput = (data: unknown): void => { + if (!isJson()) return; + // eslint-disable-next-line no-console + console.log(JSON.stringify(data, null, 2)); +}; + +/** + * Format language name with color + */ export const printLanguage = (language: LanguageId): string => chalk .hex(Languages[language].hexColor) .bold(Languages[language].displayName); + +/** + * Print a styled header + */ +export const header = (text: string): void => { + if (isQuiet() || isJson()) return; + // eslint-disable-next-line no-console + console.log('\n' + chalk.bold.underline(text) + '\n'); +}; + +/** + * Print a step indicator + */ +export const step = (stepNum: number, total: number, message: string): void => { + if (isQuiet() || isJson()) return; + // eslint-disable-next-line no-console + console.log(chalk.dim(`[${stepNum}/${total}]`) + ' ' + message); +}; diff --git a/src/deploy.ts b/src/deploy.ts index 7254675..8f60350 100644 --- a/src/deploy.ts +++ b/src/deploy.ts @@ -20,7 +20,8 @@ import Progress from './cli/progress'; import { languageSelection, listSelection } from './cli/selection'; import { logs } from './logs'; import { isInteractive } from './tty'; -import { getEnv, loadFilesToRun, zip } from './utils'; +import { filterFiles, getEnv, loadFilesToRun, zip } from './utils'; +import { debug } from './cli/messages'; export enum ErrorCode { Ok = 0, @@ -42,15 +43,26 @@ export const deployPackage = async ( let descriptor = await generatePackage(rootPath); const deploy = async (additionalJsons: MetaCallJSON[]) => { - // TODO: We should cache the plan and ask for it only once - const descriptor = await generatePackage(rootPath); + // Apply ignore patterns if specified + const filesToDeploy = filterFiles(descriptor.files, args['ignore']); + + if (args['ignore']?.length) { + const ignoredCount = + descriptor.files.length - filesToDeploy.length; + if (ignoredCount > 0) { + debug( + `Ignored ${ignoredCount} file(s) based on ignore patterns` + ); + } + } + const { progress, pulse, hide } = Progress(); const archive = await zip( rootPath, - descriptor.files, + filesToDeploy, progress, pulse, hide @@ -65,8 +77,7 @@ export const deployPackage = async ( descriptor.runners ); - // TODO: We can ask for environment variables here too and cache them - const env = await getEnv(rootPath); + const env = await getEnv(rootPath, args['env'], args['envFile']); info(`Deploying ${rootPath}...\n`); @@ -243,7 +254,7 @@ export const deployFromRepository = async ( const name = (await api.add(url, selectedBranch, [])).id; - const env = await getEnv(); + const env = await getEnv(undefined, args['env'], args['envFile']); const deploy = await api.deploy( name, diff --git a/src/help.ts b/src/help.ts index a33dab1..37252b5 100644 --- a/src/help.ts +++ b/src/help.ts @@ -3,27 +3,63 @@ import { ErrorCode } from './deploy'; const helpText = ` Official CLI for metacall-deploy - Usage: metacall-deploy [--args] - -Options - Alias Flag Accepts Description - -h --help string Prints a user manual to assist you in using the CLI. - -v --version nothing Prints current version of the CLI. - -a --addrepo string Deploy from repository. - -w --workdir string Accepts path to application directory. - -d --dev nothing Run CLI in dev mode (deploy locally to metacall/faas). - -n --projectName string Accepts name of the application. - -e --email string Accepts email id for authentication. - -p --password string Accepts password for authentication. - -t --token string Accepts token for authentication, either pass email & password or token. - -f --force nothing Accepts boolean value: it deletes the deployment present on an existing plan and deploys again. - -P --plan string Accepts type of plan: "Essential", "Standard", "Premium". - -i --inspect string Lists out all the deployments with specifications (it defaults to Table format, otherwise they are serialized into specified format: Table | Raw | OpenAPI). - -D --delete nothing Accepts boolean value: it provides you all the available deployment options to delete. - -l --logout nothing Accepts boolean value: use it in order to expire your current session. - -r --listPlans nothing Accepts boolean value: list all the plans that are offered in your account using it. - -u --serverUrl string Change the base URL for the FaaS. - -c --confDir string Overwrite the default configuration directory.`; + Usage: metacall-deploy [options] + +Authentication Options: + -e, --email Email for authentication + -p, --password Password for authentication + -t, --token API token for authentication (alternative to email/password) + -l, --logout Expire current session + +Deployment Options: + -w, --workdir Path to application directory (default: current directory) + -a, --addrepo Deploy from a Git repository URL + -n, --projectName Name for the deployment (default: directory name) + -P, --plan Plan type: "Essential", "Standard", or "Premium" + -f, --force Delete existing deployment and redeploy + --dryRun Show what would be deployed without deploying + +Environment Variables: + -E, --env Set environment variable (can be repeated) + --envFile Load environment variables from file (can be repeated) + +File Filtering: + -I, --ignore Ignore files matching pattern (can be repeated) + Examples: -I "*.log" -I "node_modules" + +Output Options: + -q, --quiet Suppress non-essential output + -V, --verbose Show detailed debug output + --json Output results in JSON format (for scripting) + -i, --inspect [format] List deployments (Table | Raw | OpenAPIv3) + +Management: + -D, --delete Interactively select and delete a deployment + -r, --listPlans List available subscription plans + +Advanced: + -d, --dev Run in dev mode (deploy to local metacall/faas) + -u, --serverUrl Override the FaaS base URL + -c, --confDir Override the configuration directory + +General: + -v, --version Show CLI version + -h, --help Show this help message + +Examples: + $ metacall-deploy Deploy current directory + $ metacall-deploy -w ./my-app Deploy specific directory + $ metacall-deploy -a https://github.com/... Deploy from repository + $ metacall-deploy -E DB_HOST=localhost Deploy with environment variable + $ metacall-deploy --envFile .env.prod Deploy with env file + $ metacall-deploy -I "*.test.js" -I ".git" Deploy ignoring test files + $ metacall-deploy --dryRun Preview deployment without deploying + $ metacall-deploy -i List all deployments + $ metacall-deploy -i Raw List deployments in raw format + $ metacall-deploy --json -i List deployments as JSON + +For more information, visit: https://github.com/metacall/deploy +`; export const printHelp = (): void => { console.log(helpText); diff --git a/src/index.ts b/src/index.ts index 3ab86b4..f4f09b4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,14 @@ import { promises as fs } from 'fs'; import { dirname, join } from 'path'; import args, { InspectFormat } from './cli/args'; import { inspect } from './cli/inspect'; -import { error } from './cli/messages'; +import { + debug, + error, + info, + jsonOutput, + setOutputMode, + success +} from './cli/messages'; import { handleUnknownArgs } from './cli/unknown'; import validateToken from './cli/validateToken'; import { deleteBySelection } from './delete'; @@ -17,23 +24,35 @@ import { plan } from './plan'; import { startup } from './startup'; void (async () => { + // Initialize output mode based on CLI flags + if (args['json']) { + setOutputMode('json'); + } else if (args['quiet']) { + setOutputMode('quiet'); + } else if (args['verbose']) { + setOutputMode('verbose'); + } + if (args['_unknown'].length) handleUnknownArgs(); if (args['version']) { - return console.log( - `v${ - ( - (await import( - join( - require.main - ? join(dirname(require.main.filename), '..') - : process.cwd(), - 'package.json' - ) - )) as { version: string } - ).version - }` - ); + const version = ( + (await import( + join( + require.main + ? join(dirname(require.main.filename), '..') + : process.cwd(), + 'package.json' + ) + )) as { version: string } + ).version; + + if (args['json']) { + jsonOutput({ version }); + } else { + console.log(`v${version}`); + } + return; } if (args['logout']) return logout(); @@ -44,6 +63,10 @@ void (async () => { args['dev'] ? config.devURL : config.baseURL ); + debug( + `Using API endpoint: ${args['dev'] ? config.devURL : config.baseURL}` + ); + await validateToken(api); if (args['listPlans']) return await listPlans(api); @@ -57,45 +80,95 @@ void (async () => { if (args['force']) await force(api); - // On line 63, we passed Essential to the FAAS in dev environment, - // the thing is there is no need of plans in Local Faas (--dev), - // this could have been handlled neatly if we created deploy as a State Machine, - // think about a better way - const planSelected: Plans = args['dev'] ? Plans.Essential : await plan(api); + debug(`Selected plan: ${planSelected}`); + + // Handle dry-run mode + if (args['dryRun']) { + info('Dry-run mode enabled. No deployment will be made.'); + } + if (args['addrepo']) { try { - return await deployFromRepository( - api, - planSelected, - new URL(args['addrepo']).href - ); + const repoUrl = new URL(args['addrepo']).href; + + if (args['dryRun']) { + info(`Would deploy repository: ${repoUrl}`); + info(`Plan: ${planSelected}`); + if (args['env']?.length) { + info( + `Environment variables: ${args['env'].length} specified` + ); + } + jsonOutput({ + dryRun: true, + action: 'deployRepository', + repository: repoUrl, + plan: planSelected, + envCount: args['env']?.length || 0 + }); + return; + } + + return await deployFromRepository(api, planSelected, repoUrl); } catch (e) { error(String(e)); } } - // If workdir is passed call than deploy using package + // If workdir is passed, deploy using package if (args['workdir']) { const rootPath = args['workdir']; + debug(`Deploying from directory: ${rootPath}`); + try { if (!(await fs.stat(rootPath)).isDirectory()) { return error( - `Invalid root path, ${rootPath} is not a directory.`, + `Invalid root path: "${rootPath}" is not a directory.`, ErrorCode.NotDirectoryRootPath ); } } catch (e) { return error( - `Invalid root path, ${rootPath} not found.`, + `Invalid root path: "${rootPath}" not found.`, ErrorCode.NotFoundRootPath ); } + if (args['dryRun']) { + const files = await fs.readdir(rootPath); + info(`Would deploy directory: ${rootPath}`); + info(`Project name: ${args['projectName']}`); + info(`Plan: ${planSelected}`); + info(`Files in directory: ${files.length}`); + if (args['env']?.length) { + info(`Environment variables from CLI: ${args['env'].length}`); + } + if (args['envFile']?.length) { + info(`Environment files: ${args['envFile'].join(', ')}`); + } + if (args['ignore']?.length) { + info(`Ignore patterns: ${args['ignore'].join(', ')}`); + } + jsonOutput({ + dryRun: true, + action: 'deployPackage', + directory: rootPath, + projectName: args['projectName'], + plan: planSelected, + fileCount: files.length, + envCount: args['env']?.length || 0, + envFiles: args['envFile'] || [], + ignorePatterns: args['ignore'] || [] + }); + return; + } + try { await deployPackage(rootPath, api, planSelected); + success('Deployment completed successfully!'); } catch (e) { error(String(e)); } @@ -105,6 +178,3 @@ void (async () => { config.baseURL = args['serverUrl']; } })(); - -// change all flag names to toUpperCase -// think of a way to write test for --dev flag diff --git a/src/plan.ts b/src/plan.ts index 9143ad4..ed85d78 100644 --- a/src/plan.ts +++ b/src/plan.ts @@ -40,16 +40,17 @@ export const planFetch = async ( export const plan = async (api: APIInterface): Promise => { const availPlans = Object.keys(await planFetch(api)); - let plan = - args['plan'] && availPlans.includes(args['plan']) && args['plan']; - - plan = - plan || availPlans.length === 1 - ? (availPlans[0] as Plans) - : await planSelection( - 'Please select plan from the list', - availPlans - ); - - return plan; + // If user specified a plan via CLI and it's available, use it + if (args['plan'] && availPlans.includes(args['plan'])) { + return args['plan']; + } + + // If only one plan is available, auto-select it + if (availPlans.length === 1) { + info(`Auto-selecting the only available plan: ${availPlans[0]}`); + return availPlans[0] as Plans; + } + + // Otherwise, prompt user to select from available plans + return await planSelection('Please select plan from the list', availPlans); }; diff --git a/src/utils.ts b/src/utils.ts index 5c5b54e..f4888b4 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -12,7 +12,7 @@ import { promises as fs } from 'fs'; import { prompt } from 'inquirer'; import { platform } from 'os'; import { basename, join, relative } from 'path'; -import { error, info, printLanguage } from './cli/messages'; +import { error, info, printLanguage, warn } from './cli/messages'; import { consentSelection, fileSelection } from './cli/selection'; import { isInteractive } from './tty'; @@ -72,13 +72,46 @@ export const loadFilesToRun = async ( } }; +/** + * Filter files based on ignore patterns (glob-style) + * Supports patterns like: *.log, node_modules, *.test.js, etc. + */ +export const filterFiles = ( + files: string[], + ignorePatterns?: string[] +): string[] => { + if (!ignorePatterns || ignorePatterns.length === 0) { + return files; + } + + // Convert glob patterns to regex + const patterns = ignorePatterns.map(pattern => { + // Escape regex special chars except * and ? + const regexStr = pattern + .replace(/[.+^${}()|[\]\\]/g, '\\$&') + .replace(/\*/g, '.*') + .replace(/\?/g, '.'); + + // Match the pattern anywhere in the path + return new RegExp(`(^|/)${regexStr}($|/)`); + }); + + return files.filter(file => { + // Keep file if it doesn't match any ignore pattern + return !patterns.some(pattern => pattern.test(file)); + }); +}; + export const zip = async ( source: string, files: string[], progress?: (text: string, bytes: number) => void, pulse?: (name: string) => void, - hide?: () => void + hide?: () => void, + ignorePatterns?: string[] ): Promise => { + // Apply ignore patterns + files = filterFiles(files, ignorePatterns); const archive = archiver('zip', { zlib: { level: 9 } }); @@ -113,65 +146,149 @@ export const zip = async ( return archive; }; +/** + * Parse a single KEY=VALUE string into an object entry + */ +const parseEnvString = ( + envStr: string +): { name: string; value: string } | null => { + const eqIndex = envStr.indexOf('='); + if (eqIndex === -1) { + return null; + } + const name = envStr.substring(0, eqIndex).trim(); + const value = envStr.substring(eqIndex + 1).trim(); + if (!name) { + return null; + } + return { name, value }; +}; + +/** + * Load environment variables from a .env file + */ +const loadEnvFile = async ( + filePath: string, + silent = false +): Promise<{ name: string; value: string }[]> => { + if (!(await exists(filePath))) { + if (!silent) { + warn(`Environment file not found: ${filePath}`); + } + return []; + } + + try { + const source = await fs.readFile(filePath, 'utf8'); + const parsedEnv = parse(source); + if (!silent) { + info(`Loaded environment variables from ${filePath}`); + } + return Object.entries(parsedEnv).map(([name, value]) => ({ + name, + value + })); + } catch (err) { + if (!silent) { + warn( + `Error reading environment file ${filePath}: ${ + (err as Error).message + }` + ); + } + return []; + } +}; + +/** + * Get environment variables from multiple sources: + * 1. CLI --env flags (highest priority) + * 2. CLI --envFile flags + * 3. Project .env file (if rootPath provided) + * 4. Interactive prompt (if TTY available) + */ export const getEnv = async ( - rootPath?: string + rootPath?: string, + cliEnvVars?: string[], + cliEnvFiles?: string[] ): Promise<{ name: string; value: string }[]> => { + const envMap = new Map(); + + // 1. Load from project .env file (lowest priority, will be overwritten) if (rootPath !== undefined) { - const envFilePath = join(rootPath, '.env'); - - if (await exists(envFilePath)) { - try { - const source = await fs.readFile(envFilePath, 'utf8'); - const parsedEnv = parse(source); - info( - 'Detected and loaded environment variables from .env file.' - ); - return Object.entries(parsedEnv).map(([name, value]) => ({ - name, - value - })); - } catch (err) { - error( - `Error while reading the .env file: ${( - err as Error - ).toString()}` + const defaultEnvPath = join(rootPath, '.env'); + const defaultEnvVars = await loadEnvFile(defaultEnvPath, true); + for (const { name, value } of defaultEnvVars) { + envMap.set(name, value); + } + if (defaultEnvVars.length > 0) { + info( + `Detected ${defaultEnvVars.length} environment variable(s) from .env file` + ); + } + } + + // 2. Load from --envFile flags (medium priority) + if (cliEnvFiles && cliEnvFiles.length > 0) { + for (const filePath of cliEnvFiles) { + const fileEnvVars = await loadEnvFile(filePath); + for (const { name, value } of fileEnvVars) { + envMap.set(name, value); + } + } + } + + // 3. Load from --env flags (highest priority, overwrites others) + if (cliEnvVars && cliEnvVars.length > 0) { + for (const envStr of cliEnvVars) { + const parsed = parseEnvString(envStr); + if (parsed) { + envMap.set(parsed.name, parsed.value); + } else { + warn( + `Invalid environment variable format: "${envStr}". Expected KEY=VALUE` ); } } + info(`Applied ${cliEnvVars.length} environment variable(s) from CLI`); + } + + // 4. If we already have env vars from files/CLI, return them + if (envMap.size > 0) { + return Array.from(envMap.entries()).map(([name, value]) => ({ + name, + value + })); } - // If the input is not interactive skip asking the end user + // 5. If no env vars and not interactive, return empty if (!isInteractive()) { - // TODO: We should implement support for all the inputs and prompts for non-interactive terminal return []; } + // 6. Interactive prompt for env vars const enableEnv = await consentSelection( 'Do you want to add environment variables?' ); - const env = enableEnv - ? await prompt<{ env: string }>([ - { - type: 'input', - name: 'env', - message: 'Type env vars in the format: K1=V1, K2=V2' - } - ]).then(({ env }) => - env - .split(',') - .map(kv => { - const [k, v] = kv.trim().split('='); - return { [k]: v }; - }) - .reduce((obj, kv) => Object.assign(obj, kv), {}) - ) - : {}; - - const envArr = Object.entries(env).map(el => { - const [k, v] = el; - return { name: k, value: v }; - }); + if (!enableEnv) { + return []; + } + + const { env } = await prompt<{ env: string }>([ + { + type: 'input', + name: 'env', + message: 'Type env vars in the format: K1=V1, K2=V2' + } + ]); + + const envEntries = env + .split(',') + .map(kv => parseEnvString(kv.trim())) + .filter( + (entry): entry is { name: string; value: string } => entry !== null + ); - return envArr; + return envEntries; };