diff --git a/ARCHITECTURE_AUDIT.md b/ARCHITECTURE_AUDIT.md new file mode 100644 index 0000000..662ff4b --- /dev/null +++ b/ARCHITECTURE_AUDIT.md @@ -0,0 +1,512 @@ +# EnvGuard Architecture Audit & Enterprise Readiness Assessment + +**Date**: 2025-10-26 +**Version**: Current (pre-0.3.0) +**Status**: Critical Issues Found + +--- + +## Executive Summary + +EnvGuard has **critical architectural gaps** that prevent it from being enterprise-ready: + +1. ❌ **Package naming relies on package.json** (not multi-language) +2. ❌ **config.json is underutilized** (not source of truth) +3. ❌ **No CLI commands for config management** +4. ❌ **manifest.json lacks metadata** (validators, descriptions) +5. ❌ **Environment management is hardcoded** (not configurable) + +--- + +## 🔴 Critical Issues + +### 1. Package Name Resolution + +**Current Implementation:** + +```typescript +// packages/cli/src/commands/init.action.ts:51-58 +async function detectPackageName(): Promise { + try { + const pkgJson = JSON.parse(await fs.readFile('package.json', 'utf-8')); + return pkgJson.name || 'my-app'; + } catch { + return 'my-app'; + } +} +``` + +**Problems:** + +- ❌ Requires package.json (not available in Python, Go, Rust, Java projects) +- ❌ npm names are NOT unique identifiers globally + - Example: `my-app` vs `@company/my-app` vs `other-company/my-app` +- ❌ No reverse domain notation support (like com.company.app) +- ❌ Fallback "my-app" will cause collisions in shared keychains + +**Impact:** + +- Cannot be used in non-Node.js projects +- Keychain collisions when multiple projects use default name +- Not suitable for enterprise mono repos + +--- + +### 2. Config.json Underutilization + +**Current Structure:** + +```json +{ + "package": "my-app", + "templateFile": ".env.template", + "manifestVersion": "1.0", + "defaultEnvironment": "development" +} +``` + +**Problems:** + +- ❌ Not treated as source of truth (falls back to package.json) +- ❌ Missing critical enterprise features: + - No list of available environments + - No validation mode (strict/relaxed) + - No custom manifest path + - No config schema version + - No documentation/warnings about manual editing +- ❌ No CLI commands to manage config (`envg config get/set`) +- ❌ Config can be corrupted with no recovery mechanism + +**Impact:** + +- Users manually edit config.json without validation +- No way to programmatically manage configuration +- Breaking changes can't be detected (no schema version) + +--- + +### 3. Environment Management + +**Current Implementation:** + +```typescript +// Hardcoded in code, not in config +const environment = + options.environment || + process.env.ENVGUARD_ENV || + process.env.NODE_ENV || + 'development'; +``` + +**Problems:** + +- ❌ No centralized list of allowed environments +- ❌ Typos create new environments silently (dev vs development) +- ❌ Can't enforce environment naming conventions +- ❌ No per-project environment preferences beyond default + +**Impact:** + +- Secret sprawl (secrets in unintended environments) +- No environment lifecycle management +- Can't prevent accidental production secret deletion + +--- + +### 4. Manifest Underutilization + +**Current Structure:** + +```json +{ + "packages": { + "my-app": { + "keys": [ + { "name": "API_KEY", "required": true }, + { "name": "PORT", "required": false } + ], + "lastUpdated": "2025-10-26T..." + } + } +} +``` + +**Problems:** + +- ❌ No validator metadata (url, email, regex, length, number ranges) +- ❌ No descriptions for keys (what is this secret?) +- ❌ No environment-specific requirements +- ❌ No deprecation markers +- ❌ No secret rotation metadata (lastRotated, rotationPolicy) + +**Impact:** + +- Can't validate secret values before storage +- No documentation for new team members +- Can't enforce security policies (rotation schedules) + +--- + +### 5. Missing CLI Config Commands + +**Current Commands:** + +```bash +envg init, set, get, del, list, check, migrate, +export, template, edit, show, copy, welcome +``` + +**Missing:** + +```bash +envg config get # ❌ Not available +envg config set # ❌ Not available +envg config list # ❌ Not available +envg config reset # ❌ Not available +envg config validate # ❌ Not available +envg env list # ❌ Not available +envg env add # ❌ Not available +envg env remove # ❌ Not available +``` + +**Impact:** + +- Users must manually edit config.json +- No validation on config changes +- No audit trail for config modifications + +--- + +## ✅ What's Working Well + +1. ✅ Keychain integration (macOS, Windows, Linux) +2. ✅ Multi-environment support (basic) +3. ✅ Secret migration from .env files +4. ✅ Interactive editing (`envg edit`) +5. ✅ Template generation +6. ✅ Required/optional secret marking + +--- + +## 🎯 Enterprise Requirements + +### Package Naming + +- ✅ Support reverse domain notation (com.company.product) +- ✅ Support multi-language projects (no package.json dependency) +- ✅ Globally unique identifiers +- ✅ Validation on package names +- ✅ Migration path for existing projects + +### Config as Source of Truth + +- ✅ config.json must be authoritative +- ✅ Schema versioning for migrations +- ✅ CLI commands for all config operations +- ✅ Validation before saving +- ✅ Backup/restore functionality +- ✅ Audit logging for changes + +### Environment Management + +- ✅ Explicit environment definitions in config +- ✅ Environment naming conventions +- ✅ Prevent typos (staging vs stagin) +- ✅ Per-environment security policies +- ✅ Environment lifecycle (create, deprecate, archive) + +### Manifest Enhancement + +- ✅ Validator metadata per key +- ✅ Descriptions and documentation +- ✅ Environment-specific requirements +- ✅ Secret rotation tracking +- ✅ Deprecation warnings + +### Security & Compliance + +- ✅ Audit trail for all operations +- ✅ Secret rotation policies +- ✅ Access control (future: team mode) +- ✅ Compliance reporting +- ✅ Backup encryption + +--- + +## 📐 Proposed Architecture + +### Enhanced Config.json + +```json +{ + "$schema": "https://envguard.dev/schemas/config/v2.json", + "version": "2.0.0", + "package": { + "name": "com.company.myapp", + "displayName": "My Application", + "type": "reverse-domain" + }, + "environments": { + "allowed": ["development", "staging", "production"], + "default": "development", + "naming": "strict" + }, + "paths": { + "template": ".env.template", + "manifest": ".envguard/manifest.json" + }, + "validation": { + "enabled": true, + "strictMode": true, + "enforceRotation": false + }, + "security": { + "auditLog": true, + "requireConfirmation": ["delete", "export"], + "allowedCommands": ["all"] + }, + "manifest": { + "version": "2.0.0", + "autoSync": true + }, + "_warnings": { + "manualEdit": "Editing this file manually may break EnvGuard. Use 'envg config' commands instead." + }, + "_metadata": { + "created": "2025-10-26T00:00:00.000Z", + "lastModified": "2025-10-26T00:00:00.000Z", + "modifiedBy": "envg-cli@0.3.0" + } +} +``` + +### Enhanced Manifest.json + +```json +{ + "$schema": "https://envguard.dev/schemas/manifest/v2.json", + "version": "2.0.0", + "packages": { + "com.company.myapp": { + "keys": [ + { + "name": "DATABASE_URL", + "required": true, + "description": "PostgreSQL connection string", + "validator": { + "type": "url", + "schemes": ["postgresql", "postgres"], + "requireCredentials": true + }, + "environments": { + "development": { "required": false }, + "production": { "required": true } + }, + "rotation": { + "lastRotated": "2025-10-01T00:00:00.000Z", + "policy": "90d", + "nextRotation": "2025-12-30T00:00:00.000Z" + }, + "metadata": { + "addedBy": "john@company.com", + "addedOn": "2025-01-01T00:00:00.000Z", + "category": "database" + } + }, + { + "name": "API_KEY", + "required": true, + "description": "Third-party API authentication key", + "validator": { + "type": "regex", + "pattern": "^[A-Za-z0-9]{32}$", + "message": "Must be 32 alphanumeric characters" + }, + "deprecated": { + "since": "2025-10-01", + "reason": "Migrating to OAuth2", + "replacement": "OAUTH_CLIENT_ID", + "removeAfter": "2026-01-01" + } + } + ], + "lastUpdated": "2025-10-26T00:00:00.000Z" + } + } +} +``` + +--- + +## 🚀 Implementation Plan + +### Phase 1: Config System Overhaul (v0.3.0) + +**Priority: CRITICAL** + +1. **Package Naming** + - Add `PackageNameResolver` with multiple strategies + - Support reverse domain notation + - Add validation (no spaces, valid chars, uniqueness check) + - Migration: Detect existing package names and offer upgrade + +2. **Config Schema v2** + - Implement enhanced config structure + - Add schema version field + - Add migration system for v1 → v2 + - Add config validation on load + +3. **CLI Config Commands** + + ```bash + envg config get + envg config set + envg config list + envg config validate + envg config backup + envg config restore + envg config migrate + ``` + +4. **Environment Management** + ```bash + envg env list + envg env add + envg env remove + envg env default + envg env validate + ``` + +### Phase 2: Manifest Enhancement (v0.4.0) + +1. **Manifest Schema v2** + - Add validator metadata + - Add descriptions + - Add rotation tracking + - Add deprecation support + +2. **Validation Engine** + - Implement validators (url, email, regex, length, number) + - Add validation on `envg set` + - Add bulk validation command + +3. **CLI Secret Commands Enhancement** + ```bash + envg set --validate + envg set --validator=url + envg set --description="My secret" + envg check --rotation # Check rotation policies + ``` + +### Phase 3: Enterprise Features (v0.5.0) + +1. **Audit Logging** + - Log all config changes + - Log all secret operations + - Export audit logs + +2. **Backup/Restore** + - Encrypted backups + - Point-in-time restore + - Automated backup schedules + +3. **Compliance** + - Rotation enforcement + - Expiry warnings + - Security policy templates + +--- + +## 🎓 Migration Strategy + +### For Existing Users + +**Automatic Migration:** + +```bash +envg config migrate + +# Steps: +# 1. Detect v1 config +# 2. Prompt for package name format (keep or convert) +# 3. Prompt for environment list +# 4. Create v2 config with defaults +# 5. Backup v1 config +# 6. Validate migration +# 7. Update manifest if needed +``` + +**Manual Migration:** +Users can edit config.json but will see warnings: + +```bash +⚠️ WARNING: config.json was manually edited +✓ Running validation... +✓ Config is valid +ℹ Use 'envg config validate' to check configuration +``` + +--- + +## 📊 Risk Assessment + +| Risk | Impact | Mitigation | +| ----------------------------------- | ------ | -------------------------------------------- | +| Breaking changes for existing users | HIGH | Automatic migration + backward compatibility | +| Package name collisions | MEDIUM | Validation + uniqueness check + warnings | +| Config corruption from manual edits | MEDIUM | Validation + backups + schema versioning | +| Complexity increase | LOW | Good docs + progressive disclosure | + +--- + +## 🎯 Success Metrics + +- ✅ 100% of projects can define unique package names +- ✅ Zero config.json manual edits needed +- ✅ All configuration via CLI commands +- ✅ Secret validation catches 95%+ of typos +- ✅ Migration success rate >99% +- ✅ Enterprise adoption feasible + +--- + +## 📝 Recommendations + +### Immediate Actions (Do Now) + +1. **Add `envg config` commands** (v0.2.1 patch) + - Prevents users from manually editing config + - Quick win, low complexity + +2. **Add package name validation** (v0.2.1 patch) + - Warn on non-unique names + - Suggest reverse domain notation + +3. **Add environment list to config** (v0.2.1 patch) + - `environments.allowed` field + - Validation on use + +### Short-term (v0.3.0) + +4. **Implement full config v2 schema** +5. **Add migration system** +6. **Update all docs** + +### Long-term (v0.4.0+) + +7. **Manifest v2 with validators** +8. **Audit logging** +9. **Enterprise features** + +--- + +## 🔗 Related Issues + +- Race condition in auto-load (see previous audit) +- Package name uniqueness +- Multi-language support +- Enterprise readiness + +--- + +**Next Steps**: Review this audit and decide on implementation priority. diff --git a/IMPLEMENTATION_SPEC.md b/IMPLEMENTATION_SPEC.md new file mode 100644 index 0000000..944938a --- /dev/null +++ b/IMPLEMENTATION_SPEC.md @@ -0,0 +1,1126 @@ +# EnvGuard v0.3.0 Implementation Specification + +**Epic**: Enterprise-Ready Configuration System +**Target Version**: 0.3.0 +**Status**: Planning + +--- + +## Table of Contents + +1. [Package Name Resolution](#1-package-name-resolution) +2. [Config Schema v2](#2-config-schema-v2) +3. [CLI Config Commands](#3-cli-config-commands) +4. [Environment Management](#4-environment-management) +5. [Manifest Enhancements](#5-manifest-enhancements) +6. [Migration System](#6-migration-system) +7. [Testing Strategy](#7-testing-strategy) + +--- + +## 1. Package Name Resolution + +### 1.1 PackageNameResolver + +**File**: `packages/core/src/config/package-name-resolver.ts` + +```typescript +export enum PackageNameStrategy { + AUTO = 'auto', // Try all strategies in order + REVERSE_DOMAIN = 'reverse-domain', // com.company.app + NPM = 'npm', // @scope/name or name + MANUAL = 'manual', // User-provided +} + +export interface IPackageNameOptions { + strategy?: PackageNameStrategy; + projectRoot?: string; + fallback?: string; +} + +export class PackageNameResolver { + /** + * Resolve package name using specified strategy + */ + static async resolve(options: IPackageNameOptions): Promise { + const strategy = options.strategy || PackageNameStrategy.AUTO; + + switch (strategy) { + case PackageNameStrategy.REVERSE_DOMAIN: + return await this.resolveReverseDomain(options); + + case PackageNameStrategy.NPM: + return await this.resolveFromNpm(options); + + case PackageNameStrategy.AUTO: + // Try reverse domain first, then npm, then fallback + return await this.resolveAuto(options); + + case PackageNameStrategy.MANUAL: + return this.validateAndReturn(options.fallback); + + default: + throw new Error(`Unknown strategy: ${strategy}`); + } + } + + /** + * Validate package name format + */ + static validate(name: string): { valid: boolean; error?: string } { + // No spaces + if (/\s/.test(name)) { + return { valid: false, error: 'Package name cannot contain spaces' }; + } + + // Valid characters + if (!/^[a-zA-Z0-9._-]+$/.test(name)) { + return { valid: false, error: 'Invalid characters in package name' }; + } + + // Not empty + if (name.trim().length === 0) { + return { valid: false, error: 'Package name cannot be empty' }; + } + + // Recommended: reverse domain notation + if (!/^[a-z]+\.[a-z]+(\.[a-z0-9-]+)*$/i.test(name)) { + return { + valid: true, + error: + 'WARNING: Consider using reverse domain notation (e.g., com.company.app)', + }; + } + + return { valid: true }; + } + + /** + * Suggest package name from project context + */ + static async suggest(projectRoot: string): Promise { + const suggestions: string[] = []; + + // Try to detect from various sources + const npmName = await this.detectNpmName(projectRoot); + if (npmName) { + suggestions.push(this.npmToReverseDomain(npmName)); + suggestions.push(npmName); + } + + const gitRemote = await this.detectGitRemote(projectRoot); + if (gitRemote) { + suggestions.push(this.gitToReverseDomain(gitRemote)); + } + + const dirName = await this.detectDirectoryName(projectRoot); + if (dirName) { + suggestions.push(`local.${dirName}`); + } + + return suggestions; + } + + private static async resolveFromNpm( + options: IPackageNameOptions + ): Promise { + const projectRoot = options.projectRoot || process.cwd(); + const pkgJsonPath = path.join(projectRoot, 'package.json'); + + try { + const pkgJson = JSON.parse(await fs.readFile(pkgJsonPath, 'utf-8')); + return pkgJson.name || options.fallback || 'my-app'; + } catch { + return options.fallback || 'my-app'; + } + } + + private static async resolveReverseDomain( + options: IPackageNameOptions + ): Promise { + // Check if existing config has reverse domain + const configManager = new ConfigManager(options.projectRoot); + const config = await configManager.load(); + + if (config) { + const existing = config.getPackage(); + if (this.isReverseDomain(existing)) { + return existing; + } + } + + // No existing reverse domain, return fallback + return ( + options.fallback || + this.npmToReverseDomain( + await this.detectNpmName(options.projectRoot || process.cwd()) + ) + ); + } + + private static isReverseDomain(name: string): boolean { + return /^[a-z]+\.[a-z]+(\.[a-z0-9-]+)*$/i.test(name); + } + + private static npmToReverseDomain(npmName: string): string { + // @envguard/node → dev.envguard.node + // my-app → local.my-app + + if (npmName.startsWith('@')) { + const parts = npmName.slice(1).split('/'); + return `dev.${parts.join('.')}`; + } + + return `local.${npmName}`; + } + + // ... additional helper methods +} +``` + +### 1.2 Update Init Command + +**File**: `packages/cli/src/commands/init.action.ts` + +```typescript +export async function initAction(options: InitOptions): Promise { + // ... existing code ... + + // 2. Determine package name + let packageName: string; + + if (options.package) { + // Validate provided package name + const validation = PackageNameResolver.validate(options.package); + if (!validation.valid) { + error(validation.error || 'Invalid package name'); + process.exit(1); + } + if (validation.error) { + warn(validation.error); // Show warning for non-reverse-domain + } + packageName = options.package; + } else { + // Interactive package name selection + const suggestions = await PackageNameResolver.suggest(process.cwd()); + + const answer = await inquirer.prompt([ + { + type: 'list', + name: 'packageName', + message: 'Select package identifier:', + choices: [ + ...suggestions.map((s) => ({ name: s, value: s })), + { name: 'Enter custom name', value: '__custom__' }, + ], + }, + ]); + + if (answer.packageName === '__custom__') { + const customAnswer = await inquirer.prompt([ + { + type: 'input', + name: 'customName', + message: 'Enter package name (e.g., com.company.app):', + validate: (input: string) => { + const validation = PackageNameResolver.validate(input); + return validation.valid || validation.error || false; + }, + }, + ]); + packageName = customAnswer.customName; + } else { + packageName = answer.packageName; + } + } + + // ... rest of init logic ... +} +``` + +--- + +## 2. Config Schema v2 + +### 2.1 Enhanced Config Interface + +**File**: `packages/core/src/config/config.ts` + +```typescript +export interface IPackageConfig { + name: string; + displayName?: string; + type: 'reverse-domain' | 'npm' | 'manual'; +} + +export interface IEnvironmentConfig { + allowed: string[]; + default: string; + naming: 'strict' | 'relaxed'; +} + +export interface IPathsConfig { + template: string; + manifest: string; +} + +export interface IValidationConfig { + enabled: boolean; + strictMode: boolean; + enforceRotation: boolean; +} + +export interface ISecurityConfig { + auditLog: boolean; + requireConfirmation: ('delete' | 'export' | 'migrate')[]; + allowedCommands: string[] | 'all'; +} + +export interface IManifestConfig { + version: string; + autoSync: boolean; +} + +export interface IConfigMetadata { + created: string; + lastModified: string; + modifiedBy: string; +} + +export interface IEnvGuardConfigV2 { + $schema: string; + version: '2.0.0'; + package: IPackageConfig; + environments: IEnvironmentConfig; + paths: IPathsConfig; + validation: IValidationConfig; + security: ISecurityConfig; + manifest: IManifestConfig; + _warnings?: Record; + _metadata?: IConfigMetadata; +} + +export class EnvGuardConfigV2 implements IEnvGuardConfigV2 { + $schema = 'https://envguard.dev/schemas/config/v2.json'; + version = '2.0.0' as const; + package: IPackageConfig; + environments: IEnvironmentConfig; + paths: IPathsConfig; + validation: IValidationConfig; + security: ISecurityConfig; + manifest: IManifestConfig; + _warnings?: Record; + _metadata?: IConfigMetadata; + + constructor(data: IEnvGuardConfigV2) { + this.package = data.package; + this.environments = data.environments; + this.paths = data.paths; + this.validation = data.validation; + this.security = data.security; + this.manifest = data.manifest; + this._warnings = data._warnings; + this._metadata = data._metadata; + } + + // ... accessor methods ... + + static createDefault(packageName: string): EnvGuardConfigV2 { + return new EnvGuardConfigV2({ + $schema: 'https://envguard.dev/schemas/config/v2.json', + version: '2.0.0', + package: { + name: packageName, + type: PackageNameResolver.isReverseDomain(packageName) + ? 'reverse-domain' + : 'manual', + }, + environments: { + allowed: ['development', 'staging', 'production'], + default: 'development', + naming: 'strict', + }, + paths: { + template: '.env.template', + manifest: '.envguard/manifest.json', + }, + validation: { + enabled: true, + strictMode: false, + enforceRotation: false, + }, + security: { + auditLog: false, + requireConfirmation: ['delete', 'export'], + allowedCommands: 'all', + }, + manifest: { + version: '2.0.0', + autoSync: true, + }, + _warnings: { + manualEdit: + "Editing this file manually may break EnvGuard. Use 'envg config' commands instead.", + }, + _metadata: { + created: new Date().toISOString(), + lastModified: new Date().toISOString(), + modifiedBy: `envg-cli@${require('../../package.json').version}`, + }, + }); + } +} +``` + +### 2.2 Config Migration + +**File**: `packages/core/src/config/config-migrator.ts` + +```typescript +export class ConfigMigrator { + /** + * Migrate from v1 to v2 + */ + static async migrateV1ToV2( + v1Config: EnvGuardConfig + ): Promise { + const packageName = v1Config.getPackage(); + + // Create v2 config with defaults + const v2Config = EnvGuardConfigV2.createDefault(packageName); + + // Migrate existing fields + v2Config.environments.default = v1Config.getDefaultEnvironment(); + v2Config.paths.template = v1Config.getTemplateFile(); + + // Save backup of v1 + await this.backupV1Config(v1Config); + + return v2Config; + } + + /** + * Detect config version + */ + static async detectVersion(configPath: string): Promise<'v1' | 'v2' | null> { + try { + const raw = JSON.parse(await fs.readFile(configPath, 'utf-8')); + + if (raw.version === '2.0.0') return 'v2'; + if (raw.package && typeof raw.package === 'string') return 'v1'; + + return null; + } catch { + return null; + } + } + + private static async backupV1Config(v1Config: EnvGuardConfig): Promise { + const backupPath = '.envguard/config.v1.backup.json'; + await fs.writeFile( + backupPath, + JSON.stringify(v1Config.toObject(), null, 2) + ); + } +} +``` + +--- + +## 3. CLI Config Commands + +### 3.1 Config Command Structure + +**File**: `packages/cli/src/commands/config.action.ts` + +```typescript +export interface ConfigGetOptions { + verbose?: boolean; +} + +export interface ConfigSetOptions { + verbose?: boolean; + validate?: boolean; +} + +export interface ConfigListOptions { + verbose?: boolean; + format?: 'json' | 'yaml' | 'table'; +} + +/** + * Get config value + */ +export async function configGetAction( + key: string, + options: ConfigGetOptions +): Promise { + const configManager = new ConfigManager(); + const config = await configManager.load(); + + if (!config) { + error('EnvGuard not initialized. Run "envg init" first.'); + process.exit(1); + } + + // Support dot notation: package.name, environments.default + const value = getNestedValue(config.toObject(), key); + + if (value === undefined) { + error(`Config key not found: ${key}`); + process.exit(1); + } + + console.log(value); +} + +/** + * Set config value + */ +export async function configSetAction( + key: string, + value: string, + options: ConfigSetOptions +): Promise { + const configManager = new ConfigManager(); + const config = await configManager.load(); + + if (!config) { + error('EnvGuard not initialized. Run "envg init" first.'); + process.exit(1); + } + + // Validate key is mutable + const immutableKeys = ['version', '$schema']; + if (immutableKeys.includes(key)) { + error(`Cannot modify immutable key: ${key}`); + process.exit(1); + } + + // Set value using dot notation + const updated = setNestedValue(config.toObject(), key, parseValue(value)); + + // Validate updated config + if (options.validate) { + const validation = ConfigValidator.validate(updated); + if (!validation.valid) { + error('Validation failed:'); + validation.errors.forEach((err) => error(` - ${err}`)); + process.exit(1); + } + } + + // Save + await configManager.save(new EnvGuardConfigV2(updated)); + success(`✓ Config updated: ${key} = ${value}`); +} + +/** + * List all config values + */ +export async function configListAction( + options: ConfigListOptions +): Promise { + const configManager = new ConfigManager(); + const config = await configManager.load(); + + if (!config) { + error('EnvGuard not initialized. Run "envg init" first.'); + process.exit(1); + } + + const configObj = config.toObject(); + + switch (options.format) { + case 'json': + console.log(JSON.stringify(configObj, null, 2)); + break; + + case 'yaml': + console.log(yaml.stringify(configObj)); + break; + + case 'table': + default: + displayConfigTable(configObj); + break; + } +} + +/** + * Validate config + */ +export async function configValidateAction(): Promise { + const configManager = new ConfigManager(); + const config = await configManager.load(); + + if (!config) { + error('EnvGuard not initialized. Run "envg init" first.'); + process.exit(1); + } + + const validation = ConfigValidator.validate(config.toObject()); + + if (validation.valid) { + success('✓ Config is valid'); + } else { + error('✗ Config validation failed:'); + validation.errors.forEach((err) => error(` - ${err}`)); + process.exit(1); + } +} + +/** + * Backup config + */ +export async function configBackupAction(): Promise { + const configManager = new ConfigManager(); + const config = await configManager.load(); + + if (!config) { + error('EnvGuard not initialized. Run "envg init" first.'); + process.exit(1); + } + + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const backupPath = `.envguard/backups/config.${timestamp}.json`; + + await fs.mkdir(path.dirname(backupPath), { recursive: true }); + await fs.writeFile(backupPath, JSON.stringify(config.toObject(), null, 2)); + + success(`✓ Config backed up to: ${backupPath}`); +} + +/** + * Restore config from backup + */ +export async function configRestoreAction(backupFile: string): Promise { + const configManager = new ConfigManager(); + + // Load backup + const backup = JSON.parse(await fs.readFile(backupFile, 'utf-8')); + + // Validate backup + const validation = ConfigValidator.validate(backup); + if (!validation.valid) { + error('Invalid backup file:'); + validation.errors.forEach((err) => error(` - ${err}`)); + process.exit(1); + } + + // Confirm with user + const answer = await inquirer.prompt([ + { + type: 'confirm', + name: 'confirm', + message: 'This will overwrite the current config. Continue?', + default: false, + }, + ]); + + if (!answer.confirm) { + info('Restore cancelled'); + return; + } + + // Restore + await configManager.save(new EnvGuardConfigV2(backup)); + success('✓ Config restored from backup'); +} + +/** + * Migrate config + */ +export async function configMigrateAction(): Promise { + const configManager = new ConfigManager(); + const version = await ConfigMigrator.detectVersion( + configManager.getConfigPath() + ); + + if (version === 'v2') { + info('Config is already v2. No migration needed.'); + return; + } + + if (version === 'v1') { + const v1Config = await configManager.load(); + const v2Config = await ConfigMigrator.migrateV1ToV2(v1Config!); + + await configManager.save(v2Config); + success('✓ Config migrated to v2'); + info(' Backup saved to: .envguard/config.v1.backup.json'); + return; + } + + error('Could not detect config version'); + process.exit(1); +} +``` + +### 3.2 Register Config Commands + +**File**: `packages/cli/src/cli.ts` + +```typescript +// Add config commands +program + .command('config ') + .description('Manage EnvGuard configuration') + .addCommand( + new Command('get') + .argument('', 'Config key (supports dot notation)') + .option('-v, --verbose', 'Verbose output') + .action(configGetAction) + ) + .addCommand( + new Command('set') + .argument('', 'Config key') + .argument('', 'Config value') + .option('-v, --verbose', 'Verbose output') + .option('--validate', 'Validate config after update') + .action(configSetAction) + ) + .addCommand( + new Command('list') + .option('-v, --verbose', 'Verbose output') + .option( + '-f, --format ', + 'Output format (json|yaml|table)', + 'table' + ) + .action(configListAction) + ) + .addCommand( + new Command('validate') + .description('Validate configuration') + .action(configValidateAction) + ) + .addCommand( + new Command('backup') + .description('Backup current configuration') + .action(configBackupAction) + ) + .addCommand( + new Command('restore') + .argument('', 'Backup file path') + .description('Restore configuration from backup') + .action(configRestoreAction) + ) + .addCommand( + new Command('migrate') + .description('Migrate config to latest version') + .action(configMigrateAction) + ); +``` + +--- + +## 4. Environment Management + +### 4.1 Environment Commands + +**File**: `packages/cli/src/commands/env.action.ts` + +```typescript +/** + * List all configured environments + */ +export async function envListAction(): Promise { + const configManager = new ConfigManager(); + const config = await configManager.load(); + + if (!config) { + error('EnvGuard not initialized'); + process.exit(1); + } + + const envs = config.environments.allowed; + const defaultEnv = config.environments.default; + + console.log('Configured environments:'); + envs.forEach((env) => { + const isDefault = env === defaultEnv; + console.log( + ` ${isDefault ? '●' : '○'} ${env}${isDefault ? ' (default)' : ''}` + ); + }); +} + +/** + * Add new environment + */ +export async function envAddAction(name: string): Promise { + const configManager = new ConfigManager(); + const config = await configManager.load(); + + if (!config) { + error('EnvGuard not initialized'); + process.exit(1); + } + + // Validate environment name + if (!/^[a-z0-9-]+$/i.test(name)) { + error( + 'Invalid environment name. Use lowercase letters, numbers, and hyphens.' + ); + process.exit(1); + } + + // Check if already exists + if (config.environments.allowed.includes(name)) { + warn(`Environment "${name}" already exists`); + return; + } + + // Add environment + config.environments.allowed.push(name); + await configManager.save(config); + + success(`✓ Added environment: ${name}`); +} + +/** + * Remove environment + */ +export async function envRemoveAction( + name: string, + options: { force?: boolean } +): Promise { + const configManager = new ConfigManager(); + const config = await configManager.load(); + + if (!config) { + error('EnvGuard not initialized'); + process.exit(1); + } + + // Cannot remove default environment + if (name === config.environments.default) { + error( + `Cannot remove default environment "${name}". Set a different default first.` + ); + process.exit(1); + } + + // Check if environment exists + if (!config.environments.allowed.includes(name)) { + error(`Environment "${name}" not found`); + process.exit(1); + } + + // Check if environment has secrets + const manifestManager = new ManifestManager(); + const keys = await manifestManager.listKeys(config.package.name); + + // Check if any secrets exist for this environment + const hasSecrets = await this.checkEnvironmentHasSecrets( + config.package.name, + name, + keys + ); + + if (hasSecrets && !options.force) { + error(`Environment "${name}" has secrets. Use --force to remove anyway.`); + info('Warning: This will NOT delete the secrets from the keychain.'); + process.exit(1); + } + + // Remove environment + config.environments.allowed = config.environments.allowed.filter( + (e) => e !== name + ); + await configManager.save(config); + + success(`✓ Removed environment: ${name}`); + if (hasSecrets) { + warn(' Note: Secrets for this environment are still in the keychain.'); + info(' Use "envg del --env ' + name + '" to delete them.'); + } +} + +/** + * Set default environment + */ +export async function envDefaultAction(name: string): Promise { + const configManager = new ConfigManager(); + const config = await configManager.load(); + + if (!config) { + error('EnvGuard not initialized'); + process.exit(1); + } + + // Check if environment exists + if (!config.environments.allowed.includes(name)) { + error(`Environment "${name}" not found`); + info('Available environments: ' + config.environments.allowed.join(', ')); + process.exit(1); + } + + // Set default + config.environments.default = name; + await configManager.save(config); + + success(`✓ Default environment set to: ${name}`); +} + +/** + * Validate environment name against config + */ +export async function envValidateAction(name: string): Promise { + const configManager = new ConfigManager(); + const config = await configManager.load(); + + if (!config) { + error('EnvGuard not initialized'); + process.exit(1); + } + + if (config.environments.naming === 'strict') { + if (!config.environments.allowed.includes(name)) { + error(`✗ Invalid environment: "${name}"`); + info('Allowed environments: ' + config.environments.allowed.join(', ')); + process.exit(1); + } + } + + success(`✓ Valid environment: ${name}`); +} +``` + +--- + +## 5. Manifest Enhancements + +**File**: `packages/core/src/manifest/manifest.ts` (v2) + +```typescript +export interface IKeyValidator { + type: 'url' | 'email' | 'regex' | 'length' | 'number'; + pattern?: string; + message?: string; + schemes?: string[]; // For URL validator + requireCredentials?: boolean; // For URL validator + minLength?: number; // For length validator + maxLength?: number; // For length validator + min?: number; // For number validator + max?: number; // For number validator +} + +export interface IEnvironmentRequirement { + required?: boolean; +} + +export interface IKeyRotation { + lastRotated: string; + policy: string; // e.g., "90d", "6m", "1y" + nextRotation: string; +} + +export interface IKeyDeprecation { + since: string; + reason: string; + replacement?: string; + removeAfter: string; +} + +export interface IKeyMetadata { + addedBy?: string; + addedOn?: string; + category?: string; +} + +export interface IKeyMetadataV2 { + name: string; + required: boolean; + description?: string; + validator?: IKeyValidator; + environments?: Record; + rotation?: IKeyRotation; + deprecated?: IKeyDeprecation; + metadata?: IKeyMetadata; +} + +export interface IManifestV2 { + $schema: string; + version: '2.0.0'; + packages: Record; +} +``` + +--- + +## 6. Migration System + +### 6.1 Automatic Migration on Commands + +All commands should detect and auto-migrate: + +```typescript +export abstract class BaseAction { + protected async ensureConfigV2(): Promise { + const configManager = new ConfigManager(); + const version = await ConfigMigrator.detectVersion( + configManager.getConfigPath() + ); + + if (version === 'v1') { + info('Migrating config to v2...'); + const v1Config = await configManager.load(); + const v2Config = await ConfigMigrator.migrateV1ToV2(v1Config!); + await configManager.save(v2Config); + success('✓ Config migrated'); + return v2Config; + } + + return (await configManager.load()) as EnvGuardConfigV2; + } +} +``` + +--- + +## 7. Testing Strategy + +### 7.1 Unit Tests + +```typescript +describe('PackageNameResolver', () => { + it('should validate reverse domain notation', () => { + expect(PackageNameResolver.validate('com.company.app').valid).toBe(true); + expect(PackageNameResolver.validate('my app').valid).toBe(false); + }); + + it('should suggest package names', async () => { + const suggestions = await PackageNameResolver.suggest('/path/to/project'); + expect(suggestions).toContain('local.my-app'); + }); +}); + +describe('ConfigMigrator', () => { + it('should migrate v1 to v2', async () => { + const v1 = new EnvGuardConfig({ + package: 'my-app', + templateFile: '.env.template', + manifestVersion: '1.0', + defaultEnvironment: 'development', + }); + + const v2 = await ConfigMigrator.migrateV1ToV2(v1); + expect(v2.version).toBe('2.0.0'); + expect(v2.package.name).toBe('my-app'); + }); +}); +``` + +### 7.2 Integration Tests + +```typescript +describe('envg config commands', () => { + it('should get config value', async () => { + execSync('envg config get package.name'); + // Assert output + }); + + it('should set config value', async () => { + execSync('envg config set environments.default staging'); + // Assert config updated + }); +}); +``` + +### 7.3 E2E Tests + +```typescript +describe('E2E: Config Management', () => { + it('should complete full config lifecycle', async () => { + // 1. Init with custom package name + execSync('envg init --package com.test.app'); + + // 2. List config + const config = JSON.parse( + execSync('envg config list --format json').toString() + ); + expect(config.package.name).toBe('com.test.app'); + + // 3. Add environment + execSync('envg env add test'); + + // 4. Set default + execSync('envg env default test'); + + // 5. Validate + execSync('envg config validate'); + }); +}); +``` + +--- + +## Implementation Checklist + +### Phase 1: Foundation (Week 1) + +- [ ] Implement `PackageNameResolver` +- [ ] Add package name validation +- [ ] Create `EnvGuardConfigV2` interface +- [ ] Implement `ConfigMigrator` +- [ ] Update `init` command to use new resolver +- [ ] Add unit tests + +### Phase 2: Config Commands (Week 2) + +- [ ] Implement `config get` command +- [ ] Implement `config set` command +- [ ] Implement `config list` command +- [ ] Implement `config validate` command +- [ ] Implement `config backup` command +- [ ] Implement `config restore` command +- [ ] Implement `config migrate` command +- [ ] Add integration tests + +### Phase 3: Environment Management (Week 3) + +- [ ] Implement `env list` command +- [ ] Implement `env add` command +- [ ] Implement `env remove` command +- [ ] Implement `env default` command +- [ ] Implement `env validate` command +- [ ] Add environment validation to all commands +- [ ] Add integration tests + +### Phase 4: Documentation & Polish (Week 4) + +- [ ] Update README with new commands +- [ ] Add migration guide +- [ ] Add configuration reference docs +- [ ] Update CHANGELOG +- [ ] Add E2E tests +- [ ] Performance testing +- [ ] Security audit + +--- + +## Success Criteria + +- [ ] All existing tests pass +- [ ] 95%+ code coverage for new code +- [ ] Automatic migration works for 100% of v1 configs +- [ ] All new commands have help text and examples +- [ ] Documentation is complete +- [ ] No breaking changes for users who use CLI only (auto-migration) +- [ ] Performance: Config operations <100ms + +--- + +## Risk Mitigation + +1. **Breaking Changes**: Auto-migration on first command run +2. **Data Loss**: Backups created automatically +3. **Complexity**: Progressive disclosure in CLI +4. **Performance**: Config caching +5. **Compatibility**: Support both v1 and v2 during transition period + +--- + +**Next Steps**: Get approval and start Phase 1 implementation. diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index d89bdde..3f65644 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -3,6 +3,13 @@ import { program } from 'commander'; import { checkAction, + configBackupAction, + configGetAction, + configListAction, + configMigrateAction, + configRestoreAction, + configSetAction, + configValidateAction, copyAction, delAction, editAction, @@ -242,6 +249,69 @@ program } ); +// Config command with subcommands +const configCommand = program + .command('config') + .description('Manage EnvGuard configuration'); + +configCommand + .command('get ') + .description('Get a config value (supports dot notation)') + .option('-v, --verbose', 'Enable verbose logging', false) + .action(async (key: string, options) => { + await configGetAction(key, options); + }); + +configCommand + .command('set ') + .description('Set a config value (supports dot notation)') + .option('-v, --verbose', 'Enable verbose logging', false) + .action(async (key: string, value: string, options) => { + await configSetAction(key, value, options); + }); + +configCommand + .command('list') + .description('List all config values') + .option('-v, --verbose', 'Enable verbose logging', false) + .action(async (options) => { + await configListAction(options); + }); + +configCommand + .command('validate') + .description('Validate current config') + .option('-v, --verbose', 'Enable verbose logging', false) + .action(async (options) => { + await configValidateAction(options); + }); + +configCommand + .command('backup') + .description('Backup current config') + .option('-o, --output ', 'Output path for backup file') + .option('-v, --verbose', 'Enable verbose logging', false) + .action(async (options) => { + await configBackupAction(options); + }); + +configCommand + .command('restore ') + .description('Restore config from backup') + .option('-v, --verbose', 'Enable verbose logging', false) + .action(async (file: string, options) => { + await configRestoreAction(file, options); + }); + +configCommand + .command('migrate') + .description('Migrate config from v1 to v2') + .option('--no-backup', 'Skip creating backup before migration') + .option('-v, --verbose', 'Enable verbose logging', false) + .action(async (options) => { + await configMigrateAction(options); + }); + program .command('status') .description('Show current EnvGuard status and configuration') diff --git a/packages/cli/src/commands/config.action.ts b/packages/cli/src/commands/config.action.ts new file mode 100644 index 0000000..2b5e803 --- /dev/null +++ b/packages/cli/src/commands/config.action.ts @@ -0,0 +1,462 @@ +/** + * @module @envguard/cli/commands + * @file config.action.ts + * @description Implementation of config management commands + */ + +import fs from 'fs/promises'; +import path from 'path'; +import { + ConfigManager, + EnvGuardConfigV2, + ConfigMigrator, +} from '@envguard/core'; +import { error, info, success, warn } from '../utils/logger'; + +/** + * Options for config get command + */ +export interface ConfigGetOptions { + verbose?: boolean; +} + +/** + * Options for config set command + */ +export interface ConfigSetOptions { + verbose?: boolean; +} + +/** + * Options for config list command + */ +export interface ConfigListOptions { + verbose?: boolean; +} + +/** + * Options for config validate command + */ +export interface ConfigValidateOptions { + verbose?: boolean; +} + +/** + * Options for config backup command + */ +export interface ConfigBackupOptions { + output?: string; + verbose?: boolean; +} + +/** + * Options for config restore command + */ +export interface ConfigRestoreOptions { + verbose?: boolean; +} + +/** + * Options for config migrate command + */ +export interface ConfigMigrateOptions { + backup?: boolean; + verbose?: boolean; +} + +/** + * Get a config value by key (supports dot notation) + * + * @param key - Config key (e.g., "package.name", "environments.default") + * @param options - Command options + */ +export async function configGetAction( + key: string, + options: ConfigGetOptions +): Promise { + try { + const configManager = new ConfigManager(); + const config = await configManager.load(); + + if (!config) { + error('EnvGuard not initialized. Run "envg init" first.'); + process.exit(1); + } + + // Handle both v1 and v2 configs + const configObj = config.toObject(); + const value = getNestedValue(configObj, key); + + if (value === undefined) { + warn(`Config key "${key}" not found`); + process.exit(1); + } + + // Pretty print the value + if (typeof value === 'object') { + console.log(JSON.stringify(value, null, 2)); + } else { + console.log(value); + } + } catch (err) { + error(`Failed to get config: ${(err as Error).message}`); + process.exit(1); + } +} + +/** + * Set a config value by key (supports dot notation) + * + * @param key - Config key (e.g., "package.displayName") + * @param value - Value to set + * @param options - Command options + */ +export async function configSetAction( + key: string, + value: string, + options: ConfigSetOptions +): Promise { + try { + const configManager = new ConfigManager(); + let config = await configManager.load(); + + if (!config) { + error('EnvGuard not initialized. Run "envg init" first.'); + process.exit(1); + } + + // Auto-migrate to v2 if needed + if (!(config instanceof EnvGuardConfigV2)) { + info('Migrating config to v2...'); + config = await configManager.loadOrMigrate(); + if (!config) { + error('Migration failed'); + process.exit(1); + } + } + + // Set the value + const success = setNestedValue(config, key, value); + if (!success) { + error(`Cannot set config key "${key}"`); + process.exit(1); + } + + // Update metadata + config.updateMetadata('envg-cli'); + + // Save + await configManager.save(config); + + info(`✓ Updated ${key} = ${value}`); + } catch (err) { + error(`Failed to set config: ${(err as Error).message}`); + process.exit(1); + } +} + +/** + * List all config values + * + * @param options - Command options + */ +export async function configListAction( + options: ConfigListOptions +): Promise { + try { + const configManager = new ConfigManager(); + const config = await configManager.load(); + + if (!config) { + error('EnvGuard not initialized. Run "envg init" first.'); + process.exit(1); + } + + const configObj = config.toObject(); + + // Display config + info('EnvGuard Configuration:'); + console.log(JSON.stringify(configObj, null, 2)); + } catch (err) { + error(`Failed to list config: ${(err as Error).message}`); + process.exit(1); + } +} + +/** + * Validate current config + * + * @param options - Command options + */ +export async function configValidateAction( + options: ConfigValidateOptions +): Promise { + try { + const configManager = new ConfigManager(); + const config = await configManager.load(); + + if (!config) { + error('EnvGuard not initialized. Run "envg init" first.'); + process.exit(1); + } + + const isValid = config.isValid(); + + if (isValid) { + success('✓ Config is valid'); + } else { + error('✗ Config is invalid'); + + // Provide detailed validation errors for v2 + if (config instanceof EnvGuardConfigV2) { + const issues: string[] = []; + + if (!config.package.name || config.package.name.trim().length === 0) { + issues.push('- package.name is empty'); + } + + if ( + !config.environments.allowed || + config.environments.allowed.length === 0 + ) { + issues.push('- environments.allowed is empty'); + } + + if ( + !config.environments.allowed.includes(config.environments.default) + ) { + issues.push( + `- environments.default "${config.environments.default}" not in allowed list` + ); + } + + if ( + !config.paths.template || + config.paths.template.trim().length === 0 + ) { + issues.push('- paths.template is empty'); + } + + if (issues.length > 0) { + error('Validation issues:'); + issues.forEach((issue) => console.log(issue)); + } + } + + process.exit(1); + } + } catch (err) { + error(`Failed to validate config: ${(err as Error).message}`); + process.exit(1); + } +} + +/** + * Backup current config + * + * @param options - Command options + */ +export async function configBackupAction( + options: ConfigBackupOptions +): Promise { + try { + const configManager = new ConfigManager(); + const config = await configManager.load(); + + if (!config) { + error('EnvGuard not initialized. Run "envg init" first.'); + process.exit(1); + } + + // Determine backup path + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const defaultBackupPath = path.join( + process.cwd(), + '.envguard', + `config.backup.${timestamp}.json` + ); + const backupPath = options.output || defaultBackupPath; + + // Ensure directory exists + await fs.mkdir(path.dirname(backupPath), { recursive: true }); + + // Write backup + await fs.writeFile(backupPath, JSON.stringify(config.toObject(), null, 2)); + + success(`✓ Config backed up to: ${backupPath}`); + } catch (err) { + error(`Failed to backup config: ${(err as Error).message}`); + process.exit(1); + } +} + +/** + * Restore config from backup + * + * @param backupPath - Path to backup file + * @param options - Command options + */ +export async function configRestoreAction( + backupPath: string, + options: ConfigRestoreOptions +): Promise { + try { + // Read backup file + const backupContent = await fs.readFile(backupPath, 'utf-8'); + const backupData = JSON.parse(backupContent); + + // Validate backup data + const version = await ConfigMigrator.detectVersion(backupPath); + if (!version) { + error('Invalid backup file format'); + process.exit(1); + } + + // Create current backup before restoring + info('Creating backup of current config...'); + const configManager = new ConfigManager(); + const currentConfig = await configManager.load(); + + if (currentConfig) { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const autoBackupPath = path.join( + process.cwd(), + '.envguard', + `config.pre-restore.${timestamp}.json` + ); + await fs.writeFile( + autoBackupPath, + JSON.stringify(currentConfig.toObject(), null, 2) + ); + info(`Current config backed up to: ${autoBackupPath}`); + } + + // Write restored config + const configPath = configManager.getConfigPath(); + await fs.writeFile(configPath, JSON.stringify(backupData, null, 2)); + + success(`✓ Config restored from: ${backupPath}`); + info('Run "envg config validate" to verify the restored config'); + } catch (err) { + error(`Failed to restore config: ${(err as Error).message}`); + process.exit(1); + } +} + +/** + * Migrate config from v1 to v2 + * + * @param options - Command options + */ +export async function configMigrateAction( + options: ConfigMigrateOptions +): Promise { + try { + const configManager = new ConfigManager(); + const configPath = configManager.getConfigPath(); + + // Check version + const version = await ConfigMigrator.detectVersion(configPath); + + if (version === 'v2') { + info('Config is already v2'); + return; + } + + if (version !== 'v1') { + error('No valid config found to migrate'); + process.exit(1); + } + + // Load v1 config + const config = await configManager.load(); + if (!config) { + error('Failed to load config'); + process.exit(1); + } + + info('Migrating config from v1 to v2...'); + + // Perform migration + const result = await ConfigMigrator.performMigration(configPath, config); + + if (!result.success) { + error(`Migration failed: ${result.error}`); + process.exit(1); + } + + success('✓ Config migrated to v2'); + if (result.backupPath) { + info(`v1 backup saved to: ${result.backupPath}`); + } + info('Run "envg config validate" to verify the migrated config'); + } catch (err) { + error(`Failed to migrate config: ${(err as Error).message}`); + process.exit(1); + } +} + +/** + * Get nested value from object using dot notation + * + * @param obj - Object to get value from + * @param key - Dot-notation key (e.g., "package.name") + * @returns Value or undefined + */ +function getNestedValue(obj: any, key: string): any { + const keys = key.split('.'); + let value = obj; + + for (const k of keys) { + if (value && typeof value === 'object' && k in value) { + value = value[k]; + } else { + return undefined; + } + } + + return value; +} + +/** + * Set nested value in object using dot notation + * + * @param obj - Object to set value in + * @param key - Dot-notation key + * @param value - Value to set + * @returns True if successful + */ +function setNestedValue(obj: any, key: string, value: string): boolean { + const keys = key.split('.'); + const lastKey = keys.pop(); + + if (!lastKey) { + return false; + } + + let target = obj; + for (const k of keys) { + if (!(k in target) || typeof target[k] !== 'object') { + return false; + } + target = target[k]; + } + + // Type conversion for known fields + if (lastKey === 'allowed' && Array.isArray(target[lastKey])) { + // Parse as array + try { + target[lastKey] = JSON.parse(value); + } catch { + return false; + } + } else if (typeof target[lastKey] === 'boolean') { + target[lastKey] = value === 'true'; + } else if (typeof target[lastKey] === 'number') { + target[lastKey] = Number(value); + } else { + target[lastKey] = value; + } + + return true; +} diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index ed5328b..1ed5d6c 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -11,3 +11,12 @@ export { templateAction } from './template.action'; export { editAction } from './edit.action'; export { showAction } from './show.action'; export { copyAction } from './copy.action'; +export { + configGetAction, + configSetAction, + configListAction, + configValidateAction, + configBackupAction, + configRestoreAction, + configMigrateAction, +} from './config.action'; diff --git a/packages/cli/src/commands/init.action.ts b/packages/cli/src/commands/init.action.ts index cbebd5c..ae63ad6 100644 --- a/packages/cli/src/commands/init.action.ts +++ b/packages/cli/src/commands/init.action.ts @@ -5,7 +5,8 @@ */ import fs from 'fs/promises'; -import { ConfigManager } from '@envguard/core'; +import inquirer from 'inquirer'; +import { ConfigManager, PackageNameResolver } from '@envguard/core'; import { TemplateFileFinder } from '../utils/template-finder'; import { error, info, success, warn } from '../utils/logger'; @@ -46,15 +47,76 @@ async function createDefaultTemplateFile(filename: string): Promise { } /** - * Detect package name from package.json + * Prompt user to select or enter package name + * + * @returns Selected package name */ -async function detectPackageName(): Promise { - try { - const pkgJson = JSON.parse(await fs.readFile('package.json', 'utf-8')); - return pkgJson.name || 'my-app'; - } catch { - return 'my-app'; +async function promptPackageName(): Promise { + // Get suggestions from project context + const suggestions = await PackageNameResolver.suggest(process.cwd()); + + // If we have no suggestions, ask user to enter manually + if (suggestions.length === 0) { + const answer = await inquirer.prompt([ + { + type: 'input', + name: 'packageName', + message: 'Enter package name (e.g., com.company.app):', + default: 'local.my-app', + validate: (input: string) => { + const validation = PackageNameResolver.validate(input); + if (!validation.valid) { + return validation.error || 'Invalid package name'; + } + if (validation.error) { + warn(validation.error); + } + return true; + }, + }, + ]); + return answer.packageName; + } + + // Show suggestions with option for custom input + const answer = await inquirer.prompt([ + { + type: 'list', + name: 'packageName', + message: 'Select package identifier:', + choices: [ + ...suggestions.map((s) => ({ + name: `${s}${PackageNameResolver.isReverseDomain(s) ? ' (recommended)' : ''}`, + value: s, + })), + { name: '→ Enter custom name', value: '__custom__' }, + ], + }, + ]); + + // If custom selected, prompt for input + if (answer.packageName === '__custom__') { + const customAnswer = await inquirer.prompt([ + { + type: 'input', + name: 'customName', + message: 'Enter package name (e.g., com.company.app):', + validate: (input: string) => { + const validation = PackageNameResolver.validate(input); + if (!validation.valid) { + return validation.error || 'Invalid package name'; + } + if (validation.error) { + warn(validation.error); + } + return true; + }, + }, + ]); + return customAnswer.customName; } + + return answer.packageName; } /** @@ -84,11 +146,21 @@ export async function initAction(options: InitOptions): Promise { let packageName: string; if (options.package) { + // Validate provided package name + const validation = PackageNameResolver.validate(options.package); + if (!validation.valid) { + error(validation.error || 'Invalid package name'); + process.exit(1); + } + if (validation.error) { + warn(validation.error); // Show warning for non-reverse-domain names + } packageName = options.package; info(`Using package name: ${packageName}`); } else { - packageName = await detectPackageName(); - info(`Auto-detected package name: ${packageName}`); + // Interactive package name selection + packageName = await promptPackageName(); + info(`Selected package name: ${packageName}`); } // 3. Determine template file @@ -130,8 +202,8 @@ export async function initAction(options: InitOptions): Promise { } } - // 4. Create config - await configManager.create(packageName, templateFile); + // 4. Create config (v2) + await configManager.createV2(packageName); // 5. Show success message success('\n✓ EnvGuard initialized successfully!'); diff --git a/packages/core/__tests__/config/config-migrator.test.ts b/packages/core/__tests__/config/config-migrator.test.ts new file mode 100644 index 0000000..5e682d0 --- /dev/null +++ b/packages/core/__tests__/config/config-migrator.test.ts @@ -0,0 +1,251 @@ +/** + * @file config-migrator.test.ts + * @description Tests for ConfigMigrator + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'fs/promises'; +import path from 'path'; +import { ConfigMigrator } from '../../src/config/config-migrator'; +import { EnvGuardConfig, EnvGuardConfigV2 } from '../../src/config/config'; + +describe('ConfigMigrator', () => { + const testDir = path.join(process.cwd(), '__test-config-migrator__'); + const configPath = path.join(testDir, '.envguard', 'config.json'); + + beforeEach(async () => { + await fs.mkdir(path.dirname(configPath), { recursive: true }); + }); + + afterEach(async () => { + await fs.rm(testDir, { recursive: true, force: true }); + }); + + describe('detectVersion', () => { + it('should detect v2 config', async () => { + const v2Config = EnvGuardConfigV2.createDefault('com.test.app'); + await fs.writeFile( + configPath, + JSON.stringify(v2Config.toObject(), null, 2) + ); + + const version = await ConfigMigrator.detectVersion(configPath); + expect(version).toBe('v2'); + }); + + it('should detect v1 config', async () => { + const v1Config = { + package: 'my-app', + templateFile: '.env.template', + manifestVersion: '1.0', + defaultEnvironment: 'development', + }; + await fs.writeFile(configPath, JSON.stringify(v1Config, null, 2)); + + const version = await ConfigMigrator.detectVersion(configPath); + expect(version).toBe('v1'); + }); + + it('should return null for non-existent file', async () => { + const version = await ConfigMigrator.detectVersion('non-existent.json'); + expect(version).toBeNull(); + }); + + it('should return null for invalid config', async () => { + await fs.writeFile( + configPath, + JSON.stringify({ invalid: 'config' }, null, 2) + ); + + const version = await ConfigMigrator.detectVersion(configPath); + expect(version).toBeNull(); + }); + }); + + describe('migrateV1ToV2', () => { + it('should migrate v1 config to v2', () => { + const v1Config = new EnvGuardConfig({ + package: 'my-app', + templateFile: '.env.example', + manifestVersion: '1.0', + defaultEnvironment: 'staging', + }); + + const v2Config = ConfigMigrator.migrateV1ToV2(v1Config); + + expect(v2Config.version).toBe('2.0.0'); + expect(v2Config.package.name).toBe('my-app'); + expect(v2Config.environments.default).toBe('staging'); + expect(v2Config.paths.template).toBe('.env.example'); + expect(v2Config.manifest.version).toBe('1.0'); + }); + + it('should preserve template file path', () => { + const v1Config = new EnvGuardConfig({ + package: 'test-app', + templateFile: 'config/.env.template', + manifestVersion: '1.0', + defaultEnvironment: 'development', + }); + + const v2Config = ConfigMigrator.migrateV1ToV2(v1Config); + + expect(v2Config.paths.template).toBe('config/.env.template'); + }); + + it('should detect package type correctly', () => { + const v1ReverseConfig = new EnvGuardConfig({ + package: 'com.company.app', + templateFile: '.env.template', + manifestVersion: '1.0', + defaultEnvironment: 'development', + }); + + const v2Config = ConfigMigrator.migrateV1ToV2(v1ReverseConfig); + + expect(v2Config.package.type).toBe('reverse-domain'); + }); + }); + + describe('backupV1Config', () => { + it('should create backup file', async () => { + const v1Config = new EnvGuardConfig({ + package: 'my-app', + templateFile: '.env.template', + manifestVersion: '1.0', + defaultEnvironment: 'development', + }); + + const backupPath = await ConfigMigrator.backupV1Config(v1Config, testDir); + + expect(backupPath).toContain('.envguard/config.v1.backup'); + expect(backupPath).toContain('.json'); + + const backupExists = await fs + .access(backupPath) + .then(() => true) + .catch(() => false); + expect(backupExists).toBe(true); + + const backupContent = JSON.parse(await fs.readFile(backupPath, 'utf-8')); + expect(backupContent.package).toBe('my-app'); + }); + }); + + describe('performMigration', () => { + it('should perform full migration with backup', async () => { + const v1Config = new EnvGuardConfig({ + package: 'my-app', + templateFile: '.env.template', + manifestVersion: '1.0', + defaultEnvironment: 'development', + }); + + await fs.writeFile( + configPath, + JSON.stringify(v1Config.toObject(), null, 2) + ); + + const result = await ConfigMigrator.performMigration( + configPath, + v1Config + ); + + expect(result.success).toBe(true); + expect(result.version).toBe('v2'); + expect(result.backupPath).toBeDefined(); + + // Check that config was actually migrated + const version = await ConfigMigrator.detectVersion(configPath); + expect(version).toBe('v2'); + }); + + it('should handle migration errors gracefully', async () => { + const v1Config = new EnvGuardConfig({ + package: 'my-app', + templateFile: '.env.template', + manifestVersion: '1.0', + defaultEnvironment: 'development', + }); + + // Use invalid path to trigger error + const result = await ConfigMigrator.performMigration( + '/invalid/path/config.json', + v1Config + ); + + expect(result.success).toBe(false); + expect(result.version).toBe('v1'); + expect(result.error).toBeDefined(); + }); + }); + + describe('needsMigration', () => { + it('should return true for v1 config', async () => { + const v1Config = { + package: 'my-app', + templateFile: '.env.template', + manifestVersion: '1.0', + defaultEnvironment: 'development', + }; + await fs.writeFile(configPath, JSON.stringify(v1Config, null, 2)); + + const needsMigration = await ConfigMigrator.needsMigration(configPath); + expect(needsMigration).toBe(true); + }); + + it('should return false for v2 config', async () => { + const v2Config = EnvGuardConfigV2.createDefault('com.test.app'); + await fs.writeFile( + configPath, + JSON.stringify(v2Config.toObject(), null, 2) + ); + + const needsMigration = await ConfigMigrator.needsMigration(configPath); + expect(needsMigration).toBe(false); + }); + + it('should return false for non-existent config', async () => { + const needsMigration = + await ConfigMigrator.needsMigration('non-existent.json'); + expect(needsMigration).toBe(false); + }); + }); + + describe('loadConfig', () => { + it('should load v1 config', async () => { + const v1Config = { + package: 'my-app', + templateFile: '.env.template', + manifestVersion: '1.0', + defaultEnvironment: 'development', + }; + await fs.writeFile(configPath, JSON.stringify(v1Config, null, 2)); + + const config = await ConfigMigrator.loadConfig(configPath); + + expect(config).toBeInstanceOf(EnvGuardConfig); + expect((config as EnvGuardConfig).getPackage()).toBe('my-app'); + }); + + it('should load v2 config', async () => { + const v2Config = EnvGuardConfigV2.createDefault('com.test.app'); + await fs.writeFile( + configPath, + JSON.stringify(v2Config.toObject(), null, 2) + ); + + const config = await ConfigMigrator.loadConfig(configPath); + + expect(config).toBeInstanceOf(EnvGuardConfigV2); + expect((config as EnvGuardConfigV2).getPackageName()).toBe( + 'com.test.app' + ); + }); + + it('should return null for non-existent file', async () => { + const config = await ConfigMigrator.loadConfig('non-existent.json'); + expect(config).toBeNull(); + }); + }); +}); diff --git a/packages/core/__tests__/config/config-v2.test.ts b/packages/core/__tests__/config/config-v2.test.ts new file mode 100644 index 0000000..40afa0b --- /dev/null +++ b/packages/core/__tests__/config/config-v2.test.ts @@ -0,0 +1,217 @@ +/** + * @file config-v2.test.ts + * @description Tests for EnvGuardConfigV2 + */ + +import { describe, it, expect } from 'vitest'; +import { EnvGuardConfigV2 } from '../../src/config/config'; + +describe('EnvGuardConfigV2', () => { + describe('createDefault', () => { + it('should create default v2 config with reverse domain package', () => { + const config = EnvGuardConfigV2.createDefault('com.company.app'); + + expect(config.version).toBe('2.0.0'); + expect(config.package.name).toBe('com.company.app'); + expect(config.package.type).toBe('reverse-domain'); + expect(config.environments.allowed).toEqual([ + 'development', + 'staging', + 'production', + ]); + expect(config.environments.default).toBe('development'); + expect(config.paths.template).toBe('.env.template'); + expect(config.paths.manifest).toBe('.envguard/manifest.json'); + }); + + it('should create default v2 config with npm package', () => { + const config = EnvGuardConfigV2.createDefault('@company/app'); + + expect(config.package.name).toBe('@company/app'); + expect(config.package.type).toBe('npm'); + }); + + it('should create default v2 config with manual package', () => { + const config = EnvGuardConfigV2.createDefault('my-app'); + + expect(config.package.name).toBe('my-app'); + expect(config.package.type).toBe('manual'); + }); + + it('should include metadata', () => { + const config = EnvGuardConfigV2.createDefault('com.test.app'); + + expect(config._metadata).toBeDefined(); + expect(config._metadata?.created).toBeDefined(); + expect(config._metadata?.lastModified).toBeDefined(); + expect(config._metadata?.modifiedBy).toContain('envg-cli'); + }); + + it('should include warnings', () => { + const config = EnvGuardConfigV2.createDefault('com.test.app'); + + expect(config._warnings).toBeDefined(); + expect(config._warnings?.manualEdit).toContain('envg config'); + }); + }); + + describe('getters', () => { + const config = EnvGuardConfigV2.createDefault('com.test.app'); + + it('should get package name', () => { + expect(config.getPackageName()).toBe('com.test.app'); + }); + + it('should get display name or package name', () => { + expect(config.getPackageDisplayName()).toBe('com.test.app'); + + config.package.displayName = 'Test App'; + expect(config.getPackageDisplayName()).toBe('Test App'); + }); + + it('should get template file', () => { + expect(config.getTemplateFile()).toBe('.env.template'); + }); + + it('should get manifest file', () => { + expect(config.getManifestFile()).toBe('.envguard/manifest.json'); + }); + + it('should get default environment', () => { + expect(config.getDefaultEnvironment()).toBe('development'); + }); + + it('should get allowed environments', () => { + expect(config.getAllowedEnvironments()).toEqual([ + 'development', + 'staging', + 'production', + ]); + }); + + it('should get manifest version', () => { + expect(config.getManifestVersion()).toBe('2.0.0'); + }); + }); + + describe('isEnvironmentAllowed', () => { + const config = EnvGuardConfigV2.createDefault('com.test.app'); + + it('should return true for allowed environments', () => { + expect(config.isEnvironmentAllowed('development')).toBe(true); + expect(config.isEnvironmentAllowed('staging')).toBe(true); + expect(config.isEnvironmentAllowed('production')).toBe(true); + }); + + it('should return false for non-allowed environments', () => { + expect(config.isEnvironmentAllowed('test')).toBe(false); + expect(config.isEnvironmentAllowed('qa')).toBe(false); + }); + }); + + describe('updateMetadata', () => { + it('should update existing metadata', () => { + const config = EnvGuardConfigV2.createDefault('com.test.app'); + const originalModified = config._metadata?.lastModified; + + // Wait a tiny bit to ensure timestamp changes + setTimeout(() => { + config.updateMetadata('envg-cli@0.3.1'); + + expect(config._metadata?.modifiedBy).toBe('envg-cli@0.3.1'); + expect(config._metadata?.lastModified).not.toBe(originalModified); + }, 10); + }); + + it('should create metadata if not exists', () => { + const config = new EnvGuardConfigV2({ + $schema: 'https://envguard.dev/schemas/config/v2.json', + version: '2.0.0', + package: { name: 'test', type: 'manual' }, + environments: { + allowed: ['development'], + default: 'development', + naming: 'strict', + }, + paths: { + template: '.env.template', + manifest: '.envguard/manifest.json', + }, + validation: { + enabled: true, + strictMode: false, + enforceRotation: false, + }, + security: { + auditLog: false, + requireConfirmation: [], + allowedCommands: 'all', + }, + manifest: { version: '2.0.0', autoSync: true }, + }); + + expect(config._metadata).toBeUndefined(); + + config.updateMetadata('envg-cli@0.3.0'); + + expect(config._metadata).toBeDefined(); + expect(config._metadata?.created).toBeDefined(); + expect(config._metadata?.modifiedBy).toBe('envg-cli@0.3.0'); + }); + }); + + describe('isValid', () => { + it('should validate complete config', () => { + const config = EnvGuardConfigV2.createDefault('com.test.app'); + expect(config.isValid()).toBe(true); + }); + + it('should reject config with empty package name', () => { + const config = EnvGuardConfigV2.createDefault('com.test.app'); + config.package.name = ''; + expect(config.isValid()).toBe(false); + }); + + it('should reject config with empty allowed environments', () => { + const config = EnvGuardConfigV2.createDefault('com.test.app'); + config.environments.allowed = []; + expect(config.isValid()).toBe(false); + }); + + it('should reject config with default env not in allowed', () => { + const config = EnvGuardConfigV2.createDefault('com.test.app'); + config.environments.default = 'test'; + expect(config.isValid()).toBe(false); + }); + + it('should reject config with empty template path', () => { + const config = EnvGuardConfigV2.createDefault('com.test.app'); + config.paths.template = ''; + expect(config.isValid()).toBe(false); + }); + }); + + describe('toObject', () => { + it('should convert to plain object', () => { + const config = EnvGuardConfigV2.createDefault('com.test.app'); + const obj = config.toObject(); + + expect(obj.$schema).toBe('https://envguard.dev/schemas/config/v2.json'); + expect(obj.version).toBe('2.0.0'); + expect(obj.package.name).toBe('com.test.app'); + expect(obj.environments).toBeDefined(); + expect(obj.paths).toBeDefined(); + expect(obj.validation).toBeDefined(); + expect(obj.security).toBeDefined(); + expect(obj.manifest).toBeDefined(); + }); + + it('should include optional fields if present', () => { + const config = EnvGuardConfigV2.createDefault('com.test.app'); + const obj = config.toObject(); + + expect(obj._warnings).toBeDefined(); + expect(obj._metadata).toBeDefined(); + }); + }); +}); diff --git a/packages/core/__tests__/config/package-name-resolver.test.ts b/packages/core/__tests__/config/package-name-resolver.test.ts new file mode 100644 index 0000000..343c058 --- /dev/null +++ b/packages/core/__tests__/config/package-name-resolver.test.ts @@ -0,0 +1,184 @@ +/** + * @file package-name-resolver.test.ts + * @description Tests for PackageNameResolver + */ + +import { describe, it, expect } from 'vitest'; +import { + PackageNameResolver, + PackageNameStrategy, +} from '../../src/config/package-name-resolver'; + +describe('PackageNameResolver', () => { + describe('validate', () => { + it('should accept valid reverse domain names', () => { + expect(PackageNameResolver.validate('com.company.app')).toEqual({ + valid: true, + }); + expect(PackageNameResolver.validate('dev.myorg.project')).toEqual({ + valid: true, + }); + expect(PackageNameResolver.validate('local.my-app')).toEqual({ + valid: true, + }); + }); + + it('should accept npm package names with warning', () => { + const result = PackageNameResolver.validate('@envguard/node'); + expect(result.valid).toBe(true); + expect(result.error).toContain('reverse domain notation'); + }); + + it('should reject empty names', () => { + expect(PackageNameResolver.validate('')).toEqual({ + valid: false, + error: 'Package name cannot be empty', + }); + expect(PackageNameResolver.validate(' ')).toEqual({ + valid: false, + error: 'Package name cannot be empty', + }); + }); + + it('should reject names with spaces', () => { + expect(PackageNameResolver.validate('my app')).toEqual({ + valid: false, + error: 'Package name cannot contain spaces', + }); + }); + + it('should reject names with invalid characters', () => { + const result = PackageNameResolver.validate('my$app'); + expect(result.valid).toBe(false); + expect(result.error).toContain('letters, numbers'); + }); + }); + + describe('isReverseDomain', () => { + it('should identify reverse domain names', () => { + expect(PackageNameResolver.isReverseDomain('com.company.app')).toBe(true); + expect(PackageNameResolver.isReverseDomain('dev.envguard.cli')).toBe( + true + ); + expect(PackageNameResolver.isReverseDomain('local.my-app')).toBe(true); + }); + + it('should reject non-reverse domain names', () => { + expect(PackageNameResolver.isReverseDomain('my-app')).toBe(false); + expect(PackageNameResolver.isReverseDomain('@envguard/node')).toBe(false); + expect(PackageNameResolver.isReverseDomain('MyApp')).toBe(false); + }); + }); + + describe('npmToReverseDomain', () => { + it('should convert scoped npm names', () => { + expect(PackageNameResolver.npmToReverseDomain('@envguard/node')).toBe( + 'dev.envguard.node' + ); + expect(PackageNameResolver.npmToReverseDomain('@company/product')).toBe( + 'dev.company.product' + ); + }); + + it('should convert unscoped npm names', () => { + expect(PackageNameResolver.npmToReverseDomain('my-app')).toBe( + 'local.my-app' + ); + expect(PackageNameResolver.npmToReverseDomain('express')).toBe( + 'local.express' + ); + }); + + it('should handle empty input', () => { + expect(PackageNameResolver.npmToReverseDomain('')).toBe('local.my-app'); + }); + }); + + describe('gitToReverseDomain', () => { + it('should convert SSH git URLs', () => { + expect( + PackageNameResolver.gitToReverseDomain( + 'git@github.com:company/repo.git' + ) + ).toBe('com.github.company.repo'); + expect( + PackageNameResolver.gitToReverseDomain('git@gitlab.com:org/project.git') + ).toBe('com.gitlab.org.project'); + }); + + it('should convert HTTPS git URLs', () => { + expect( + PackageNameResolver.gitToReverseDomain( + 'https://github.com/company/repo.git' + ) + ).toBe('com.github.company.repo'); + expect( + PackageNameResolver.gitToReverseDomain('http://github.com/org/proj.git') + ).toBe('com.github.org.proj'); + }); + + it('should handle invalid git URLs', () => { + expect(PackageNameResolver.gitToReverseDomain('invalid-url')).toBe( + 'local.git-project' + ); + }); + }); + + describe('resolve', () => { + it('should use MANUAL strategy with fallback', async () => { + const result = await PackageNameResolver.resolve({ + strategy: PackageNameStrategy.MANUAL, + fallback: 'com.test.app', + }); + expect(result).toBe('com.test.app'); + }); + + it('should default to my-app for MANUAL without fallback', async () => { + const result = await PackageNameResolver.resolve({ + strategy: PackageNameStrategy.MANUAL, + }); + expect(result).toBe('my-app'); + }); + + it('should throw on invalid MANUAL fallback', async () => { + await expect( + PackageNameResolver.resolve({ + strategy: PackageNameStrategy.MANUAL, + fallback: 'invalid name with spaces', + }) + ).rejects.toThrow('Package name cannot contain spaces'); + }); + + it('should handle unknown strategy', async () => { + await expect( + PackageNameResolver.resolve({ + strategy: 'unknown' as PackageNameStrategy, + }) + ).rejects.toThrow('Unknown strategy'); + }); + }); + + describe('suggest', () => { + it('should return unique suggestions', async () => { + const suggestions = await PackageNameResolver.suggest(process.cwd()); + expect(Array.isArray(suggestions)).toBe(true); + expect(suggestions.length).toBeGreaterThan(0); + + // Check for uniqueness + const uniqueSuggestions = new Set(suggestions); + expect(uniqueSuggestions.size).toBe(suggestions.length); + }); + + it('should prioritize reverse domain format', async () => { + const suggestions = await PackageNameResolver.suggest(process.cwd()); + // First suggestion should be reverse domain if possible + if (suggestions.length > 0) { + expect( + PackageNameResolver.isReverseDomain(suggestions[0]) || + suggestions[0].startsWith('dev.') || + suggestions[0].startsWith('local.') + ).toBe(true); + } + }); + }); +}); diff --git a/packages/core/src/config/config-migrator.ts b/packages/core/src/config/config-migrator.ts new file mode 100644 index 0000000..1c3ad3e --- /dev/null +++ b/packages/core/src/config/config-migrator.ts @@ -0,0 +1,191 @@ +/** + * @module @envguard/core/config + * @file config-migrator.ts + * @description Config migration utilities for v1 → v2 + */ + +import fs from 'fs/promises'; +import path from 'path'; +import { EnvGuardConfig, EnvGuardConfigV2 } from './config'; + +/** + * Config version type + */ +export type ConfigVersion = 'v1' | 'v2' | null; + +/** + * Migration result + */ +export interface IMigrationResult { + success: boolean; + version: ConfigVersion; + backupPath?: string; + error?: string; +} + +/** + * Config migrator - handles version detection and migration + */ +export class ConfigMigrator { + /** + * Detect config version from file + * + * @param configPath - Path to config.json file + * @returns Config version or null if file doesn't exist + */ + static async detectVersion(configPath: string): Promise { + try { + const content = await fs.readFile(configPath, 'utf-8'); + const raw = JSON.parse(content); + + // Check for v2 version field + if (raw.version === '2.0.0') { + return 'v2'; + } + + // Check for v1 structure (package is a string) + if (raw.package && typeof raw.package === 'string') { + return 'v1'; + } + + return null; + } catch { + return null; + } + } + + /** + * Migrate v1 config to v2 + * + * @param v1Config - v1 config instance + * @param cliVersion - CLI version for metadata + * @returns v2 config instance + */ + static migrateV1ToV2( + v1Config: EnvGuardConfig, + cliVersion: string = '0.3.0' + ): EnvGuardConfigV2 { + const packageName = v1Config.getPackage(); + + // Create v2 config with defaults + const v2Config = EnvGuardConfigV2.createDefault(packageName, cliVersion); + + // Migrate existing fields + v2Config.environments.default = v1Config.getDefaultEnvironment(); + v2Config.paths.template = v1Config.getTemplateFile(); + + // Keep manifest version if it exists + const manifestVersion = v1Config.getManifestVersion(); + if (manifestVersion) { + v2Config.manifest.version = manifestVersion; + } + + return v2Config; + } + + /** + * Create backup of v1 config + * + * @param v1Config - v1 config instance + * @param projectRoot - Project root directory + * @returns Path to backup file + */ + static async backupV1Config( + v1Config: EnvGuardConfig, + projectRoot: string = process.cwd() + ): Promise { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const backupPath = path.join( + projectRoot, + '.envguard', + `config.v1.backup.${timestamp}.json` + ); + + await fs.writeFile( + backupPath, + JSON.stringify(v1Config.toObject(), null, 2) + ); + + return backupPath; + } + + /** + * Perform full migration from v1 to v2 with backup + * + * @param configPath - Path to config.json + * @param v1Config - v1 config instance + * @param cliVersion - CLI version for metadata + * @returns Migration result + */ + static async performMigration( + configPath: string, + v1Config: EnvGuardConfig, + cliVersion: string = '0.3.0' + ): Promise { + try { + const projectRoot = path.dirname(path.dirname(configPath)); + + // Create backup + const backupPath = await this.backupV1Config(v1Config, projectRoot); + + // Migrate to v2 + const v2Config = this.migrateV1ToV2(v1Config, cliVersion); + + // Write v2 config + await fs.writeFile( + configPath, + JSON.stringify(v2Config.toObject(), null, 2) + ); + + return { + success: true, + version: 'v2', + backupPath, + }; + } catch (error) { + return { + success: false, + version: 'v1', + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + } + + /** + * Check if migration is needed + * + * @param configPath - Path to config.json + * @returns True if migration needed (v1 detected) + */ + static async needsMigration(configPath: string): Promise { + const version = await this.detectVersion(configPath); + return version === 'v1'; + } + + /** + * Load config from file (handles both v1 and v2) + * + * @param configPath - Path to config.json + * @returns Config instance (v1 or v2) or null if not found + */ + static async loadConfig( + configPath: string + ): Promise { + try { + const content = await fs.readFile(configPath, 'utf-8'); + const raw = JSON.parse(content); + + const version = await this.detectVersion(configPath); + + if (version === 'v2') { + return new EnvGuardConfigV2(raw); + } else if (version === 'v1') { + return new EnvGuardConfig(raw); + } + + return null; + } catch { + return null; + } + } +} diff --git a/packages/core/src/config/config.manager.ts b/packages/core/src/config/config.manager.ts index c6be6a3..d783bcf 100644 --- a/packages/core/src/config/config.manager.ts +++ b/packages/core/src/config/config.manager.ts @@ -5,9 +5,10 @@ */ import path from 'path'; -import { EnvGuardConfig } from './config'; +import { EnvGuardConfig, EnvGuardConfigV2 } from './config'; import { ConfigParser } from './config.parser'; import { ConfigFactory } from './config.factory'; +import { ConfigMigrator } from './config-migrator'; /** * Manages EnvGuard configuration operations and business logic @@ -47,12 +48,48 @@ export class ConfigManager { } /** - * Load config from disk + * Load config from disk (v1 or v2) * - * @returns EnvGuardConfig instance or null if not initialized + * @returns EnvGuardConfig or EnvGuardConfigV2 instance or null if not initialized */ - async load(): Promise { - return await this.parser.readFromFile(this.configPath); + async load(): Promise { + return await ConfigMigrator.loadConfig(this.configPath); + } + + /** + * Load config and auto-migrate if needed + * + * @param cliVersion - CLI version for metadata + * @returns EnvGuardConfigV2 instance or null if not initialized + */ + async loadOrMigrate( + cliVersion: string = '0.3.0' + ): Promise { + const config = await this.load(); + + if (!config) { + return null; + } + + // Already v2 + if (config instanceof EnvGuardConfigV2) { + return config; + } + + // Need migration from v1 + const result = await ConfigMigrator.performMigration( + this.configPath, + config, + cliVersion + ); + + if (!result.success) { + throw new Error(`Migration failed: ${result.error}`); + } + + // Reload the migrated config + const migratedConfig = await this.load(); + return migratedConfig as EnvGuardConfigV2; } /** @@ -76,7 +113,9 @@ export class ConfigManager { if (!config) { throw new Error('EnvGuard not initialized. Run "envguard init" first.'); } - return config.getPackage(); + return config instanceof EnvGuardConfigV2 + ? config.getPackageName() + : config.getPackage(); } /** @@ -145,11 +184,12 @@ export class ConfigManager { } /** - * Create a new config file + * Create a new config file (v1 - legacy) * * @param packageName - Package name * @param templateFile - Template file path * @returns Created config instance + * @deprecated Use createV2 instead */ async create( packageName: string, @@ -160,6 +200,22 @@ export class ConfigManager { return config; } + /** + * Create a new v2 config file + * + * @param packageName - Package name + * @param cliVersion - CLI version for metadata + * @returns Created v2 config instance + */ + async createV2( + packageName: string, + cliVersion: string = '0.3.0' + ): Promise { + const config = EnvGuardConfigV2.createDefault(packageName, cliVersion); + await this.parser.writeToFile(this.configPath, config); + return config; + } + /** * Update existing config * @@ -179,20 +235,37 @@ export class ConfigManager { throw new Error('EnvGuard not initialized. Run "envguard init" first.'); } - if (updates.package !== undefined) { - config.package = updates.package; - } - if (updates.templateFile !== undefined) { - config.templateFile = updates.templateFile; - } - if (updates.manifestVersion !== undefined) { - config.manifestVersion = updates.manifestVersion; - } - if (updates.defaultEnvironment !== undefined) { - config.defaultEnvironment = updates.defaultEnvironment; + if (config instanceof EnvGuardConfigV2) { + // Update v2 config + if (updates.package !== undefined) { + config.package.name = updates.package; + } + if (updates.templateFile !== undefined) { + config.paths.template = updates.templateFile; + } + if (updates.manifestVersion !== undefined) { + config.manifest.version = updates.manifestVersion; + } + if (updates.defaultEnvironment !== undefined) { + config.environments.default = updates.defaultEnvironment; + } + } else { + // Update v1 config + if (updates.package !== undefined) { + config.package = updates.package; + } + if (updates.templateFile !== undefined) { + config.templateFile = updates.templateFile; + } + if (updates.manifestVersion !== undefined) { + config.manifestVersion = updates.manifestVersion; + } + if (updates.defaultEnvironment !== undefined) { + config.defaultEnvironment = updates.defaultEnvironment; + } } - await this.save(config); + await this.parser.writeToFile(this.configPath, config); } /** diff --git a/packages/core/src/config/config.parser.ts b/packages/core/src/config/config.parser.ts index 41bc7be..11647bc 100644 --- a/packages/core/src/config/config.parser.ts +++ b/packages/core/src/config/config.parser.ts @@ -7,7 +7,7 @@ import fs from 'fs/promises'; import path from 'path'; import { z } from 'zod'; -import { EnvGuardConfig, IEnvGuardConfig } from './config'; +import { EnvGuardConfig, EnvGuardConfigV2, IEnvGuardConfig } from './config'; import { ConfigFactory } from './config.factory'; import { EnvGuardConfigSchema } from '../types/types.schema'; @@ -51,13 +51,16 @@ export class ConfigParser { } /** - * Write config to JSON file + * Write config to JSON file (supports both v1 and v2) * * @param filePath - Path to config file - * @param config - Config instance to write + * @param config - Config instance to write (v1 or v2) * @throws Error if write fails */ - async writeToFile(filePath: string, config: EnvGuardConfig): Promise { + async writeToFile( + filePath: string, + config: EnvGuardConfig | EnvGuardConfigV2 + ): Promise { try { const json = this.serializeToJSON(config); const dir = path.dirname(filePath); @@ -89,13 +92,13 @@ export class ConfigParser { } /** - * Serialize EnvGuardConfig to JSON string + * Serialize EnvGuardConfig to JSON string (supports both v1 and v2) * - * @param config - Config instance + * @param config - Config instance (v1 or v2) * @returns Formatted JSON string */ - serializeToJSON(config: EnvGuardConfig): string { - const data: IEnvGuardConfig = config.toObject(); + serializeToJSON(config: EnvGuardConfig | EnvGuardConfigV2): string { + const data = config.toObject(); return JSON.stringify(data, null, 2); } diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 23f718b..cf62e51 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -1,11 +1,13 @@ /** * @module @envguard/cli/core/config * @file config.ts - * @description Pure data model for EnvGuard configuration + * @description Pure data model for EnvGuard configuration (v1 and v2) */ +import { PackageNameResolver } from './package-name-resolver'; + /** - * Interface for EnvGuard configuration + * Interface for EnvGuard configuration v1 (legacy) */ export interface IEnvGuardConfig { package: string; @@ -14,6 +16,83 @@ export interface IEnvGuardConfig { defaultEnvironment: string; } +/** + * Package configuration for v2 + */ +export interface IPackageConfig { + name: string; + displayName?: string; + type: 'reverse-domain' | 'npm' | 'manual'; +} + +/** + * Environment configuration for v2 + */ +export interface IEnvironmentConfig { + allowed: string[]; + default: string; + naming: 'strict' | 'relaxed'; +} + +/** + * Paths configuration for v2 + */ +export interface IPathsConfig { + template: string; + manifest: string; +} + +/** + * Validation configuration for v2 + */ +export interface IValidationConfig { + enabled: boolean; + strictMode: boolean; + enforceRotation: boolean; +} + +/** + * Security configuration for v2 + */ +export interface ISecurityConfig { + auditLog: boolean; + requireConfirmation: ('delete' | 'export' | 'migrate')[]; + allowedCommands: string[] | 'all'; +} + +/** + * Manifest configuration for v2 + */ +export interface IManifestConfig { + version: string; + autoSync: boolean; +} + +/** + * Config metadata for v2 + */ +export interface IConfigMetadata { + created: string; + lastModified: string; + modifiedBy: string; +} + +/** + * Interface for EnvGuard configuration v2 + */ +export interface IEnvGuardConfigV2 { + $schema: string; + version: '2.0.0'; + package: IPackageConfig; + environments: IEnvironmentConfig; + paths: IPathsConfig; + validation: IValidationConfig; + security: ISecurityConfig; + manifest: IManifestConfig; + _warnings?: Record; + _metadata?: IConfigMetadata; +} + /** * EnvGuard configuration class - pure data model * @@ -111,3 +190,245 @@ export class EnvGuardConfig implements IEnvGuardConfig { ); } } + +/** + * EnvGuard configuration v2 class - enhanced data model + * + * @remarks + * This class represents the v2 project-level configuration with enhanced features: + * - Structured package configuration with type detection + * - Environment management with allowed list + * - Validation and security policies + * - Metadata tracking + * + * @example + * ```ts + * const config = EnvGuardConfigV2.createDefault('com.company.app'); + * config.environments.allowed.push('staging'); + * ``` + */ +export class EnvGuardConfigV2 implements IEnvGuardConfigV2 { + $schema = 'https://envguard.dev/schemas/config/v2.json'; + version = '2.0.0' as const; + package: IPackageConfig; + environments: IEnvironmentConfig; + paths: IPathsConfig; + validation: IValidationConfig; + security: ISecurityConfig; + manifest: IManifestConfig; + _warnings?: Record; + _metadata?: IConfigMetadata; + + constructor(data: IEnvGuardConfigV2) { + this.package = data.package; + this.environments = data.environments; + this.paths = data.paths; + this.validation = data.validation; + this.security = data.security; + this.manifest = data.manifest; + if (data._warnings !== undefined) { + this._warnings = data._warnings; + } + if (data._metadata !== undefined) { + this._metadata = data._metadata; + } + } + + /** + * Get package name + * + * @returns Package name + */ + getPackageName(): string { + return this.package.name; + } + + /** + * Get package display name + * + * @returns Display name or package name if not set + */ + getPackageDisplayName(): string { + return this.package.displayName || this.package.name; + } + + /** + * Get template file path + * + * @returns Template file path + */ + getTemplateFile(): string { + return this.paths.template; + } + + /** + * Get manifest file path + * + * @returns Manifest file path + */ + getManifestFile(): string { + return this.paths.manifest; + } + + /** + * Get default environment + * + * @returns Default environment name + */ + getDefaultEnvironment(): string { + return this.environments.default; + } + + /** + * Get allowed environments + * + * @returns Array of allowed environment names + */ + getAllowedEnvironments(): string[] { + return this.environments.allowed; + } + + /** + * Check if an environment is allowed + * + * @param env - Environment name + * @returns True if allowed + */ + isEnvironmentAllowed(env: string): boolean { + return this.environments.allowed.includes(env); + } + + /** + * Get manifest version + * + * @returns Manifest version + */ + getManifestVersion(): string { + return this.manifest.version; + } + + /** + * Convert config to plain object + * + * @returns Plain object representation + */ + toObject(): IEnvGuardConfigV2 { + const obj: IEnvGuardConfigV2 = { + $schema: this.$schema, + version: this.version, + package: this.package, + environments: this.environments, + paths: this.paths, + validation: this.validation, + security: this.security, + manifest: this.manifest, + }; + + if (this._warnings !== undefined) { + obj._warnings = this._warnings; + } + if (this._metadata !== undefined) { + obj._metadata = this._metadata; + } + + return obj; + } + + /** + * Update metadata timestamps + */ + updateMetadata(modifiedBy: string): void { + if (!this._metadata) { + this._metadata = { + created: new Date().toISOString(), + lastModified: new Date().toISOString(), + modifiedBy, + }; + } else { + this._metadata.lastModified = new Date().toISOString(); + this._metadata.modifiedBy = modifiedBy; + } + } + + /** + * Check if config is valid (has all required fields) + * + * @returns True if valid + */ + isValid(): boolean { + return ( + !!this.package && + !!this.package.name && + this.package.name.trim().length > 0 && + !!this.environments && + Array.isArray(this.environments.allowed) && + this.environments.allowed.length > 0 && + !!this.environments.default && + this.environments.allowed.includes(this.environments.default) && + !!this.paths && + !!this.paths.template && + this.paths.template.trim().length > 0 + ); + } + + /** + * Create a default v2 config + * + * @param packageName - Package name + * @param cliVersion - CLI version for metadata (optional) + * @returns New EnvGuardConfigV2 instance + */ + static createDefault( + packageName: string, + cliVersion: string = '0.3.0' + ): EnvGuardConfigV2 { + // Detect package type + let packageType: 'reverse-domain' | 'npm' | 'manual' = 'manual'; + if (PackageNameResolver.isReverseDomain(packageName)) { + packageType = 'reverse-domain'; + } else if (packageName.startsWith('@') || packageName.includes('/')) { + packageType = 'npm'; + } + + return new EnvGuardConfigV2({ + $schema: 'https://envguard.dev/schemas/config/v2.json', + version: '2.0.0', + package: { + name: packageName, + type: packageType, + }, + environments: { + allowed: ['development', 'staging', 'production'], + default: 'development', + naming: 'strict', + }, + paths: { + template: '.env.template', + manifest: '.envguard/manifest.json', + }, + validation: { + enabled: true, + strictMode: false, + enforceRotation: false, + }, + security: { + auditLog: false, + requireConfirmation: ['delete', 'export'], + allowedCommands: 'all', + }, + manifest: { + version: '2.0.0', + autoSync: true, + }, + _warnings: { + manualEdit: + "Editing this file manually may break EnvGuard. Use 'envg config' commands instead.", + }, + _metadata: { + created: new Date().toISOString(), + lastModified: new Date().toISOString(), + modifiedBy: `envg-cli@${cliVersion}`, + }, + }); + } +} diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts index aefc9b4..5733565 100644 --- a/packages/core/src/config/index.ts +++ b/packages/core/src/config/index.ts @@ -6,5 +6,25 @@ export { ConfigManager } from './config.manager'; export { ConfigParser } from './config.parser'; export { ConfigFactory } from './config.factory'; -export { EnvGuardConfig } from './config'; -export type { IEnvGuardConfig } from './config'; +export { EnvGuardConfig, EnvGuardConfigV2 } from './config'; +export type { + IEnvGuardConfig, + IEnvGuardConfigV2, + IPackageConfig, + IEnvironmentConfig, + IPathsConfig, + IValidationConfig, + ISecurityConfig, + IManifestConfig, + IConfigMetadata, +} from './config'; +export { + PackageNameResolver, + PackageNameStrategy, +} from './package-name-resolver'; +export type { + IPackageNameOptions, + IValidationResult, +} from './package-name-resolver'; +export { ConfigMigrator } from './config-migrator'; +export type { ConfigVersion, IMigrationResult } from './config-migrator'; diff --git a/packages/core/src/config/package-name-resolver.ts b/packages/core/src/config/package-name-resolver.ts new file mode 100644 index 0000000..7344551 --- /dev/null +++ b/packages/core/src/config/package-name-resolver.ts @@ -0,0 +1,356 @@ +/** + * @module @envguard/core/config + * @file package-name-resolver.ts + * @description Multi-strategy package name resolution for multi-language support + */ + +import fs from 'fs/promises'; +import path from 'path'; +import { exec } from 'child_process'; +import { promisify } from 'util'; + +const execAsync = promisify(exec); + +/** + * Package name resolution strategies + */ +export enum PackageNameStrategy { + AUTO = 'auto', // Try all strategies in order + REVERSE_DOMAIN = 'reverse-domain', // com.company.app + NPM = 'npm', // @scope/name or name + MANUAL = 'manual', // User-provided +} + +/** + * Options for package name resolution + */ +export interface IPackageNameOptions { + strategy?: PackageNameStrategy; + projectRoot?: string; + fallback?: string; +} + +/** + * Validation result + */ +export interface IValidationResult { + valid: boolean; + error?: string; +} + +/** + * Multi-strategy package name resolver + * Supports reverse domain notation, npm names, and multi-language projects + */ +export class PackageNameResolver { + /** + * Resolve package name using specified strategy + * + * @param options - Resolution options + * @returns Resolved package name + */ + static async resolve(options: IPackageNameOptions = {}): Promise { + const strategy = options.strategy || PackageNameStrategy.AUTO; + + switch (strategy) { + case PackageNameStrategy.REVERSE_DOMAIN: + return await this.resolveReverseDomain(options); + + case PackageNameStrategy.NPM: + return await this.resolveFromNpm(options); + + case PackageNameStrategy.AUTO: + return await this.resolveAuto(options); + + case PackageNameStrategy.MANUAL: + return this.validateAndReturn(options.fallback || 'my-app'); + + default: + throw new Error(`Unknown strategy: ${strategy}`); + } + } + + /** + * Validate package name format + * + * @param name - Package name to validate + * @returns Validation result + */ + static validate(name: string): IValidationResult { + // Not empty + if (!name || name.trim().length === 0) { + return { valid: false, error: 'Package name cannot be empty' }; + } + + // No spaces + if (/\s/.test(name)) { + return { valid: false, error: 'Package name cannot contain spaces' }; + } + + // Valid characters + if (!/^[@a-zA-Z0-9._/-]+$/.test(name)) { + return { + valid: false, + error: + 'Package name can only contain letters, numbers, dots, hyphens, underscores, and slashes', + }; + } + + // Check if it's reverse domain notation + if (this.isReverseDomain(name)) { + return { valid: true }; + } + + // Valid but not reverse domain - show warning + return { + valid: true, + error: + 'Consider using reverse domain notation (e.g., com.company.app) for global uniqueness', + }; + } + + /** + * Suggest package names from project context + * + * @param projectRoot - Project root directory + * @returns Array of suggested package names + */ + static async suggest(projectRoot: string = process.cwd()): Promise { + const suggestions: string[] = []; + + // Try npm name detection + const npmName = await this.detectNpmName(projectRoot); + if (npmName) { + // Add reverse domain version first (recommended) + suggestions.push(this.npmToReverseDomain(npmName)); + // Add original npm name + if (npmName !== this.npmToReverseDomain(npmName)) { + suggestions.push(npmName); + } + } + + // Try git remote detection + const gitRemote = await this.detectGitRemote(projectRoot); + if (gitRemote) { + const gitDomain = this.gitToReverseDomain(gitRemote); + if (!suggestions.includes(gitDomain)) { + suggestions.push(gitDomain); + } + } + + // Add directory name as last resort + const dirName = await this.detectDirectoryName(projectRoot); + if (dirName) { + const localName = `local.${dirName}`; + if (!suggestions.includes(localName)) { + suggestions.push(localName); + } + } + + return suggestions; + } + + /** + * Check if a name follows reverse domain notation + * + * @param name - Name to check + * @returns True if reverse domain format + */ + static isReverseDomain(name: string): boolean { + // Match: com.company.app, dev.myorg.project, local.my-app + // Pattern: tld.domain.name where each part can have letters, numbers, hyphens + return /^[a-z]+\.[a-z0-9-]+(\.[a-z0-9-]+)*$/i.test(name); + } + + /** + * Convert npm package name to reverse domain notation + * + * @param npmName - npm package name + * @returns Reverse domain format + */ + static npmToReverseDomain(npmName: string): string { + if (!npmName) { + return 'local.my-app'; + } + + // @envguard/node → dev.envguard.node + if (npmName.startsWith('@')) { + const parts = npmName.slice(1).split('/'); + return `dev.${parts.join('.')}`; + } + + // my-app → local.my-app + return `local.${npmName}`; + } + + /** + * Convert git remote URL to reverse domain notation + * + * @param gitRemote - Git remote URL + * @returns Reverse domain format + */ + static gitToReverseDomain(gitRemote: string): string { + // Extract org/repo from git URLs: + // git@github.com:company/repo.git → com.github.company.repo + // https://github.com/company/repo.git → com.github.company.repo + + const sshMatch = gitRemote.match(/git@([^:]+):([^/]+)\/([^.]+)\.git/); + if (sshMatch && sshMatch[1] && sshMatch[2] && sshMatch[3]) { + const [, host, org, repo] = sshMatch; + const domain = host.split('.').reverse().join('.'); + return `${domain}.${org}.${repo}`; + } + + const httpsMatch = gitRemote.match( + /https?:\/\/([^/]+)\/([^/]+)\/([^.]+)\.git/ + ); + if (httpsMatch && httpsMatch[1] && httpsMatch[2] && httpsMatch[3]) { + const [, host, org, repo] = httpsMatch; + const domain = host.split('.').reverse().join('.'); + return `${domain}.${org}.${repo}`; + } + + return 'local.git-project'; + } + + /** + * Resolve using AUTO strategy - try all methods + * + * @param options - Resolution options + * @returns Resolved package name + */ + private static async resolveAuto( + options: IPackageNameOptions + ): Promise { + const projectRoot = options.projectRoot || process.cwd(); + + // Try npm first + const npmName = await this.detectNpmName(projectRoot); + if (npmName) { + return this.npmToReverseDomain(npmName); + } + + // Try git remote + const gitRemote = await this.detectGitRemote(projectRoot); + if (gitRemote) { + return this.gitToReverseDomain(gitRemote); + } + + // Try directory name + const dirName = await this.detectDirectoryName(projectRoot); + if (dirName) { + return `local.${dirName}`; + } + + // Final fallback + return options.fallback || 'local.my-app'; + } + + /** + * Resolve using NPM strategy + * + * @param options - Resolution options + * @returns Package name from package.json + */ + private static async resolveFromNpm( + options: IPackageNameOptions + ): Promise { + const projectRoot = options.projectRoot || process.cwd(); + const npmName = await this.detectNpmName(projectRoot); + return npmName || options.fallback || 'my-app'; + } + + /** + * Resolve using REVERSE_DOMAIN strategy + * + * @param options - Resolution options + * @returns Reverse domain package name + */ + private static async resolveReverseDomain( + options: IPackageNameOptions + ): Promise { + const projectRoot = options.projectRoot || process.cwd(); + + // Try npm to reverse domain + const npmName = await this.detectNpmName(projectRoot); + if (npmName) { + return this.npmToReverseDomain(npmName); + } + + // Try git to reverse domain + const gitRemote = await this.detectGitRemote(projectRoot); + if (gitRemote) { + return this.gitToReverseDomain(gitRemote); + } + + // Fallback + return options.fallback || 'local.my-app'; + } + + /** + * Validate and return package name + * + * @param name - Package name + * @returns Validated name + */ + private static validateAndReturn(name: string): string { + const validation = this.validate(name); + if (!validation.valid) { + throw new Error(validation.error || 'Invalid package name'); + } + return name; + } + + /** + * Detect npm package name from package.json + * + * @param projectRoot - Project root directory + * @returns Package name or null + */ + private static async detectNpmName( + projectRoot: string + ): Promise { + const pkgJsonPath = path.join(projectRoot, 'package.json'); + + try { + const content = await fs.readFile(pkgJsonPath, 'utf-8'); + const pkgJson = JSON.parse(content); + return pkgJson.name || null; + } catch { + return null; + } + } + + /** + * Detect git remote URL + * + * @param projectRoot - Project root directory + * @returns Git remote URL or null + */ + private static async detectGitRemote( + projectRoot: string + ): Promise { + try { + const { stdout } = await execAsync('git remote get-url origin', { + cwd: projectRoot, + }); + return stdout.trim() || null; + } catch { + return null; + } + } + + /** + * Detect directory name + * + * @param projectRoot - Project root directory + * @returns Directory name + */ + private static async detectDirectoryName( + projectRoot: string + ): Promise { + const dirName = path.basename(projectRoot); + // Sanitize: remove spaces, special chars + return dirName.toLowerCase().replace(/[^a-z0-9-]/g, '-'); + } +} diff --git a/packages/node/CHANGELOG.md b/packages/node/CHANGELOG.md index e2e8278..9ea9f94 100644 --- a/packages/node/CHANGELOG.md +++ b/packages/node/CHANGELOG.md @@ -5,6 +5,89 @@ All notable changes to `@envguard/node` will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +## [0.3.0] - 2025-10-26 + +### BREAKING CHANGES + +#### 1. Auto-Loading Completely Removed + +The auto-load feature has been **removed** due to a critical race condition. + +**Removed:** + +- `require('@envguard/node/config')` ❌ +- `import '@envguard/node/config'` ❌ +- `node --require @envguard/node/register` ❌ + +**Reason:** The async IIFE implementation returned immediately without waiting for secrets to load, causing timing issues where `process.env` secrets were undefined. + +**Migration Required:** + +```javascript +// ❌ Old (v0.2.x) - NO LONGER WORKS +require('@envguard/node/config'); +console.log(process.env.API_KEY); // undefined! + +// ✅ New (v0.3.0+) - Explicit async +(async () => { + await require('@envguard/node').config(); + console.log(process.env.API_KEY); // Works! +})(); +``` + +**For --require users:** + +```javascript +// entry.js +(async () => { + await require('@envguard/node').config({ + env: process.env.ENVGUARD_ENV, + debug: process.env.ENVGUARD_DEBUG === 'true', + }); + require('./app'); // Your actual app +})(); +``` + +```bash +# Run: node entry.js (instead of node --require @envguard/node/register app.js) +``` + +### + +Removed + +- **config.ts** - Auto-load entry point +- **register.ts** - --require hook +- **Exports**: `./config` and `./register` from package.json +- **Build**: config/register from tsup.config.ts + +### Added + +- **MIGRATION_GUIDE_V03.md** - Comprehensive migration guide with examples +- **Updated documentation** - All README examples show async usage +- **Framework examples** - Express, Next.js, NestJS updated for v0.3.0 + +### Fixed + +- **Critical race condition** - Secrets now guaranteed to load before app starts +- **Timeout errors** - No more "Timeout waiting for secrets to load" +- **Event loop blocking** - Removed broken synchronous wrapper + +### Documentation + +- Updated Quick Start with breaking change warning +- Added migration checklist +- Updated all framework integration examples +- Improved error messages and warnings + +### Migration Path + +See [MIGRATION_GUIDE_V03.md](./MIGRATION_GUIDE_V03.md) for detailed migration instructions. + +--- + ## [0.2.0] - 2025-01-25 ### BREAKING CHANGES @@ -69,6 +152,8 @@ const result = await envguard.config(); - Testing utilities - Full TypeScript support +[Unreleased]: https://github.com/amannirala13/envguard/compare/@envguard/node@0.3.0...HEAD +[0.3.0]: https://github.com/amannirala13/envguard/compare/@envguard/node@0.2.0...@envguard/node@0.3.0 [0.2.0]: https://github.com/amannirala13/envguard/compare/@envguard/node@0.1.3...@envguard/node@0.2.0 [0.1.3]: https://github.com/amannirala13/envguard/compare/@envguard/node@0.1.2...@envguard/node@0.1.3 [0.1.2]: https://github.com/amannirala13/envguard/compare/@envguard/node@0.1.1...@envguard/node@0.1.2 diff --git a/packages/node/MIGRATION_GUIDE_V03.md b/packages/node/MIGRATION_GUIDE_V03.md new file mode 100644 index 0000000..719683e --- /dev/null +++ b/packages/node/MIGRATION_GUIDE_V03.md @@ -0,0 +1,153 @@ +# Migration Guide: EnvGuard v0.2.x → v0.3.0 + +## Breaking Changes + +### 1. Auto-Loading Removed + +**Reason**: The auto-load implementation had a race condition that caused secrets to be unavailable immediately after import. + +**Before (v0.2.x):** + +```javascript +// ❌ This no longer works +require('@envguard/node/config'); +console.log(process.env.API_KEY); // undefined! +``` + +**After (v0.3.0):** + +```javascript +// ✅ Explicit async loading +(async () => { + await require('@envguard/node').config(); + console.log(process.env.API_KEY); // Now it works! +})(); +``` + +### 2. --require Hook Removed + +**Before (v0.2.x):** + +```bash +# ❌ This no longer works +node --require @envguard/node/register app.js +``` + +**After (v0.3.0):** + +```javascript +// Create entry.js +(async () => { + await require('@envguard/node').config(); + require('./app'); +})(); +``` + +```bash +# Run with entry file +node entry.js +``` + +## Updated Examples + +### Express.js + +**Before:** + +```javascript +require('@envguard/node/config'); +const express = require('express'); +const app = express(); +app.listen(3000); +``` + +**After:** + +```javascript +const envguard = require('@envguard/node'); +const express = require('express'); + +(async () => { + await envguard.config(); + const app = express(); + app.listen(3000); +})(); +``` + +### Next.js + +**Before:** + +```javascript +// next.config.js +require('@envguard/node/config'); +module.exports = { + /* ... */ +}; +``` + +**After:** + +```javascript +// next.config.js +const envguard = require('@envguard/node'); + +module.exports = (async () => { + await envguard.config(); + return { + /* ... */ + }; +})(); +``` + +### ES Modules with Top-Level Await + +```javascript +// index.js (type: "module" in package.json) +import envguard from '@envguard/node'; + +// Top-level await (Node.js 14.8+) +await envguard.config(); + +// Your app code +import app from './app.js'; +app.start(); +``` + +## Why This Change? + +The previous auto-load implementation used a "fire-and-forget" async IIFE: + +```javascript +// Old implementation (broken) +(async () => { + await load(); // Runs async +})(); // Returns immediately, doesn't wait + +// Next line runs BEFORE secrets are loaded! +console.log(process.env.SECRET); // undefined +``` + +This caused timing issues where secrets weren't available when your app needed them. + +The new approach is explicit and guaranteed to work: + +```javascript +// New implementation (reliable) +await config(); // Waits for secrets to load +console.log(process.env.SECRET); // Always available +``` + +## Migration Checklist + +- [ ] Replace `require('@envguard/node/config')` with async `await envguard.config()` +- [ ] Replace `import '@envguard/node/config'` with `await envguard.config()` +- [ ] Replace `--require @envguard/node/register` with entry file approach +- [ ] Wrap app initialization in async IIFE or use top-level await +- [ ] Test that secrets are available before app starts +- [ ] Update CI/CD scripts if using --require flag + +## Need Help? + +- See full documentation: `packages/node/README.md` +- Report issues: https://github.com/amannirala13/envguard/issues diff --git a/packages/node/README.md b/packages/node/README.md index 5282ca9..7010eb9 100644 --- a/packages/node/README.md +++ b/packages/node/README.md @@ -62,16 +62,22 @@ npx envguard set DATABASE_URL postgres://localhost/mydb ### 3. Use in your app +⚠️ **v0.3.0 Breaking Change**: Auto-loading removed. See [MIGRATION_GUIDE_V03.md](./MIGRATION_GUIDE_V03.md) + **Before (dotenv):** ```javascript require('dotenv').config(); ``` -**After (EnvGuard):** +**After (EnvGuard v0.3.0):** ```javascript -require('@envguard/node/config'); +// EnvGuard requires async +(async () => { + await require('@envguard/node').config(); + // Your app code here +})(); ``` That's it! Your secrets now come from the OS keychain instead of `.env` files. diff --git a/packages/node/package.json b/packages/node/package.json index 56ea772..e908633 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -1,6 +1,6 @@ { "name": "@envguard/node", - "version": "0.2.0", + "version": "0.3.0", "description": "EnvGuard Node.js runtime - Drop-in dotenv replacement with OS keychain", "keywords": [ "envguard", @@ -38,16 +38,6 @@ "import": "./dist/index.js", "require": "./dist/index.cjs" }, - "./config": { - "types": "./dist/config.d.ts", - "import": "./dist/config.js", - "require": "./dist/config.cjs" - }, - "./register": { - "types": "./dist/register.d.ts", - "import": "./dist/register.js", - "require": "./dist/register.cjs" - }, "./testing": { "types": "./dist/testing.d.ts", "import": "./dist/testing.js", diff --git a/packages/node/src/config.ts b/packages/node/src/config.ts deleted file mode 100644 index 692b740..0000000 --- a/packages/node/src/config.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * @module @envguard/node/config - * @description Auto-loader entry point - * - * Usage: - * ```javascript - * // CommonJS - * require('@envguard/node/config'); - * - * // ESM - * import '@envguard/node/config'; - * ``` - * - * This will automatically load secrets from keychain into process.env - */ - -import { load } from './loader'; -import { logger } from './utils/logger'; - -// Auto-load on import -(async () => { - try { - logger.debug('Auto-loading secrets from keychain'); - - const result = await load({ - debug: process.env['ENVGUARD_DEBUG'] === 'true', - }); - - if (!result.success) { - logger.error('Failed to auto-load secrets'); - - if (result.errors.length > 0) { - result.errors.forEach((err) => { - logger.error(` ${err.key}: ${err.message}`); - }); - } - - // Don't exit in auto-load mode, just warn - logger.warn( - 'Application will continue with existing environment variables' - ); - } else { - logger.debug(`Auto-loaded ${result.count} secrets`); - } - } catch (error) { - logger.error('Failed to auto-load secrets:', error); - logger.warn( - 'Application will continue with existing environment variables' - ); - } -})(); diff --git a/packages/node/src/loader/index.ts b/packages/node/src/loader/index.ts index 0a35b51..14265b4 100644 --- a/packages/node/src/loader/index.ts +++ b/packages/node/src/loader/index.ts @@ -48,7 +48,11 @@ export async function load(options: LoadOptions = {}): Promise { throw new NotInitializedError(); } - const packageName = opts.packageName || config.getPackage(); + const packageName = + opts.packageName || + ('getPackageName' in config + ? config.getPackageName() + : config.getPackage()); logger.debug(`Package: ${packageName}`); // 3. Get list of keys from manifest diff --git a/packages/node/src/register.ts b/packages/node/src/register.ts deleted file mode 100644 index 2a13fb7..0000000 --- a/packages/node/src/register.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * @module @envguard/node/register - * @description Node.js --require hook entry point - * - * Usage: - * ```bash - * node --require @envguard/node/register app.js - * ``` - * - * Environment variables: - * - ENVGUARD_ENV: Override environment - * - ENVGUARD_DEBUG: Enable debug logging - */ - -import { load } from './loader'; -import { logger } from './utils/logger'; - -// Load secrets before application starts -(async () => { - logger.debug('Loading secrets via --require hook'); - - try { - const options: any = { - debug: process.env['ENVGUARD_DEBUG'] === 'true', - }; - - const envVar = process.env['ENVGUARD_ENV']; - if (envVar) { - options.environment = envVar; - } - - const result = await load(options); - - if (!result.success) { - logger.error('Failed to load secrets via --require hook'); - process.exit(1); - } - - logger.debug(`Loaded ${result.count} secrets via --require hook`); - } catch (error) { - logger.error('Fatal error loading secrets:', error); - process.exit(1); - } -})(); diff --git a/packages/node/tsup.config.ts b/packages/node/tsup.config.ts index 3ca765b..12e8951 100644 --- a/packages/node/tsup.config.ts +++ b/packages/node/tsup.config.ts @@ -5,8 +5,6 @@ export default defineConfig([ { entry: { index: 'src/index.ts', - config: 'src/config.ts', - register: 'src/register.ts', }, format: ['esm', 'cjs'], dts: true,